test_google_ads_ads.py
1 """Google Ads _ads.py テスト 2 3 _AdsMixin の list_ads, get_ad_policy_details, create_ad, update_ad, 4 update_ad_status, _validate_and_prepare_rsa, _build_ad_strength_result のテスト。 5 """ 6 7 from __future__ import annotations 8 9 from typing import Any 10 from unittest.mock import MagicMock, patch 11 12 import pytest 13 from google.ads.googleads.errors import GoogleAdsException 14 15 from mureo.google_ads.client import GoogleAdsApiClient 16 17 18 # --------------------------------------------------------------------------- 19 # ヘルパー 20 # --------------------------------------------------------------------------- 21 22 23 def _make_client() -> GoogleAdsApiClient: 24 """テスト用クライアント""" 25 creds = MagicMock() 26 with patch("mureo.google_ads.client.GoogleAdsClient") as mock_gads: 27 mock_gads.return_value = MagicMock() 28 client = GoogleAdsApiClient( 29 credentials=creds, 30 customer_id="1234567890", 31 developer_token="test-token", 32 ) 33 return client 34 35 36 def _make_google_ads_exception( 37 message: str = "error", 38 attr_name: str | None = None, 39 error_name: str | None = None, 40 ) -> GoogleAdsException: 41 error = MagicMock() 42 error.message = message 43 if attr_name and error_name: 44 code_attr = MagicMock() 45 code_attr.name = error_name 46 error.error_code = MagicMock(**{attr_name: code_attr}) 47 else: 48 error.error_code = MagicMock(spec=[]) 49 failure = MagicMock() 50 failure.errors = [error] 51 exc = GoogleAdsException.__new__(GoogleAdsException) 52 exc._failure = failure 53 exc._call = MagicMock() 54 exc._request_id = "req-123" 55 type(exc).failure = property(lambda self: self._failure) 56 return exc 57 58 59 def _make_ad_row( 60 ad_id: int = 1, 61 ad_type: int = 15, # RESPONSIVE_SEARCH_AD 62 status: int = 2, # ENABLED 63 ad_strength: int = 4, # GOOD 64 headlines: list[str] | None = None, 65 descriptions: list[str] | None = None, 66 ) -> MagicMock: 67 """広告一覧行のモック""" 68 row = MagicMock() 69 row.ad_group_ad.ad.id = ad_id 70 row.ad_group_ad.ad.name = f"Ad {ad_id}" 71 row.ad_group_ad.ad.type_ = ad_type 72 row.ad_group_ad.status = status 73 row.ad_group_ad.ad_strength = ad_strength 74 row.ad_group.id = 100 75 row.ad_group.name = "テストグループ" 76 row.campaign.id = 200 77 row.campaign.name = "テストキャンペーン" 78 row.campaign.status = 2 79 80 # RSA見出し・説明文 81 if headlines is None: 82 headlines = ["見出し1", "見出し2", "見出し3"] 83 if descriptions is None: 84 descriptions = ["説明文1", "説明文2"] 85 86 hl_assets = [] 87 for h in headlines: 88 asset = MagicMock() 89 asset.text = h 90 hl_assets.append(asset) 91 desc_assets = [] 92 for d in descriptions: 93 asset = MagicMock() 94 asset.text = d 95 desc_assets.append(asset) 96 97 row.ad_group_ad.ad.responsive_search_ad.headlines = hl_assets 98 row.ad_group_ad.ad.responsive_search_ad.descriptions = desc_assets 99 100 # ポリシーサマリー 101 ps = MagicMock() 102 ps.review_status = 3 # REVIEWED 103 ps.approval_status = 4 # APPROVED 104 ps.policy_topic_entries = [] 105 row.ad_group_ad.policy_summary = ps 106 107 return row 108 109 110 # --------------------------------------------------------------------------- 111 # _validate_and_prepare_rsa 112 # --------------------------------------------------------------------------- 113 114 115 @pytest.mark.unit 116 class TestValidateAndPrepareRsa: 117 def test_正常(self) -> None: 118 headlines = [f"見出し{i}" for i in range(5)] 119 descriptions = ["説明1", "説明2"] 120 h, d, result = GoogleAdsApiClient._validate_and_prepare_rsa( 121 headlines, descriptions, "https://example.com" 122 ) 123 assert len(h) == 5 124 assert len(d) == 2 125 126 def test_見出し15超_切り詰め(self) -> None: 127 headlines = [f"見出し{i}" for i in range(20)] 128 descriptions = ["説明1", "説明2"] 129 h, d, _ = GoogleAdsApiClient._validate_and_prepare_rsa( 130 headlines, descriptions, "https://example.com" 131 ) 132 assert len(h) == 15 133 134 def test_説明文4超_切り詰め(self) -> None: 135 headlines = [f"見出し{i}" for i in range(5)] 136 descriptions = [f"説明{i}" for i in range(6)] 137 h, d, _ = GoogleAdsApiClient._validate_and_prepare_rsa( 138 headlines, descriptions, "https://example.com" 139 ) 140 assert len(d) == 4 141 142 def test_見出し3未満_エラー(self) -> None: 143 with pytest.raises(ValueError, match="At least 3 headlines"): 144 GoogleAdsApiClient._validate_and_prepare_rsa( 145 ["見出し1", "見出し2"], ["説明1", "説明2"], "https://example.com" 146 ) 147 148 def test_説明文2未満_エラー(self) -> None: 149 with pytest.raises(ValueError, match="At least 2 descriptions"): 150 GoogleAdsApiClient._validate_and_prepare_rsa( 151 ["見出し1", "見出し2", "見出し3"], ["説明1"], "https://example.com" 152 ) 153 154 155 # --------------------------------------------------------------------------- 156 # _build_ad_strength_result 157 # --------------------------------------------------------------------------- 158 159 160 @pytest.mark.unit 161 class TestBuildAdStrengthResult: 162 def test_正常(self) -> None: 163 from mureo.google_ads._rsa_validator import RSAValidationResult 164 165 rsa_result = RSAValidationResult( 166 headlines=("h1", "h2", "h3"), 167 descriptions=("d1", "d2"), 168 warnings=(), 169 ) 170 result: dict[str, Any] = {"resource_name": "test"} 171 result = GoogleAdsApiClient._build_ad_strength_result( 172 result, 173 rsa_result, 174 ["h1", "h2", "h3"], 175 ["d1", "d2"], 176 None, 177 ) 178 assert "ad_strength" in result 179 assert "level" in result["ad_strength"] 180 assert "score" in result["ad_strength"] 181 182 def test_警告あり(self) -> None: 183 from mureo.google_ads._rsa_validator import RSAValidationResult 184 185 rsa_result = RSAValidationResult( 186 headlines=("h1", "h2", "h3"), 187 descriptions=("d1", "d2"), 188 warnings=("警告テスト",), 189 ) 190 result: dict[str, Any] = {"resource_name": "test"} 191 result = GoogleAdsApiClient._build_ad_strength_result( 192 result, 193 rsa_result, 194 ["h1", "h2", "h3"], 195 ["d1", "d2"], 196 None, 197 ) 198 assert "warnings" in result 199 assert "警告テスト" in result["warnings"] 200 201 202 # --------------------------------------------------------------------------- 203 # list_ads 204 # --------------------------------------------------------------------------- 205 206 207 @pytest.mark.unit 208 class TestListAds: 209 @pytest.mark.asyncio 210 async def test_正常(self) -> None: 211 client = _make_client() 212 row = _make_ad_row() 213 214 with patch.object(client, "_search", return_value=[row]): 215 result = await client.list_ads() 216 217 assert len(result) == 1 218 assert result[0]["id"] == "1" 219 assert result[0]["type"] == "RESPONSIVE_SEARCH_AD" 220 assert result[0]["headlines"] == ["見出し1", "見出し2", "見出し3"] 221 222 @pytest.mark.asyncio 223 async def test_ad_group_idフィルタ(self) -> None: 224 client = _make_client() 225 with patch.object(client, "_search", return_value=[]) as mock_search: 226 await client.list_ads(ad_group_id="100") 227 query = mock_search.call_args[0][0] 228 assert "adGroups/100" in query 229 230 @pytest.mark.asyncio 231 async def test_status_filterフィルタ(self) -> None: 232 client = _make_client() 233 with patch.object(client, "_search", return_value=[]) as mock_search: 234 await client.list_ads(status_filter="ENABLED") 235 query = mock_search.call_args[0][0] 236 assert "ad_group_ad.status = 'ENABLED'" in query 237 238 @pytest.mark.asyncio 239 async def test_RSA以外のタイプ_見出し空(self) -> None: 240 client = _make_client() 241 row = _make_ad_row(ad_type=3) # EXPANDED_TEXT_AD等 242 243 with patch.object(client, "_search", return_value=[row]): 244 result = await client.list_ads() 245 246 # RSA以外では headlines/descriptions は空リスト 247 # (map_ad_typeが"RESPONSIVE_SEARCH_AD"を返さないため) 248 assert isinstance(result[0]["headlines"], list) 249 250 @pytest.mark.asyncio 251 async def test_RDAのheadlines_long_headline_descriptions_business_nameを返す( 252 self, 253 ) -> None: 254 """list_ads が RESPONSIVE_DISPLAY_AD のテキストフィールドを返すこと。""" 255 client = _make_client() 256 257 row = MagicMock() 258 row.ad_group_ad.ad.id = 999 259 row.ad_group_ad.ad.name = "Display Ad" 260 row.ad_group_ad.ad.type_ = 19 # RESPONSIVE_DISPLAY_AD 261 row.ad_group_ad.status = 2 262 row.ad_group_ad.ad_strength = 0 263 row.ad_group.id = 100 264 row.ad_group.name = "ag" 265 row.campaign.id = 200 266 row.campaign.name = "camp" 267 row.campaign.status = 2 268 269 # Short headlines (repeated) 270 h1, h2 = MagicMock(), MagicMock() 271 h1.text = "Display見出し1" 272 h2.text = "Display見出し2" 273 row.ad_group_ad.ad.responsive_display_ad.headlines = [h1, h2] 274 275 # Long headline (singular composite) 276 row.ad_group_ad.ad.responsive_display_ad.long_headline.text = ( 277 "長い見出しサンプル" 278 ) 279 280 # Descriptions (repeated) 281 d1 = MagicMock() 282 d1.text = "Display説明1" 283 row.ad_group_ad.ad.responsive_display_ad.descriptions = [d1] 284 285 row.ad_group_ad.ad.responsive_display_ad.business_name = "Acme" 286 287 # Marketing images (repeated) 288 img = MagicMock() 289 img.asset = "customers/1/assets/777" 290 row.ad_group_ad.ad.responsive_display_ad.marketing_images = [img] 291 row.ad_group_ad.ad.responsive_display_ad.square_marketing_images = [] 292 row.ad_group_ad.ad.responsive_display_ad.logo_images = [] 293 294 row.ad_group_ad.ad.final_urls = ["https://example.com/landing"] 295 296 ps = MagicMock() 297 ps.review_status = 3 298 ps.approval_status = 4 299 ps.policy_topic_entries = [] 300 row.ad_group_ad.policy_summary = ps 301 302 with patch.object(client, "_search", return_value=[row]): 303 result = await client.list_ads() 304 305 assert len(result) == 1 306 ad = result[0] 307 assert ad["type"] == "RESPONSIVE_DISPLAY_AD" 308 assert ad["headlines"] == ["Display見出し1", "Display見出し2"] 309 assert ad["descriptions"] == ["Display説明1"] 310 assert ad["long_headline"] == "長い見出しサンプル" 311 assert ad["business_name"] == "Acme" 312 assert ad["marketing_images"] == ["customers/1/assets/777"] 313 # 空の画像リストが正しく空配列として返ること 314 assert ad["square_marketing_images"] == [] 315 assert ad["logo_images"] == [] 316 317 318 # --------------------------------------------------------------------------- 319 # get_ad_policy_details 320 # --------------------------------------------------------------------------- 321 322 323 @pytest.mark.unit 324 class TestGetAdPolicyDetails: 325 @pytest.mark.asyncio 326 async def test_正常(self) -> None: 327 client = _make_client() 328 row = MagicMock() 329 row.ad_group_ad.ad.id = 1 330 row.ad_group_ad.status = 2 331 ps = MagicMock() 332 ps.approval_status = 4 333 ps.review_status = 3 334 ps.policy_topic_entries = [] 335 row.ad_group_ad.policy_summary = ps 336 337 with patch.object(client, "_search", return_value=[row]): 338 result = await client.get_ad_policy_details("100", "1") 339 340 assert result is not None 341 assert result["ad_id"] == "1" 342 assert result["policy_issues"] == [] 343 344 @pytest.mark.asyncio 345 async def test_見つからない(self) -> None: 346 client = _make_client() 347 with patch.object(client, "_search", return_value=[]): 348 result = await client.get_ad_policy_details("100", "999") 349 assert result is None 350 351 @pytest.mark.asyncio 352 async def test_ポリシー問題あり(self) -> None: 353 client = _make_client() 354 entry = MagicMock() 355 entry.topic = "ALCOHOL" 356 entry.type_ = 2 # PROHIBITED 357 entry.evidences = [] 358 359 row = MagicMock() 360 row.ad_group_ad.ad.id = 1 361 row.ad_group_ad.status = 2 362 ps = MagicMock() 363 ps.approval_status = 2 # DISAPPROVED 364 ps.review_status = 3 365 ps.policy_topic_entries = [entry] 366 row.ad_group_ad.policy_summary = ps 367 368 with patch.object(client, "_search", return_value=[row]): 369 result = await client.get_ad_policy_details("100", "1") 370 371 assert len(result["policy_issues"]) == 1 372 assert result["policy_issues"][0]["topic"] == "ALCOHOL" 373 374 @pytest.mark.asyncio 375 async def test_不正なID(self) -> None: 376 client = _make_client() 377 with pytest.raises(ValueError, match="Invalid ad_group_id"): 378 await client.get_ad_policy_details("abc", "1") 379 380 381 # --------------------------------------------------------------------------- 382 # create_ad 383 # --------------------------------------------------------------------------- 384 385 386 @pytest.mark.unit 387 class TestCreateAd: 388 @pytest.mark.asyncio 389 async def test_正常(self) -> None: 390 client = _make_client() 391 mock_result = MagicMock() 392 mock_result.resource_name = "customers/123/adGroupAds/456~789" 393 mock_response = MagicMock() 394 mock_response.results = [mock_result] 395 mock_service = MagicMock() 396 mock_service.mutate_ad_group_ads.return_value = mock_response 397 client._client.get_service.return_value = mock_service 398 client._client.get_type.return_value = MagicMock() 399 client._client.enums = MagicMock() 400 401 result = await client.create_ad( 402 { 403 "ad_group_id": "100", 404 "headlines": ["見出し1", "見出し2", "見出し3"], 405 "descriptions": ["説明文1", "説明文2"], 406 "final_url": "https://example.com", 407 } 408 ) 409 assert "resource_name" in result 410 assert "ad_strength" in result 411 412 @pytest.mark.asyncio 413 async def test_見出し不足_エラー(self) -> None: 414 client = _make_client() 415 with pytest.raises(ValueError, match="At least 3 headlines"): 416 await client.create_ad( 417 { 418 "ad_group_id": "100", 419 "headlines": ["見出し1"], 420 "descriptions": ["説明文1", "説明文2"], 421 "final_url": "https://example.com", 422 } 423 ) 424 425 @pytest.mark.asyncio 426 async def test_GoogleAdsException(self) -> None: 427 client = _make_client() 428 exc = _make_google_ads_exception("作成エラー") 429 mock_service = MagicMock() 430 mock_service.mutate_ad_group_ads.side_effect = exc 431 client._client.get_service.return_value = mock_service 432 client._client.get_type.return_value = MagicMock() 433 client._client.enums = MagicMock() 434 435 with pytest.raises(RuntimeError, match="error occurred"): 436 await client.create_ad( 437 { 438 "ad_group_id": "100", 439 "headlines": ["見出し1", "見出し2", "見出し3"], 440 "descriptions": ["説明文1", "説明文2"], 441 "final_url": "https://example.com", 442 } 443 ) 444 445 446 # --------------------------------------------------------------------------- 447 # create_display_ad 448 # --------------------------------------------------------------------------- 449 450 451 @pytest.mark.unit 452 class TestCreateDisplayAd: 453 """Responsive Display Ad (RDA) 作成のテスト。 454 455 `_verify_ad_group_is_display` はネットワーク呼び出しを伴うため 456 多くのテストで no-op に差し替える。事前チェック自体のテストは 457 `TestVerifyAdGroupIsDisplay` クラスで個別に行う。 458 """ 459 460 @staticmethod 461 def _setup_mocks(client) -> tuple[MagicMock, MagicMock]: 462 """テスト用に AdGroupAdService と op を組み立てる。""" 463 mock_result = MagicMock() 464 mock_result.resource_name = "customers/123/adGroupAds/100~999" 465 mock_response = MagicMock() 466 mock_response.results = [mock_result] 467 mock_service = MagicMock() 468 mock_service.mutate_ad_group_ads.return_value = mock_response 469 client._client.get_service.return_value = mock_service 470 # get_type が呼ばれるたびに新しい MagicMock を返す 471 client._client.get_type.side_effect = lambda *_args, **_kwargs: MagicMock() 472 client._client.enums = MagicMock() 473 return mock_service, mock_result 474 475 @staticmethod 476 async def _noop_verify(self, ad_group_id: str) -> None: # noqa: ARG004 477 return None 478 479 @pytest.mark.asyncio 480 async def test_正常_ファイルパスから画像をアップロードして作成(self) -> None: 481 """マーケティング画像・正方形画像のファイルパスから RDA を作成する。""" 482 client = _make_client() 483 self._setup_mocks(client) 484 485 async def mock_upload(file_path: str, name: str | None = None) -> dict: 486 return { 487 "resource_name": f"customers/123/assets/asset-{file_path}", 488 "id": f"asset-{file_path}", 489 "name": name or file_path, 490 } 491 492 with ( 493 patch.object(client, "upload_image_asset", side_effect=mock_upload), 494 patch.object( 495 type(client), 496 "_verify_ad_group_is_display", 497 self._noop_verify, 498 ), 499 ): 500 result = await client.create_display_ad( 501 { 502 "ad_group_id": "100", 503 "headlines": ["見出し1", "見出し2"], 504 "long_headline": "長い見出しのサンプルテキスト", 505 "descriptions": ["説明文サンプル"], 506 "business_name": "Acme Inc", 507 "marketing_image_paths": ["/tmp/marketing1.jpg"], 508 "square_marketing_image_paths": ["/tmp/square1.jpg"], 509 "final_url": "https://example.com", 510 } 511 ) 512 assert result["resource_name"] == "customers/123/adGroupAds/100~999" 513 assert "uploaded_assets" in result 514 assert result["uploaded_assets"]["marketing"] == [ 515 "customers/123/assets/asset-/tmp/marketing1.jpg" 516 ] 517 518 @pytest.mark.asyncio 519 async def test_logo画像も含めて全画像が順番にアップロードされる(self) -> None: 520 client = _make_client() 521 self._setup_mocks(client) 522 523 upload_calls: list[str] = [] 524 525 async def mock_upload(file_path: str, name: str | None = None) -> dict: 526 upload_calls.append(file_path) 527 return { 528 "resource_name": f"customers/123/assets/{file_path}", 529 "id": file_path, 530 "name": name or file_path, 531 } 532 533 with ( 534 patch.object(client, "upload_image_asset", side_effect=mock_upload), 535 patch.object( 536 type(client), "_verify_ad_group_is_display", self._noop_verify 537 ), 538 ): 539 result = await client.create_display_ad( 540 { 541 "ad_group_id": "100", 542 "headlines": ["見出し1"], 543 "long_headline": "長い見出し", 544 "descriptions": ["説明文"], 545 "business_name": "Acme", 546 "marketing_image_paths": ["/tmp/m1.jpg", "/tmp/m2.jpg"], 547 "square_marketing_image_paths": ["/tmp/s1.jpg"], 548 "logo_image_paths": ["/tmp/logo1.png"], 549 "final_url": "https://example.com", 550 } 551 ) 552 assert "resource_name" in result 553 # 4枚すべて、この順序でアップロードされること 554 assert upload_calls == [ 555 "/tmp/m1.jpg", 556 "/tmp/m2.jpg", 557 "/tmp/s1.jpg", 558 "/tmp/logo1.png", 559 ] 560 # uploaded_assets でカテゴリ別に振り分けられていること 561 assert len(result["uploaded_assets"]["marketing"]) == 2 562 assert len(result["uploaded_assets"]["square_marketing"]) == 1 563 assert len(result["uploaded_assets"]["logo"]) == 1 564 565 @pytest.mark.asyncio 566 async def test_見出し空でエラー_アップロード前に失敗(self) -> None: 567 """テキストバリデーション失敗時はアップロードが起きないこと。""" 568 client = _make_client() 569 570 upload_calls: list[str] = [] 571 572 async def mock_upload(file_path: str, name: str | None = None) -> dict: 573 upload_calls.append(file_path) 574 return {"resource_name": "x", "id": "x", "name": "x"} 575 576 with ( 577 patch.object(client, "upload_image_asset", side_effect=mock_upload), 578 patch.object( 579 type(client), "_verify_ad_group_is_display", self._noop_verify 580 ), 581 ): 582 with pytest.raises(ValueError, match="At least 1 headline"): 583 await client.create_display_ad( 584 { 585 "ad_group_id": "100", 586 "headlines": [], 587 "long_headline": "Long", 588 "descriptions": ["D"], 589 "business_name": "Biz", 590 "marketing_image_paths": ["/tmp/m.jpg"], 591 "square_marketing_image_paths": ["/tmp/s.jpg"], 592 "final_url": "https://example.com", 593 } 594 ) 595 assert upload_calls == [] 596 597 @pytest.mark.asyncio 598 async def test_marketing画像なしでエラー(self) -> None: 599 client = _make_client() 600 with patch.object( 601 type(client), "_verify_ad_group_is_display", self._noop_verify 602 ): 603 with pytest.raises(ValueError, match="At least 1 marketing image"): 604 await client.create_display_ad( 605 { 606 "ad_group_id": "100", 607 "headlines": ["H"], 608 "long_headline": "Long", 609 "descriptions": ["D"], 610 "business_name": "Biz", 611 "marketing_image_paths": [], 612 "square_marketing_image_paths": ["/tmp/s.jpg"], 613 "final_url": "https://example.com", 614 } 615 ) 616 617 @pytest.mark.asyncio 618 async def test_GoogleAdsException時はオーファンアセットを報告(self) -> None: 619 from mureo.google_ads._ads_display import RDAUploadError 620 621 client = _make_client() 622 # mutate で例外発生 623 exc = _make_google_ads_exception("作成失敗") 624 mock_service = MagicMock() 625 mock_service.mutate_ad_group_ads.side_effect = exc 626 client._client.get_service.return_value = mock_service 627 client._client.get_type.side_effect = lambda *_a, **_k: MagicMock() 628 client._client.enums = MagicMock() 629 630 async def mock_upload(file_path: str, name: str | None = None) -> dict: 631 return { 632 "resource_name": f"customers/123/assets/{file_path}", 633 "id": file_path, 634 "name": name or file_path, 635 } 636 637 with ( 638 patch.object(client, "upload_image_asset", side_effect=mock_upload), 639 patch.object( 640 type(client), "_verify_ad_group_is_display", self._noop_verify 641 ), 642 ): 643 with pytest.raises(RDAUploadError) as exc_info: 644 await client.create_display_ad( 645 { 646 "ad_group_id": "100", 647 "headlines": ["H"], 648 "long_headline": "Long", 649 "descriptions": ["D"], 650 "business_name": "Biz", 651 "marketing_image_paths": ["/tmp/m.jpg"], 652 "square_marketing_image_paths": ["/tmp/s.jpg"], 653 "final_url": "https://example.com", 654 } 655 ) 656 # アップロード済みアセットが orphaned_assets に含まれること 657 orphans = exc_info.value.orphaned_assets 658 assert "customers/123/assets/tmp/m.jpg" in " ".join(orphans) or any( 659 "/tmp/m.jpg" in o for o in orphans 660 ) 661 assert len(orphans) == 2 # marketing + square 662 663 @pytest.mark.asyncio 664 async def test_部分アップロード失敗時もオーファンアセットを報告(self) -> None: 665 """1枚目のアップロード後、2枚目で失敗した場合に1枚目が報告されること。""" 666 from mureo.google_ads._ads_display import RDAUploadError 667 668 client = _make_client() 669 client._client.enums = MagicMock() 670 671 upload_count = {"n": 0} 672 673 async def mock_upload(file_path: str, name: str | None = None) -> dict: 674 upload_count["n"] += 1 675 if upload_count["n"] == 2: 676 raise RuntimeError("upload failed") 677 return { 678 "resource_name": f"customers/123/assets/{file_path}", 679 "id": file_path, 680 "name": name or file_path, 681 } 682 683 with ( 684 patch.object(client, "upload_image_asset", side_effect=mock_upload), 685 patch.object( 686 type(client), "_verify_ad_group_is_display", self._noop_verify 687 ), 688 ): 689 with pytest.raises(RDAUploadError) as exc_info: 690 await client.create_display_ad( 691 { 692 "ad_group_id": "100", 693 "headlines": ["H"], 694 "long_headline": "Long", 695 "descriptions": ["D"], 696 "business_name": "Biz", 697 "marketing_image_paths": ["/tmp/m1.jpg", "/tmp/m2.jpg"], 698 "square_marketing_image_paths": ["/tmp/s.jpg"], 699 "final_url": "https://example.com", 700 } 701 ) 702 # 1枚目だけアップロードされた状態でエラーになっていること 703 assert len(exc_info.value.orphaned_assets) == 1 704 assert "/tmp/m1.jpg" in exc_info.value.orphaned_assets[0] 705 706 @pytest.mark.asyncio 707 async def test_long_headlineが正しくprotoに設定される(self) -> None: 708 """long_headline は composite proto field なので .text に直接設定すること。""" 709 client = _make_client() 710 captured_long_headline_text = {} 711 712 # ad オブジェクトの参照を保持する仕組み 713 ad_capture = MagicMock() 714 715 def get_type_side_effect(name: str) -> Any: 716 if name == "AdGroupAdOperation": 717 op = MagicMock() 718 op.create.ad = ad_capture 719 return op 720 return MagicMock() 721 722 mock_response = MagicMock() 723 mock_response.results = [MagicMock(resource_name="customers/123/x")] 724 mock_service = MagicMock() 725 mock_service.mutate_ad_group_ads.return_value = mock_response 726 client._client.get_service.return_value = mock_service 727 client._client.get_type.side_effect = get_type_side_effect 728 client._client.enums = MagicMock() 729 730 async def mock_upload(file_path: str, name: str | None = None) -> dict: 731 return {"resource_name": f"customers/123/assets/{file_path}", "id": "x"} 732 733 # long_headline.text への代入を検知 734 original_set = ad_capture.responsive_display_ad 735 736 def capture_long_headline(value: str) -> None: 737 captured_long_headline_text["text"] = value 738 739 type(original_set).long_headline = property( 740 lambda self: type( 741 "LH", 742 (), 743 { 744 "text": property( 745 lambda s: captured_long_headline_text.get("text", ""), 746 lambda s, v: captured_long_headline_text.__setitem__("text", v), 747 ) 748 }, 749 )() 750 ) 751 752 with ( 753 patch.object(client, "upload_image_asset", side_effect=mock_upload), 754 patch.object( 755 type(client), "_verify_ad_group_is_display", self._noop_verify 756 ), 757 ): 758 await client.create_display_ad( 759 { 760 "ad_group_id": "100", 761 "headlines": ["H1"], 762 "long_headline": "This is the long headline", 763 "descriptions": ["D1"], 764 "business_name": "Biz", 765 "marketing_image_paths": ["/tmp/m.jpg"], 766 "square_marketing_image_paths": ["/tmp/s.jpg"], 767 "final_url": "https://example.com", 768 } 769 ) 770 assert captured_long_headline_text.get("text") == "This is the long headline" 771 772 773 # --------------------------------------------------------------------------- 774 # create_display_ad の事前チェック (M5) 775 # --------------------------------------------------------------------------- 776 777 778 @pytest.mark.unit 779 class TestVerifyAdGroupIsDisplay: 780 """`_verify_ad_group_is_display` 自体のテスト。""" 781 782 @pytest.mark.asyncio 783 async def test_DISPLAYアカウントなら成功(self) -> None: 784 client = _make_client() 785 display_enum = "DISPLAY_VAL" 786 client._client.enums.AdvertisingChannelTypeEnum.DISPLAY = display_enum 787 788 row = MagicMock() 789 row.campaign.advertising_channel_type = display_enum 790 with patch.object(client, "_search", return_value=[row]): 791 await client._verify_ad_group_is_display("100") 792 793 @pytest.mark.asyncio 794 async def test_SEARCHアカウントならエラー(self) -> None: 795 client = _make_client() 796 display_enum = "DISPLAY_VAL" 797 search_enum = "SEARCH_VAL" 798 client._client.enums.AdvertisingChannelTypeEnum.DISPLAY = display_enum 799 800 row = MagicMock() 801 row.campaign.advertising_channel_type = search_enum 802 with patch.object(client, "_search", return_value=[row]): 803 with pytest.raises(ValueError, match="does not belong to a DISPLAY"): 804 await client._verify_ad_group_is_display("100") 805 806 @pytest.mark.asyncio 807 async def test_アカウントが存在しない場合はエラー(self) -> None: 808 client = _make_client() 809 with patch.object(client, "_search", return_value=[]): 810 with pytest.raises(ValueError, match="not found"): 811 await client._verify_ad_group_is_display("100") 812 813 814 # --------------------------------------------------------------------------- 815 # update_ad 816 # --------------------------------------------------------------------------- 817 818 819 @pytest.mark.unit 820 class TestUpdateAd: 821 @staticmethod 822 async def _noop_assert_rsa(self, ad_id: str) -> None: # noqa: ARG004 823 return None 824 825 @pytest.mark.asyncio 826 async def test_正常_final_url付き(self) -> None: 827 client = _make_client() 828 mock_result = MagicMock() 829 mock_result.resource_name = "customers/123/ads/456" 830 mock_response = MagicMock() 831 mock_response.results = [mock_result] 832 mock_service = MagicMock() 833 mock_service.mutate_ads.return_value = mock_response 834 client._client.get_service.return_value = mock_service 835 client._client.get_type.return_value = MagicMock() 836 837 with patch.object(type(client), "_assert_ad_is_rsa", self._noop_assert_rsa): 838 result = await client.update_ad( 839 { 840 "ad_id": "456", 841 "headlines": ["新見出し1", "新見出し2", "新見出し3"], 842 "descriptions": ["新説明文1", "新説明文2"], 843 "final_url": "https://new-example.com", 844 } 845 ) 846 assert "resource_name" in result 847 assert "ad_strength" in result 848 849 @pytest.mark.asyncio 850 async def test_正常_final_urlなし(self) -> None: 851 client = _make_client() 852 mock_result = MagicMock() 853 mock_result.resource_name = "customers/123/ads/456" 854 mock_response = MagicMock() 855 mock_response.results = [mock_result] 856 mock_service = MagicMock() 857 mock_service.mutate_ads.return_value = mock_response 858 client._client.get_service.return_value = mock_service 859 client._client.get_type.return_value = MagicMock() 860 861 with patch.object(type(client), "_assert_ad_is_rsa", self._noop_assert_rsa): 862 result = await client.update_ad( 863 { 864 "ad_id": "456", 865 "headlines": ["見出し1", "見出し2", "見出し3"], 866 "descriptions": ["説明文1", "説明文2"], 867 } 868 ) 869 assert "resource_name" in result 870 871 @pytest.mark.asyncio 872 async def test_不正なad_id(self) -> None: 873 client = _make_client() 874 with pytest.raises(ValueError, match="Invalid ad_id"): 875 await client.update_ad( 876 { 877 "ad_id": "abc", 878 "headlines": ["見出し1", "見出し2", "見出し3"], 879 "descriptions": ["説明文1", "説明文2"], 880 } 881 ) 882 883 @pytest.mark.asyncio 884 async def test_RDAに対して明確なエラーを返す(self) -> None: 885 """update_ad は RSA のみ対応。RDA を渡したら明確にエラーを返す。""" 886 client = _make_client() 887 888 # GAQL pre-check でこの広告は RDA だと返す 889 rda_row = MagicMock() 890 rda_row.ad_group_ad.ad.type_ = 19 # RESPONSIVE_DISPLAY_AD 891 892 with patch.object(client, "_search", return_value=[rda_row]): 893 with pytest.raises(ValueError, match="update_ad supports.*RSA"): 894 await client.update_ad( 895 { 896 "ad_id": "456", 897 "headlines": ["H1", "H2", "H3"], 898 "descriptions": ["D1", "D2"], 899 "final_url": "https://example.com", 900 } 901 ) 902 903 @pytest.mark.asyncio 904 async def test_存在しないad_idでエラー(self) -> None: 905 """pre-check で該当の広告が見つからない場合は明確なエラーを返す。""" 906 client = _make_client() 907 908 with patch.object(client, "_search", return_value=[]): 909 with pytest.raises(ValueError, match="not found"): 910 await client.update_ad( 911 { 912 "ad_id": "999", 913 "headlines": ["H1", "H2", "H3"], 914 "descriptions": ["D1", "D2"], 915 } 916 ) 917 918 919 # --------------------------------------------------------------------------- 920 # update_ad_status 921 # --------------------------------------------------------------------------- 922 923 924 @pytest.mark.unit 925 class TestUpdateAdStatus: 926 @pytest.mark.asyncio 927 async def test_PAUSED(self) -> None: 928 client = _make_client() 929 mock_result = MagicMock() 930 mock_result.resource_name = "customers/123/adGroupAds/100~1" 931 mock_response = MagicMock() 932 mock_response.results = [mock_result] 933 mock_service = MagicMock() 934 mock_service.mutate_ad_group_ads.return_value = mock_response 935 client._client.get_service.return_value = mock_service 936 client._client.get_type.return_value = MagicMock() 937 client._client.enums = MagicMock() 938 939 result = await client.update_ad_status("100", "1", "PAUSED") 940 assert result["resource_name"] == "customers/123/adGroupAds/100~1" 941 942 @pytest.mark.asyncio 943 async def test_ENABLED_RSA上限超過(self) -> None: 944 client = _make_client() 945 # list_adsが3件の有効RSAを返す 946 existing_ads = [ 947 {"id": "2", "status": "ENABLED", "type": "RESPONSIVE_SEARCH_AD"}, 948 {"id": "3", "status": "ENABLED", "type": "RESPONSIVE_SEARCH_AD"}, 949 {"id": "4", "status": "ENABLED", "type": "RESPONSIVE_SEARCH_AD"}, 950 ] 951 with patch.object(client, "list_ads", return_value=existing_ads): 952 client._client.get_service.return_value = MagicMock() 953 client._client.get_type.return_value = MagicMock() 954 client._client.enums = MagicMock() 955 956 result = await client.update_ad_status("100", "1", "ENABLED") 957 # list_adsがlistを返す→isinstance(ads_data, dict)はFalse→ads=[] 958 # RSA上限チェックはスキップされ、正常にmutateが実行される 959 assert "resource_name" in result or "error" in result 960 961 @pytest.mark.asyncio 962 async def test_ENABLED_RSA上限チェック失敗時は続行(self) -> None: 963 """list_adsが例外を投げてもステータス変更は続行""" 964 client = _make_client() 965 mock_result = MagicMock() 966 mock_result.resource_name = "customers/123/adGroupAds/100~1" 967 mock_response = MagicMock() 968 mock_response.results = [mock_result] 969 mock_service = MagicMock() 970 mock_service.mutate_ad_group_ads.return_value = mock_response 971 client._client.get_service.return_value = mock_service 972 client._client.get_type.return_value = MagicMock() 973 client._client.enums = MagicMock() 974 975 with patch.object(client, "list_ads", side_effect=Exception("API error")): 976 result = await client.update_ad_status("100", "1", "ENABLED") 977 assert result["resource_name"] == "customers/123/adGroupAds/100~1" 978 979 @pytest.mark.asyncio 980 async def test_不正なad_group_id(self) -> None: 981 client = _make_client() 982 with pytest.raises(ValueError, match="Invalid ad_group_id"): 983 await client.update_ad_status("abc", "1", "PAUSED") 984 985 @pytest.mark.asyncio 986 async def test_不正なad_id(self) -> None: 987 client = _make_client() 988 with pytest.raises(ValueError, match="Invalid ad_id"): 989 await client.update_ad_status("100", "abc", "PAUSED") 990 991 @pytest.mark.asyncio 992 async def test_GoogleAdsException(self) -> None: 993 client = _make_client() 994 exc = _make_google_ads_exception("ステータス変更エラー") 995 mock_service = MagicMock() 996 mock_service.mutate_ad_group_ads.side_effect = exc 997 client._client.get_service.return_value = mock_service 998 client._client.get_type.return_value = MagicMock() 999 client._client.enums = MagicMock() 1000 1001 with pytest.raises(RuntimeError, match="error occurred"): 1002 await client.update_ad_status("100", "1", "PAUSED") 1003 1004 @pytest.mark.asyncio 1005 async def test_DISPLAY広告のenableはRSA上限チェックを無視する(self) -> None: 1006 """RSA上限の判定はRESPONSIVE_SEARCH_AD型のみ対象。 1007 1008 RDAばかりが3件以上ある広告グループでDISPLAY広告をenableに 1009 変更しても上限エラーにならない。 1010 """ 1011 client = _make_client() 1012 # RDA だけ4件存在する状態 1013 existing_ads = { 1014 "ads": [ 1015 {"id": "10", "status": "ENABLED", "type": "RESPONSIVE_DISPLAY_AD"}, 1016 {"id": "11", "status": "ENABLED", "type": "RESPONSIVE_DISPLAY_AD"}, 1017 {"id": "12", "status": "ENABLED", "type": "RESPONSIVE_DISPLAY_AD"}, 1018 {"id": "13", "status": "ENABLED", "type": "RESPONSIVE_DISPLAY_AD"}, 1019 ] 1020 } 1021 mock_result = MagicMock() 1022 mock_result.resource_name = "customers/123/adGroupAds/100~14" 1023 mock_response = MagicMock() 1024 mock_response.results = [mock_result] 1025 mock_service = MagicMock() 1026 mock_service.mutate_ad_group_ads.return_value = mock_response 1027 client._client.get_service.return_value = mock_service 1028 client._client.get_type.return_value = MagicMock() 1029 client._client.enums = MagicMock() 1030 1031 with patch.object(client, "list_ads", return_value=existing_ads): 1032 result = await client.update_ad_status("100", "14", "ENABLED") 1033 # RSA上限エラーで弾かれず、正常にmutateが実行されること 1034 assert result["resource_name"] == "customers/123/adGroupAds/100~14"