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"