/ tests / test_meta_ads_media.py
test_meta_ads_media.py
  1  """Meta Ads 動画アップロード・カルーセル・コレクションクリエイティブのテスト
  2  
  3  TDD: テストを先に作成し、実装はこのテストが通るように行う。
  4  """
  5  
  6  from __future__ import annotations
  7  
  8  import json
  9  from pathlib import Path
 10  from typing import Any
 11  from unittest.mock import AsyncMock, MagicMock, patch
 12  
 13  import pytest
 14  
 15  from mureo.auth import MetaAdsCredentials
 16  
 17  # ---------------------------------------------------------------------------
 18  # フィクスチャ
 19  # ---------------------------------------------------------------------------
 20  
 21  
 22  @pytest.fixture()
 23  def meta_client() -> Any:
 24      """テスト用MetaAdsApiClientを作成する"""
 25      from mureo.meta_ads.client import MetaAdsApiClient
 26  
 27      return MetaAdsApiClient(
 28          access_token="test-token",
 29          ad_account_id="act_123456",
 30      )
 31  
 32  
 33  @pytest.fixture()
 34  def sample_video(tmp_path: Path) -> Path:
 35      """テスト用のダミー動画ファイル(mp4)を作成する"""
 36      video = tmp_path / "test_video.mp4"
 37      video.write_bytes(b"\x00\x00\x00\x1cftypisom" + b"\x00" * 100)
 38      return video
 39  
 40  
 41  @pytest.fixture()
 42  def sample_mov(tmp_path: Path) -> Path:
 43      """テスト用のダミーMOVファイルを作成する"""
 44      video = tmp_path / "test_video.mov"
 45      video.write_bytes(b"\x00" * 100)
 46      return video
 47  
 48  
 49  # ---------------------------------------------------------------------------
 50  # 1. test_upload_ad_video — URL指定動画アップロード
 51  # ---------------------------------------------------------------------------
 52  
 53  
 54  @pytest.mark.unit
 55  class TestUploadAdVideo:
 56      """URL指定での動画アップロード"""
 57  
 58      @pytest.mark.asyncio()
 59      async def test_upload_ad_video(self, meta_client: Any) -> None:
 60          """URL指定で動画をアップロードし、video_idが返ること"""
 61          meta_client._post = AsyncMock(return_value={"id": "video_123"})
 62  
 63          result = await meta_client.upload_ad_video(
 64              video_url="https://example.com/video.mp4",
 65              title="テスト動画",
 66          )
 67  
 68          assert result["id"] == "video_123"
 69          meta_client._post.assert_awaited_once()
 70          call_args = meta_client._post.call_args
 71          assert "/advideos" in call_args[0][0]
 72          data = (
 73              call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
 74          )
 75          assert data.get("file_url") == "https://example.com/video.mp4"
 76          assert data.get("title") == "テスト動画"
 77  
 78      @pytest.mark.asyncio()
 79      async def test_upload_ad_video_without_title(self, meta_client: Any) -> None:
 80          """title省略時もアップロードできること"""
 81          meta_client._post = AsyncMock(return_value={"id": "video_456"})
 82  
 83          result = await meta_client.upload_ad_video(
 84              video_url="https://example.com/video.mp4",
 85          )
 86  
 87          assert result["id"] == "video_456"
 88          call_args = meta_client._post.call_args
 89          data = (
 90              call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
 91          )
 92          assert "title" not in data
 93  
 94  
 95  # ---------------------------------------------------------------------------
 96  # 2. test_upload_ad_video_file — ファイル指定動画アップロード
 97  # ---------------------------------------------------------------------------
 98  
 99  
100  @pytest.mark.unit
101  class TestUploadAdVideoFile:
102      """ローカルファイルからの動画アップロード"""
103  
104      @pytest.mark.asyncio()
105      async def test_upload_ad_video_file(
106          self, meta_client: Any, sample_video: Path
107      ) -> None:
108          """正常アップロードでvideo_idが返ること"""
109          mock_response = MagicMock()
110          mock_response.status_code = 200
111          mock_response.json.return_value = {"id": "video_789"}
112          mock_response.raise_for_status = MagicMock()
113  
114          mock_http_client = AsyncMock()
115          mock_http_client.post.return_value = mock_response
116          mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
117          mock_http_client.__aexit__ = AsyncMock(return_value=False)
118  
119          with patch(
120              "mureo.meta_ads._creatives.httpx.AsyncClient",
121              return_value=mock_http_client,
122          ):
123              result = await meta_client.upload_ad_video_file(str(sample_video))
124  
125          assert result["id"] == "video_789"
126  
127      @pytest.mark.asyncio()
128      async def test_upload_ad_video_file_with_title(
129          self, meta_client: Any, sample_video: Path
130      ) -> None:
131          """title指定時にそのtitleが送信されること"""
132          mock_response = MagicMock()
133          mock_response.status_code = 200
134          mock_response.json.return_value = {"id": "video_789"}
135          mock_response.raise_for_status = MagicMock()
136  
137          mock_http_client = AsyncMock()
138          mock_http_client.post.return_value = mock_response
139          mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
140          mock_http_client.__aexit__ = AsyncMock(return_value=False)
141  
142          with patch(
143              "mureo.meta_ads._creatives.httpx.AsyncClient",
144              return_value=mock_http_client,
145          ):
146              result = await meta_client.upload_ad_video_file(
147                  str(sample_video), title="カスタムタイトル"
148              )
149  
150          assert result["id"] == "video_789"
151  
152  
153  # ---------------------------------------------------------------------------
154  # 3. test_upload_ad_video_file_too_large — サイズ超過
155  # ---------------------------------------------------------------------------
156  
157  
158  @pytest.mark.unit
159  class TestUploadAdVideoFileTooLarge:
160      """動画ファイルサイズ制限"""
161  
162      @pytest.mark.asyncio()
163      async def test_upload_ad_video_file_too_large(
164          self, meta_client: Any, tmp_path: Path
165      ) -> None:
166          """100MB超のファイルでValueErrorが発生すること"""
167          large_file = tmp_path / "large.mp4"
168          # 100MB + 1 byte
169          large_file.write_bytes(b"\x00" * (100 * 1024 * 1024 + 1))
170  
171          with pytest.raises(ValueError, match="100MB"):
172              await meta_client.upload_ad_video_file(str(large_file))
173  
174  
175  # ---------------------------------------------------------------------------
176  # 4. test_upload_ad_video_file_invalid_format — 未対応形式
177  # ---------------------------------------------------------------------------
178  
179  
180  @pytest.mark.unit
181  class TestUploadAdVideoFileInvalidFormat:
182      """動画ファイル形式チェック"""
183  
184      @pytest.mark.asyncio()
185      async def test_upload_ad_video_file_invalid_format(
186          self, meta_client: Any, tmp_path: Path
187      ) -> None:
188          """未対応形式(txt)でValueErrorが発生すること"""
189          txt_file = tmp_path / "document.txt"
190          txt_file.write_bytes(b"not a video")
191  
192          with pytest.raises(ValueError, match="Unsupported video format"):
193              await meta_client.upload_ad_video_file(str(txt_file))
194  
195      @pytest.mark.asyncio()
196      async def test_upload_ad_video_file_supported_formats(
197          self, meta_client: Any, tmp_path: Path
198      ) -> None:
199          """mp4, mov, avi, wmv, mkvが許可されること"""
200          mock_response = MagicMock()
201          mock_response.status_code = 200
202          mock_response.json.return_value = {"id": "v1"}
203          mock_response.raise_for_status = MagicMock()
204  
205          mock_http_client = AsyncMock()
206          mock_http_client.post.return_value = mock_response
207          mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
208          mock_http_client.__aexit__ = AsyncMock(return_value=False)
209  
210          for ext in ("mp4", "mov", "avi", "wmv", "mkv"):
211              video = tmp_path / f"test.{ext}"
212              video.write_bytes(b"\x00" * 100)
213              with patch(
214                  "mureo.meta_ads._creatives.httpx.AsyncClient",
215                  return_value=mock_http_client,
216              ):
217                  result = await meta_client.upload_ad_video_file(str(video))
218                  assert "id" in result
219  
220      @pytest.mark.asyncio()
221      async def test_upload_ad_video_file_not_found(self, meta_client: Any) -> None:
222          """ファイルが存在しない場合にFileNotFoundErrorが発生すること"""
223          with pytest.raises(FileNotFoundError):
224              await meta_client.upload_ad_video_file("/nonexistent/path/video.mp4")
225  
226      @pytest.mark.asyncio()
227      async def test_upload_ad_video_file_path_traversal(
228          self, meta_client: Any, tmp_path: Path
229      ) -> None:
230          """パストラバーサルを含むパスが拒否されること"""
231          with pytest.raises(ValueError, match="Invalid file path"):
232              await meta_client.upload_ad_video_file(
233                  str(tmp_path / ".." / ".." / "etc" / "passwd")
234              )
235  
236  
237  # ---------------------------------------------------------------------------
238  # 5. test_create_carousel_creative — カルーセル作成(正常)
239  # ---------------------------------------------------------------------------
240  
241  
242  @pytest.mark.unit
243  class TestCreateCarouselCreative:
244      """カルーセルクリエイティブ作成"""
245  
246      @pytest.mark.asyncio()
247      async def test_create_carousel_creative(self, meta_client: Any) -> None:
248          """3枚のカードでカルーセルが作成できること"""
249          meta_client._post = AsyncMock(return_value={"id": "creative_carousel_1"})
250  
251          cards = [
252              {
253                  "link": "https://example.com/product1",
254                  "name": "商品1",
255                  "description": "説明1",
256                  "image_hash": "abc123",
257              },
258              {
259                  "link": "https://example.com/product2",
260                  "name": "商品2",
261                  "description": "説明2",
262                  "image_hash": "def456",
263              },
264              {
265                  "link": "https://example.com/product3",
266                  "name": "商品3",
267                  "description": "説明3",
268                  "image_hash": "ghi789",
269              },
270          ]
271  
272          result = await meta_client.create_carousel_creative(
273              page_id="page_123",
274              cards=cards,
275              link="https://example.com",
276              name="カルーセル広告テスト",
277          )
278  
279          assert result["id"] == "creative_carousel_1"
280          meta_client._post.assert_awaited_once()
281          call_args = meta_client._post.call_args
282          assert "/adcreatives" in call_args[0][0]
283          data = (
284              call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
285          )
286          # object_story_specにchild_attachmentsが含まれること
287          oss = json.loads(data["object_story_spec"])
288          assert oss["page_id"] == "page_123"
289          assert len(oss["link_data"]["child_attachments"]) == 3
290          assert oss["link_data"]["link"] == "https://example.com"
291  
292  
293  # ---------------------------------------------------------------------------
294  # 6. test_create_carousel_creative_min_cards — 2枚(最小)
295  # ---------------------------------------------------------------------------
296  
297  
298  @pytest.mark.unit
299  class TestCreateCarouselCreativeMinCards:
300      """カルーセル最小カード数"""
301  
302      @pytest.mark.asyncio()
303      async def test_create_carousel_creative_min_cards(self, meta_client: Any) -> None:
304          """2枚のカードでカルーセルが作成できること"""
305          meta_client._post = AsyncMock(return_value={"id": "creative_carousel_min"})
306  
307          cards = [
308              {
309                  "link": "https://example.com/p1",
310                  "name": "商品1",
311                  "image_hash": "hash1",
312              },
313              {
314                  "link": "https://example.com/p2",
315                  "name": "商品2",
316                  "image_hash": "hash2",
317              },
318          ]
319  
320          result = await meta_client.create_carousel_creative(
321              page_id="page_123",
322              cards=cards,
323              link="https://example.com",
324          )
325  
326          assert result["id"] == "creative_carousel_min"
327  
328  
329  # ---------------------------------------------------------------------------
330  # 7. test_create_carousel_creative_too_few — 1枚でエラー
331  # ---------------------------------------------------------------------------
332  
333  
334  @pytest.mark.unit
335  class TestCreateCarouselCreativeTooFew:
336      """カルーセルカード数バリデーション"""
337  
338      @pytest.mark.asyncio()
339      async def test_create_carousel_creative_too_few(self, meta_client: Any) -> None:
340          """1枚のカードでValueErrorが発生すること"""
341          cards = [
342              {
343                  "link": "https://example.com/p1",
344                  "name": "商品1",
345                  "image_hash": "hash1",
346              },
347          ]
348  
349          with pytest.raises(ValueError, match="2.+10"):
350              await meta_client.create_carousel_creative(
351                  page_id="page_123",
352                  cards=cards,
353                  link="https://example.com",
354              )
355  
356      @pytest.mark.asyncio()
357      async def test_create_carousel_creative_too_many(self, meta_client: Any) -> None:
358          """11枚のカードでValueErrorが発生すること"""
359          cards = [
360              {
361                  "link": f"https://example.com/p{i}",
362                  "name": f"商品{i}",
363                  "image_hash": f"hash{i}",
364              }
365              for i in range(11)
366          ]
367  
368          with pytest.raises(ValueError, match="2.+10"):
369              await meta_client.create_carousel_creative(
370                  page_id="page_123",
371                  cards=cards,
372                  link="https://example.com",
373              )
374  
375      @pytest.mark.asyncio()
376      async def test_create_carousel_creative_max_cards(self, meta_client: Any) -> None:
377          """10枚のカードでカルーセルが作成できること"""
378          meta_client._post = AsyncMock(return_value={"id": "creative_carousel_max"})
379  
380          cards = [
381              {
382                  "link": f"https://example.com/p{i}",
383                  "name": f"商品{i}",
384                  "image_hash": f"hash{i}",
385              }
386              for i in range(10)
387          ]
388  
389          result = await meta_client.create_carousel_creative(
390              page_id="page_123",
391              cards=cards,
392              link="https://example.com",
393          )
394  
395          assert result["id"] == "creative_carousel_max"
396  
397  
398  # ---------------------------------------------------------------------------
399  # 8. test_create_collection_creative — コレクション作成
400  # ---------------------------------------------------------------------------
401  
402  
403  @pytest.mark.unit
404  class TestCreateCollectionCreative:
405      """コレクションクリエイティブ作成"""
406  
407      @pytest.mark.asyncio()
408      async def test_create_collection_creative(self, meta_client: Any) -> None:
409          """画像カバーでコレクションが作成できること"""
410          meta_client._post = AsyncMock(return_value={"id": "creative_collection_1"})
411  
412          result = await meta_client.create_collection_creative(
413              page_id="page_123",
414              product_ids=["product_1", "product_2", "product_3"],
415              link="https://example.com",
416              cover_image_hash="cover_hash_abc",
417              name="コレクション広告テスト",
418          )
419  
420          assert result["id"] == "creative_collection_1"
421          meta_client._post.assert_awaited_once()
422          call_args = meta_client._post.call_args
423          assert "/adcreatives" in call_args[0][0]
424          data = (
425              call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
426          )
427          oss = json.loads(data["object_story_spec"])
428          assert oss["page_id"] == "page_123"
429          td = oss["template_data"]
430          assert td["retailer_item_ids"] == ["product_1", "product_2", "product_3"]
431          assert td["call_to_action"]["value"]["link"] == "https://example.com"
432  
433  
434  # ---------------------------------------------------------------------------
435  # 9. test_create_collection_creative_with_video — 動画カバー付き
436  # ---------------------------------------------------------------------------
437  
438  
439  @pytest.mark.unit
440  class TestCreateCollectionCreativeWithVideo:
441      """動画カバー付きコレクションクリエイティブ"""
442  
443      @pytest.mark.asyncio()
444      async def test_create_collection_creative_with_video(
445          self, meta_client: Any
446      ) -> None:
447          """動画カバーでコレクションが作成できること"""
448          meta_client._post = AsyncMock(return_value={"id": "creative_collection_video"})
449  
450          result = await meta_client.create_collection_creative(
451              page_id="page_123",
452              product_ids=["product_1", "product_2"],
453              link="https://example.com",
454              cover_video_id="video_cover_123",
455              name="動画コレクション",
456          )
457  
458          assert result["id"] == "creative_collection_video"
459          call_args = meta_client._post.call_args
460          data = (
461              call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
462          )
463          oss = json.loads(data["object_story_spec"])
464          td = oss["template_data"]
465          assert td["format_option"] == "collection_video"
466  
467      @pytest.mark.asyncio()
468      async def test_create_collection_creative_no_cover(self, meta_client: Any) -> None:
469          """カバーなしでもコレクションが作成できること"""
470          meta_client._post = AsyncMock(
471              return_value={"id": "creative_collection_nocover"}
472          )
473  
474          result = await meta_client.create_collection_creative(
475              page_id="page_123",
476              product_ids=["product_1", "product_2"],
477              link="https://example.com",
478          )
479  
480          assert result["id"] == "creative_collection_nocover"
481  
482  
483  # ---------------------------------------------------------------------------
484  # MCPツール定義テスト
485  # ---------------------------------------------------------------------------
486  
487  
488  @pytest.mark.unit
489  class TestMetaAdsMediaToolDefinitions:
490      """動画・カルーセル・コレクション MCPツール定義テスト"""
491  
492      def _get_tools(self) -> list[Any]:
493          from mureo.mcp.tools_meta_ads import TOOLS
494  
495          return TOOLS
496  
497      def _get_tool(self, name: str) -> Any:
498          tools = self._get_tools()
499          tool = next((t for t in tools if t.name == name), None)
500          assert tool is not None, f"ツール {name} が見つかりません"
501          return tool
502  
503      def test_videos_upload_tool_exists(self) -> None:
504          """meta_ads.videos.upload がTOOLSに定義されていること"""
505          self._get_tool("meta_ads.videos.upload")
506  
507      def test_videos_upload_required_fields(self) -> None:
508          """videos.uploadの必須パラメータが正しいこと"""
509          tool = self._get_tool("meta_ads.videos.upload")
510          assert set(tool.inputSchema["required"]) == {"video_url"}
511  
512      def test_videos_upload_file_tool_exists(self) -> None:
513          """meta_ads.videos.upload_file がTOOLSに定義されていること"""
514          self._get_tool("meta_ads.videos.upload_file")
515  
516      def test_videos_upload_file_required_fields(self) -> None:
517          """videos.upload_fileの必須パラメータが正しいこと"""
518          tool = self._get_tool("meta_ads.videos.upload_file")
519          assert set(tool.inputSchema["required"]) == {"file_path"}
520  
521      def test_creatives_create_carousel_tool_exists(self) -> None:
522          """meta_ads.creatives.create_carousel がTOOLSに定義されていること"""
523          self._get_tool("meta_ads.creatives.create_carousel")
524  
525      def test_creatives_create_carousel_required_fields(self) -> None:
526          """create_carouselの必須パラメータが正しいこと"""
527          tool = self._get_tool("meta_ads.creatives.create_carousel")
528          assert set(tool.inputSchema["required"]) == {
529              "page_id",
530              "cards",
531              "link",
532          }
533  
534      def test_creatives_create_collection_tool_exists(self) -> None:
535          """meta_ads.creatives.create_collection がTOOLSに定義されていること"""
536          self._get_tool("meta_ads.creatives.create_collection")
537  
538      def test_creatives_create_collection_required_fields(self) -> None:
539          """create_collectionの必須パラメータが正しいこと"""
540          tool = self._get_tool("meta_ads.creatives.create_collection")
541          assert set(tool.inputSchema["required"]) == {
542              "page_id",
543              "product_ids",
544              "link",
545          }
546  
547  
548  # ---------------------------------------------------------------------------
549  # MCPハンドラーテスト
550  # ---------------------------------------------------------------------------
551  
552  
553  @pytest.mark.unit
554  class TestMetaAdsMediaHandlers:
555      """動画・カルーセル・コレクション MCPハンドラーテスト"""
556  
557      @pytest.mark.asyncio()
558      async def test_handle_videos_upload(self) -> None:
559          """videos.uploadハンドラーが正しくクライアントを呼び出すこと"""
560          from mureo.mcp.tools_meta_ads import handle_tool
561  
562          mock_client = AsyncMock()
563          mock_client.upload_ad_video.return_value = {"id": "video_mcp_1"}
564  
565          with (
566              patch(
567                  "mureo.mcp._handlers_meta_ads.load_meta_ads_credentials",
568                  return_value=MetaAdsCredentials(access_token="tok"),
569              ),
570              patch(
571                  "mureo.mcp._handlers_meta_ads.create_meta_ads_client",
572                  return_value=mock_client,
573              ),
574          ):
575              result = await handle_tool(
576                  "meta_ads.videos.upload",
577                  {
578                      "account_id": "act_123",
579                      "video_url": "https://example.com/video.mp4",
580                      "title": "MCP動画",
581                  },
582              )
583  
584          assert len(result) == 1
585          data = json.loads(result[0].text)
586          assert data["id"] == "video_mcp_1"
587          mock_client.upload_ad_video.assert_awaited_once()
588  
589      @pytest.mark.asyncio()
590      async def test_handle_videos_upload_file(self, sample_video: Path) -> None:
591          """videos.upload_fileハンドラーが正しくクライアントを呼び出すこと"""
592          from mureo.mcp.tools_meta_ads import handle_tool
593  
594          mock_client = AsyncMock()
595          mock_client.upload_ad_video_file.return_value = {"id": "video_file_1"}
596  
597          with (
598              patch(
599                  "mureo.mcp._handlers_meta_ads.load_meta_ads_credentials",
600                  return_value=MetaAdsCredentials(access_token="tok"),
601              ),
602              patch(
603                  "mureo.mcp._handlers_meta_ads.create_meta_ads_client",
604                  return_value=mock_client,
605              ),
606          ):
607              result = await handle_tool(
608                  "meta_ads.videos.upload_file",
609                  {
610                      "account_id": "act_123",
611                      "file_path": str(sample_video),
612                  },
613              )
614  
615          assert len(result) == 1
616          data = json.loads(result[0].text)
617          assert data["id"] == "video_file_1"
618  
619      @pytest.mark.asyncio()
620      async def test_handle_creatives_create_carousel(self) -> None:
621          """create_carouselハンドラーが正しくクライアントを呼び出すこと"""
622          from mureo.mcp.tools_meta_ads import handle_tool
623  
624          mock_client = AsyncMock()
625          mock_client.create_carousel_creative.return_value = {"id": "carousel_mcp_1"}
626  
627          with (
628              patch(
629                  "mureo.mcp._handlers_meta_ads.load_meta_ads_credentials",
630                  return_value=MetaAdsCredentials(access_token="tok"),
631              ),
632              patch(
633                  "mureo.mcp._handlers_meta_ads.create_meta_ads_client",
634                  return_value=mock_client,
635              ),
636          ):
637              result = await handle_tool(
638                  "meta_ads.creatives.create_carousel",
639                  {
640                      "account_id": "act_123",
641                      "page_id": "page_1",
642                      "cards": [
643                          {"link": "https://a.com", "name": "A", "image_hash": "h1"},
644                          {"link": "https://b.com", "name": "B", "image_hash": "h2"},
645                      ],
646                      "link": "https://example.com",
647                      "name": "テストカルーセル",
648                  },
649              )
650  
651          assert len(result) == 1
652          data = json.loads(result[0].text)
653          assert data["id"] == "carousel_mcp_1"
654  
655      @pytest.mark.asyncio()
656      async def test_handle_creatives_create_collection(self) -> None:
657          """create_collectionハンドラーが正しくクライアントを呼び出すこと"""
658          from mureo.mcp.tools_meta_ads import handle_tool
659  
660          mock_client = AsyncMock()
661          mock_client.create_collection_creative.return_value = {"id": "collection_mcp_1"}
662  
663          with (
664              patch(
665                  "mureo.mcp._handlers_meta_ads.load_meta_ads_credentials",
666                  return_value=MetaAdsCredentials(access_token="tok"),
667              ),
668              patch(
669                  "mureo.mcp._handlers_meta_ads.create_meta_ads_client",
670                  return_value=mock_client,
671              ),
672          ):
673              result = await handle_tool(
674                  "meta_ads.creatives.create_collection",
675                  {
676                      "account_id": "act_123",
677                      "page_id": "page_1",
678                      "product_ids": ["p1", "p2"],
679                      "link": "https://example.com",
680                      "cover_image_hash": "cover_h",
681                  },
682              )
683  
684          assert len(result) == 1
685          data = json.loads(result[0].text)
686          assert data["id"] == "collection_mcp_1"