test_google_ads_extensions.py
1 """Google Ads _extensions.py ユニットテスト 2 3 _ExtensionsMixin の各メソッドをモックベースでテストする。 4 _search / _get_service / _client をモックし、外部API呼び出しを排除。 5 """ 6 7 from __future__ import annotations 8 9 import math 10 from types import SimpleNamespace 11 from unittest.mock import AsyncMock, MagicMock, patch 12 13 import pytest 14 15 from mureo.google_ads._extensions import ( 16 _ExtensionsMixin, 17 _DEVICE_ENUM_MAP, 18 _normalize_device_type, 19 _VALID_CONVERSION_ACTION_TYPES, 20 _VALID_CONVERSION_ACTION_CATEGORIES, 21 _VALID_CONVERSION_ACTION_STATUSES, 22 ) 23 24 25 # --------------------------------------------------------------------------- 26 # テスト用のモッククライアントクラス 27 # --------------------------------------------------------------------------- 28 29 30 class _MockExtensionsClient(_ExtensionsMixin): 31 """_ExtensionsMixin をテスト可能にするモッククラス""" 32 33 def __init__(self) -> None: 34 self._customer_id = "1234567890" 35 self._client = MagicMock() 36 self._search = AsyncMock(return_value=[]) 37 38 @staticmethod 39 def _validate_id(value: str, field_name: str) -> str: 40 if not value or not value.isdigit(): 41 raise ValueError(f"Invalid {field_name}: {value}") 42 return value 43 44 @staticmethod 45 def _validate_date(value: str, field_name: str) -> str: 46 return value 47 48 @staticmethod 49 def _validate_recommendation_type(rec_type: str) -> str: 50 return rec_type 51 52 @staticmethod 53 def _validate_resource_name(value: str, pattern, field_name: str) -> str: 54 return value 55 56 def _get_service(self, service_name: str): 57 return MagicMock() 58 59 60 # --------------------------------------------------------------------------- 61 # _normalize_device_type テスト 62 # --------------------------------------------------------------------------- 63 64 65 @pytest.mark.unit 66 class TestNormalizeDeviceType: 67 def test_int_values(self) -> None: 68 assert _normalize_device_type(2) == "MOBILE" 69 assert _normalize_device_type(3) == "TABLET" 70 assert _normalize_device_type(4) == "DESKTOP" 71 72 def test_unknown_int(self) -> None: 73 result = _normalize_device_type(99) 74 assert "UNKNOWN" in result 75 76 def test_dotted_string(self) -> None: 77 assert _normalize_device_type("DeviceType.DESKTOP") == "DESKTOP" 78 79 def test_plain_string(self) -> None: 80 assert _normalize_device_type("MOBILE") == "MOBILE" 81 82 def test_int_string(self) -> None: 83 assert _normalize_device_type("2") == "MOBILE" 84 assert _normalize_device_type("4") == "DESKTOP" 85 86 def test_non_numeric_string(self) -> None: 87 assert _normalize_device_type("TABLET") == "TABLET" 88 89 90 # --------------------------------------------------------------------------- 91 # list_sitelinks テスト 92 # --------------------------------------------------------------------------- 93 94 95 @pytest.mark.unit 96 class TestListSitelinks: 97 @pytest.fixture() 98 def client(self) -> _MockExtensionsClient: 99 return _MockExtensionsClient() 100 101 @pytest.mark.asyncio 102 async def test_returns_campaign_and_account_sitelinks( 103 self, client: _MockExtensionsClient 104 ) -> None: 105 """キャンペーンレベルとアカウントレベルのサイトリンクを統合して返す""" 106 campaign_row = MagicMock() 107 # map_sitelink が呼ばれるので、パッチする 108 with patch("mureo.google_ads._extensions_sitelinks.map_sitelink") as mock_map: 109 mock_map.side_effect = [ 110 {"id": "1", "link_text": "Link1"}, 111 {"id": "2", "link_text": "Link2"}, 112 ] 113 client._search = AsyncMock( 114 side_effect=[ 115 [campaign_row], # キャンペーンレベル 116 [MagicMock()], # アカウントレベル 117 ] 118 ) 119 result = await client.list_sitelinks("123") 120 121 assert len(result) == 2 122 assert result[0]["level"] == "campaign" 123 assert result[1]["level"] == "account" 124 125 @pytest.mark.asyncio 126 async def test_dedup_by_id(self, client: _MockExtensionsClient) -> None: 127 """同一IDのサイトリンクは重複排除される""" 128 with patch("mureo.google_ads._extensions_sitelinks.map_sitelink") as mock_map: 129 mock_map.side_effect = [ 130 {"id": "1", "link_text": "Link1"}, 131 {"id": "1", "link_text": "Link1dup"}, # 同じID 132 ] 133 client._search = AsyncMock( 134 side_effect=[ 135 [MagicMock()], 136 [MagicMock()], 137 ] 138 ) 139 result = await client.list_sitelinks("123") 140 141 assert len(result) == 1 142 143 @pytest.mark.asyncio 144 async def test_account_level_failure_graceful( 145 self, client: _MockExtensionsClient 146 ) -> None: 147 """アカウントレベルの取得に失敗してもキャンペーンレベルは返る""" 148 with patch("mureo.google_ads._extensions_sitelinks.map_sitelink") as mock_map: 149 mock_map.return_value = {"id": "1", "link_text": "Link1"} 150 client._search = AsyncMock( 151 side_effect=[ 152 [MagicMock()], 153 RuntimeError("account query failed"), 154 ] 155 ) 156 result = await client.list_sitelinks("123") 157 158 assert len(result) == 1 159 assert result[0]["level"] == "campaign" 160 161 162 # --------------------------------------------------------------------------- 163 # create_sitelink テスト 164 # --------------------------------------------------------------------------- 165 166 167 @pytest.mark.unit 168 class TestCreateSitelink: 169 @pytest.fixture() 170 def client(self) -> _MockExtensionsClient: 171 return _MockExtensionsClient() 172 173 @pytest.mark.asyncio 174 async def test_max_sitelinks_exceeded(self, client: _MockExtensionsClient) -> None: 175 """上限超過時はバリデーションエラーを返す""" 176 with patch.object(client, "list_sitelinks", new_callable=AsyncMock) as mock_ls: 177 mock_ls.return_value = [ 178 {"id": str(i), "level": "campaign"} for i in range(20) 179 ] 180 result = await client.create_sitelink( 181 { 182 "campaign_id": "123", 183 "link_text": "Test", 184 "final_url": "https://example.com", 185 } 186 ) 187 assert result["error"] is True 188 assert "Maximum 20" in result["message"] 189 190 191 # --------------------------------------------------------------------------- 192 # list_callouts テスト 193 # --------------------------------------------------------------------------- 194 195 196 @pytest.mark.unit 197 class TestListCallouts: 198 @pytest.fixture() 199 def client(self) -> _MockExtensionsClient: 200 return _MockExtensionsClient() 201 202 @pytest.mark.asyncio 203 async def test_returns_callouts(self, client: _MockExtensionsClient) -> None: 204 with patch("mureo.google_ads._extensions_callouts.map_callout") as mock_map: 205 mock_map.return_value = {"id": "1", "callout_text": "Free"} 206 client._search = AsyncMock(return_value=[MagicMock()]) 207 result = await client.list_callouts("123") 208 209 assert len(result) == 1 210 assert result[0]["callout_text"] == "Free" 211 212 213 # --------------------------------------------------------------------------- 214 # create_callout テスト 215 # --------------------------------------------------------------------------- 216 217 218 @pytest.mark.unit 219 class TestCreateCallout: 220 @pytest.fixture() 221 def client(self) -> _MockExtensionsClient: 222 return _MockExtensionsClient() 223 224 @pytest.mark.asyncio 225 async def test_max_callouts_exceeded(self, client: _MockExtensionsClient) -> None: 226 """上限超過時はバリデーションエラーを返す""" 227 with patch.object(client, "list_callouts", new_callable=AsyncMock) as mock_lc: 228 mock_lc.return_value = [{"id": str(i)} for i in range(20)] 229 result = await client.create_callout( 230 { 231 "campaign_id": "123", 232 "callout_text": "Test", 233 } 234 ) 235 assert result["error"] is True 236 assert "Maximum 20" in result["message"] 237 238 239 # --------------------------------------------------------------------------- 240 # list_conversion_actions テスト 241 # --------------------------------------------------------------------------- 242 243 244 @pytest.mark.unit 245 class TestListConversionActions: 246 @pytest.fixture() 247 def client(self) -> _MockExtensionsClient: 248 return _MockExtensionsClient() 249 250 @pytest.mark.asyncio 251 async def test_returns_mapped_actions(self, client: _MockExtensionsClient) -> None: 252 mock_row = MagicMock() 253 client._search = AsyncMock(return_value=[mock_row]) 254 with patch( 255 "mureo.google_ads._extensions_conversions.map_conversion_action" 256 ) as mock_map: 257 mock_map.return_value = {"id": "1", "name": "Purchase"} 258 result = await client.list_conversion_actions() 259 260 assert len(result) == 1 261 assert result[0]["name"] == "Purchase" 262 263 264 # --------------------------------------------------------------------------- 265 # create_conversion_action バリデーションテスト 266 # --------------------------------------------------------------------------- 267 268 269 @pytest.mark.unit 270 class TestCreateConversionActionValidation: 271 @pytest.fixture() 272 def client(self) -> _MockExtensionsClient: 273 return _MockExtensionsClient() 274 275 @pytest.mark.asyncio 276 async def test_empty_name_raises(self, client: _MockExtensionsClient) -> None: 277 with pytest.raises(ValueError, match="name is required"): 278 await client.create_conversion_action({"name": ""}) 279 280 @pytest.mark.asyncio 281 async def test_long_name_raises(self, client: _MockExtensionsClient) -> None: 282 with pytest.raises(ValueError, match="256 characters"): 283 await client.create_conversion_action({"name": "x" * 257}) 284 285 @pytest.mark.asyncio 286 async def test_invalid_type_raises(self, client: _MockExtensionsClient) -> None: 287 with pytest.raises(ValueError, match="Invalid type"): 288 await client.create_conversion_action({"name": "Test", "type": "INVALID"}) 289 290 @pytest.mark.asyncio 291 async def test_invalid_category_raises(self, client: _MockExtensionsClient) -> None: 292 with pytest.raises(ValueError, match="Invalid category"): 293 await client.create_conversion_action( 294 {"name": "Test", "category": "INVALID"} 295 ) 296 297 @pytest.mark.asyncio 298 async def test_lookback_window_out_of_range( 299 self, client: _MockExtensionsClient 300 ) -> None: 301 with pytest.raises(ValueError, match="1.+90"): 302 await client.create_conversion_action( 303 { 304 "name": "Test", 305 "click_through_lookback_window_days": 100, 306 } 307 ) 308 309 @pytest.mark.asyncio 310 async def test_view_through_lookback_out_of_range( 311 self, client: _MockExtensionsClient 312 ) -> None: 313 with pytest.raises(ValueError, match="1.+30"): 314 await client.create_conversion_action( 315 { 316 "name": "Test", 317 "view_through_lookback_window_days": 31, 318 } 319 ) 320 321 322 # --------------------------------------------------------------------------- 323 # update_conversion_action バリデーションテスト 324 # --------------------------------------------------------------------------- 325 326 327 @pytest.mark.unit 328 class TestUpdateConversionActionValidation: 329 @pytest.fixture() 330 def client(self) -> _MockExtensionsClient: 331 return _MockExtensionsClient() 332 333 @pytest.mark.asyncio 334 async def test_no_fields_raises(self, client: _MockExtensionsClient) -> None: 335 with pytest.raises(ValueError, match="At least one field must be specified"): 336 await client.update_conversion_action({"conversion_action_id": "123"}) 337 338 @pytest.mark.asyncio 339 async def test_invalid_status_raises(self, client: _MockExtensionsClient) -> None: 340 with pytest.raises(ValueError, match="Invalid status"): 341 await client.update_conversion_action( 342 { 343 "conversion_action_id": "123", 344 "status": "INVALID", 345 } 346 ) 347 348 @pytest.mark.asyncio 349 async def test_invalid_category_raises(self, client: _MockExtensionsClient) -> None: 350 with pytest.raises(ValueError, match="Invalid category"): 351 await client.update_conversion_action( 352 { 353 "conversion_action_id": "123", 354 "category": "BADCAT", 355 } 356 ) 357 358 359 # --------------------------------------------------------------------------- 360 # get_conversion_action テスト 361 # --------------------------------------------------------------------------- 362 363 364 @pytest.mark.unit 365 class TestGetConversionAction: 366 @pytest.fixture() 367 def client(self) -> _MockExtensionsClient: 368 return _MockExtensionsClient() 369 370 @pytest.mark.asyncio 371 async def test_found(self, client: _MockExtensionsClient) -> None: 372 mock_row = MagicMock() 373 client._search = AsyncMock(return_value=[mock_row]) 374 with patch( 375 "mureo.google_ads._extensions_conversions.map_conversion_action" 376 ) as mock_map: 377 mock_map.return_value = {"id": "1", "name": "Purchase"} 378 result = await client.get_conversion_action("1") 379 assert result is not None 380 assert result["name"] == "Purchase" 381 382 @pytest.mark.asyncio 383 async def test_not_found(self, client: _MockExtensionsClient) -> None: 384 client._search = AsyncMock(return_value=[]) 385 result = await client.get_conversion_action("999") 386 assert result is None 387 388 389 # --------------------------------------------------------------------------- 390 # get_conversion_action_tag テスト 391 # --------------------------------------------------------------------------- 392 393 394 @pytest.mark.unit 395 class TestGetConversionActionTag: 396 @pytest.fixture() 397 def client(self) -> _MockExtensionsClient: 398 return _MockExtensionsClient() 399 400 @pytest.mark.asyncio 401 async def test_returns_snippets(self, client: _MockExtensionsClient) -> None: 402 snippet = MagicMock() 403 mock_row = MagicMock() 404 mock_row.conversion_action.tag_snippets = [snippet] 405 client._search = AsyncMock(return_value=[mock_row]) 406 with patch( 407 "mureo.google_ads._extensions_conversions.map_tag_snippet" 408 ) as mock_map: 409 mock_map.return_value = {"type": "EVENT_SNIPPET"} 410 result = await client.get_conversion_action_tag("1") 411 assert len(result) == 1 412 413 @pytest.mark.asyncio 414 async def test_not_found_returns_empty(self, client: _MockExtensionsClient) -> None: 415 client._search = AsyncMock(return_value=[]) 416 result = await client.get_conversion_action_tag("999") 417 assert result == [] 418 419 420 # --------------------------------------------------------------------------- 421 # list_recommendations テスト 422 # --------------------------------------------------------------------------- 423 424 425 @pytest.mark.unit 426 class TestListRecommendations: 427 @pytest.fixture() 428 def client(self) -> _MockExtensionsClient: 429 return _MockExtensionsClient() 430 431 @pytest.mark.asyncio 432 async def test_no_filter(self, client: _MockExtensionsClient) -> None: 433 mock_row = MagicMock() 434 client._search = AsyncMock(return_value=[mock_row]) 435 with patch( 436 "mureo.google_ads._extensions_targeting.map_recommendation" 437 ) as mock_map: 438 mock_map.return_value = {"type": "KEYWORD"} 439 result = await client.list_recommendations() 440 assert len(result) == 1 441 442 @pytest.mark.asyncio 443 async def test_with_campaign_filter(self, client: _MockExtensionsClient) -> None: 444 client._search = AsyncMock(return_value=[]) 445 result = await client.list_recommendations(campaign_id="123") 446 assert result == [] 447 448 449 # --------------------------------------------------------------------------- 450 # get_device_targeting テスト 451 # --------------------------------------------------------------------------- 452 453 454 @pytest.mark.unit 455 class TestGetDeviceTargeting: 456 @pytest.fixture() 457 def client(self) -> _MockExtensionsClient: 458 return _MockExtensionsClient() 459 460 @pytest.mark.asyncio 461 async def test_all_devices_returned(self, client: _MockExtensionsClient) -> None: 462 """criterionが無くても3デバイスが常に返る""" 463 client._search = AsyncMock(return_value=[]) 464 result = await client.get_device_targeting("123") 465 assert len(result) == 3 466 device_types = {r["device_type"] for r in result} 467 assert device_types == {"DESKTOP", "MOBILE", "TABLET"} 468 # デフォルトは有効 469 for r in result: 470 assert r["enabled"] is True 471 assert r["criterion_id"] is None 472 473 @pytest.mark.asyncio 474 async def test_disabled_device(self, client: _MockExtensionsClient) -> None: 475 """bid_modifier=0.0 のデバイスは無効と判定される""" 476 mock_row = MagicMock() 477 mock_row.campaign_criterion.device.type_ = 4 # DESKTOP 478 mock_row.campaign_criterion.bid_modifier = 0.0 479 mock_row.campaign_criterion.criterion_id = "99" 480 client._search = AsyncMock(return_value=[mock_row]) 481 482 result = await client.get_device_targeting("123") 483 desktop = [r for r in result if r["device_type"] == "DESKTOP"][0] 484 assert desktop["enabled"] is False 485 assert desktop["criterion_id"] == "99" 486 487 488 # --------------------------------------------------------------------------- 489 # set_device_targeting バリデーションテスト 490 # --------------------------------------------------------------------------- 491 492 493 @pytest.mark.unit 494 class TestSetDeviceTargetingValidation: 495 @pytest.fixture() 496 def client(self) -> _MockExtensionsClient: 497 return _MockExtensionsClient() 498 499 @pytest.mark.asyncio 500 async def test_invalid_device_raises(self, client: _MockExtensionsClient) -> None: 501 with pytest.raises(ValueError, match="Invalid device type"): 502 await client.set_device_targeting( 503 { 504 "campaign_id": "123", 505 "enabled_devices": ["PHONE"], 506 } 507 ) 508 509 @pytest.mark.asyncio 510 async def test_empty_devices_raises(self, client: _MockExtensionsClient) -> None: 511 with pytest.raises(ValueError, match="At least one"): 512 await client.set_device_targeting( 513 { 514 "campaign_id": "123", 515 "enabled_devices": [], 516 } 517 ) 518 519 520 # --------------------------------------------------------------------------- 521 # get_bid_adjustments テスト 522 # --------------------------------------------------------------------------- 523 524 525 @pytest.mark.unit 526 class TestGetBidAdjustments: 527 @pytest.fixture() 528 def client(self) -> _MockExtensionsClient: 529 return _MockExtensionsClient() 530 531 @pytest.mark.asyncio 532 async def test_returns_adjustments(self, client: _MockExtensionsClient) -> None: 533 mock_row = MagicMock() 534 mock_row.campaign_criterion.criterion_id = "1" 535 mock_row.campaign_criterion.type_ = "DEVICE" 536 mock_row.campaign_criterion.bid_modifier = 1.5 537 mock_row.campaign_criterion.device.type_ = 4 # DESKTOP 538 client._search = AsyncMock(return_value=[mock_row]) 539 540 result = await client.get_bid_adjustments("123") 541 assert len(result) == 1 542 assert result[0]["bid_modifier"] == 1.5 543 assert result[0]["device_type"] == "DESKTOP" 544 545 @pytest.mark.asyncio 546 async def test_empty_adjustments(self, client: _MockExtensionsClient) -> None: 547 client._search = AsyncMock(return_value=[]) 548 result = await client.get_bid_adjustments("123") 549 assert result == [] 550 551 552 # --------------------------------------------------------------------------- 553 # update_bid_adjustment バリデーションテスト 554 # --------------------------------------------------------------------------- 555 556 557 @pytest.mark.unit 558 class TestUpdateBidAdjustmentValidation: 559 @pytest.fixture() 560 def client(self) -> _MockExtensionsClient: 561 return _MockExtensionsClient() 562 563 @pytest.mark.asyncio 564 async def test_bid_modifier_too_low(self, client: _MockExtensionsClient) -> None: 565 with pytest.raises(ValueError, match="0\\.1.+10\\.0"): 566 await client.update_bid_adjustment( 567 { 568 "campaign_id": "123", 569 "criterion_id": "1", 570 "bid_modifier": 0.05, 571 } 572 ) 573 574 @pytest.mark.asyncio 575 async def test_bid_modifier_too_high(self, client: _MockExtensionsClient) -> None: 576 with pytest.raises(ValueError, match="0\\.1.+10\\.0"): 577 await client.update_bid_adjustment( 578 { 579 "campaign_id": "123", 580 "criterion_id": "1", 581 "bid_modifier": 10.5, 582 } 583 ) 584 585 586 # --------------------------------------------------------------------------- 587 # list_change_history テスト 588 # --------------------------------------------------------------------------- 589 590 591 @pytest.mark.unit 592 class TestListChangeHistory: 593 @pytest.fixture() 594 def client(self) -> _MockExtensionsClient: 595 return _MockExtensionsClient() 596 597 @pytest.mark.asyncio 598 async def test_default_date_range(self, client: _MockExtensionsClient) -> None: 599 """日付未指定時はデフォルトの14日間で検索される""" 600 mock_row = MagicMock() 601 client._search = AsyncMock(return_value=[mock_row]) 602 with patch( 603 "mureo.google_ads._extensions_targeting.map_change_event" 604 ) as mock_map: 605 mock_map.return_value = {"change_date_time": "2024-01-01"} 606 result = await client.list_change_history() 607 assert len(result) == 1 608 609 @pytest.mark.asyncio 610 async def test_custom_date_range(self, client: _MockExtensionsClient) -> None: 611 client._search = AsyncMock(return_value=[]) 612 result = await client.list_change_history( 613 start_date="2024-01-01", end_date="2024-01-31" 614 ) 615 assert result == [] 616 617 618 # --------------------------------------------------------------------------- 619 # list_location_targeting テスト 620 # --------------------------------------------------------------------------- 621 622 623 @pytest.mark.unit 624 class TestListLocationTargeting: 625 @pytest.fixture() 626 def client(self) -> _MockExtensionsClient: 627 return _MockExtensionsClient() 628 629 @pytest.mark.asyncio 630 async def test_returns_locations(self, client: _MockExtensionsClient) -> None: 631 mock_row = MagicMock() 632 mock_row.campaign_criterion.criterion_id = "1" 633 mock_row.campaign_criterion.location.geo_target_constant = ( 634 "geoTargetConstants/2392" 635 ) 636 mock_row.campaign_criterion.bid_modifier = 1.0 637 client._search = AsyncMock(return_value=[mock_row]) 638 639 result = await client.list_location_targeting("123") 640 assert len(result) == 1 641 assert "2392" in result[0]["geo_target_constant"] 642 643 @pytest.mark.asyncio 644 async def test_empty_locations(self, client: _MockExtensionsClient) -> None: 645 client._search = AsyncMock(return_value=[]) 646 result = await client.list_location_targeting("123") 647 assert result == [] 648 649 650 # --------------------------------------------------------------------------- 651 # list_schedule_targeting テスト 652 # --------------------------------------------------------------------------- 653 654 655 @pytest.mark.unit 656 class TestListScheduleTargeting: 657 @pytest.fixture() 658 def client(self) -> _MockExtensionsClient: 659 return _MockExtensionsClient() 660 661 @pytest.mark.asyncio 662 async def test_returns_schedules(self, client: _MockExtensionsClient) -> None: 663 mock_row = MagicMock() 664 mock_row.campaign_criterion.criterion_id = "1" 665 mock_row.campaign_criterion.ad_schedule.day_of_week = "MONDAY" 666 mock_row.campaign_criterion.ad_schedule.start_hour = 9 667 mock_row.campaign_criterion.ad_schedule.end_hour = 18 668 mock_row.campaign_criterion.ad_schedule.start_minute = "ZERO" 669 mock_row.campaign_criterion.ad_schedule.end_minute = "ZERO" 670 mock_row.campaign_criterion.bid_modifier = 1.0 671 client._search = AsyncMock(return_value=[mock_row]) 672 673 result = await client.list_schedule_targeting("123") 674 assert len(result) == 1 675 assert result[0]["day_of_week"] == "MONDAY" 676 assert result[0]["start_hour"] == 9 677 assert result[0]["end_hour"] == 18 678 679 680 # --------------------------------------------------------------------------- 681 # get_conversion_performance テスト 682 # --------------------------------------------------------------------------- 683 684 685 @pytest.mark.unit 686 class TestGetConversionPerformance: 687 @pytest.fixture() 688 def client(self) -> _MockExtensionsClient: 689 c = _MockExtensionsClient() 690 c._period_to_date_clause = MagicMock(return_value="DURING LAST_30_DAYS") 691 return c 692 693 @pytest.mark.asyncio 694 async def test_empty_response(self, client: _MockExtensionsClient) -> None: 695 client._search = AsyncMock(return_value=[]) 696 result = await client.get_conversion_performance() 697 assert result["total_conversions"] == 0 698 assert result["actions"] == [] 699 assert result["daily_details"] == [] 700 701 @pytest.mark.asyncio 702 async def test_with_data(self, client: _MockExtensionsClient) -> None: 703 cv_row = MagicMock() 704 cv_row.campaign.id = 1 705 cv_row.campaign.name = "Campaign A" 706 cv_row.segments.conversion_action_name = "Purchase" 707 cv_row.segments.date = "2024-01-01" 708 cv_row.metrics.conversions = 5.0 709 cv_row.metrics.conversions_value = 50000.0 710 711 cost_row = MagicMock() 712 cost_row.campaign.id = 1 713 cost_row.metrics.cost_micros = 10_000_000_000 # 10,000円 714 715 # 1回目: CV別、2回目: コスト、3回目: LP別 716 client._search = AsyncMock( 717 side_effect=[ 718 [cv_row], 719 [cost_row], 720 [], # LP 721 ] 722 ) 723 result = await client.get_conversion_performance() 724 assert result["total_conversions"] == 5.0 725 assert len(result["actions"]) == 1 726 assert result["actions"][0]["conversion_action_name"] == "Purchase" 727 728 @pytest.mark.asyncio 729 async def test_with_campaign_id_filter(self, client: _MockExtensionsClient) -> None: 730 """campaign_id指定時のフィルタリング""" 731 client._search = AsyncMock(return_value=[]) 732 result = await client.get_conversion_performance(campaign_id="456") 733 assert result["campaign_id"] == "456" 734 assert result["total_conversions"] == 0 735 736 @pytest.mark.asyncio 737 async def test_cost_query_failure_fallback( 738 self, client: _MockExtensionsClient 739 ) -> None: 740 """コスト取得失敗時にCPAは0で返却される""" 741 cv_row = MagicMock() 742 cv_row.campaign.id = 1 743 cv_row.campaign.name = "Campaign A" 744 cv_row.segments.conversion_action_name = "Purchase" 745 cv_row.segments.date = "2024-01-01" 746 cv_row.metrics.conversions = 3.0 747 cv_row.metrics.conversions_value = 30000.0 748 749 client._search = AsyncMock( 750 side_effect=[ 751 [cv_row], # CV別 752 RuntimeError("fail"), # コスト取得失敗 753 [], # LP 754 ] 755 ) 756 result = await client.get_conversion_performance() 757 assert result["total_conversions"] == 3.0 758 assert result["actions"][0]["cost_per_conversion"] == 0 759 760 @pytest.mark.asyncio 761 async def test_lp_query_failure_graceful( 762 self, client: _MockExtensionsClient 763 ) -> None: 764 """LP別CV取得失敗時も正常に返却される""" 765 cv_row = MagicMock() 766 cv_row.campaign.id = 1 767 cv_row.campaign.name = "Campaign A" 768 cv_row.segments.conversion_action_name = "Purchase" 769 cv_row.segments.date = "2024-01-01" 770 cv_row.metrics.conversions = 2.0 771 cv_row.metrics.conversions_value = 20000.0 772 773 cost_row = MagicMock() 774 cost_row.campaign.id = 1 775 cost_row.metrics.cost_micros = 5_000_000_000 776 777 client._search = AsyncMock( 778 side_effect=[ 779 [cv_row], 780 [cost_row], 781 RuntimeError("lp fail"), # LP取得失敗 782 ] 783 ) 784 result = await client.get_conversion_performance() 785 assert result["total_conversions"] == 2.0 786 assert result["landing_pages"] == [] 787 788 @pytest.mark.asyncio 789 async def test_with_lp_data(self, client: _MockExtensionsClient) -> None: 790 """LP別CVデータが正しく返る""" 791 cv_row = MagicMock() 792 cv_row.campaign.id = 1 793 cv_row.campaign.name = "Campaign A" 794 cv_row.segments.conversion_action_name = "Purchase" 795 cv_row.segments.date = "2024-01-01" 796 cv_row.metrics.conversions = 5.0 797 cv_row.metrics.conversions_value = 50000.0 798 799 cost_row = MagicMock() 800 cost_row.campaign.id = 1 801 cost_row.metrics.cost_micros = 10_000_000_000 802 803 lp_row = MagicMock() 804 lp_row.segments.date = "2024-01-01" 805 lp_row.landing_page_view.unexpanded_final_url = "https://example.com" 806 lp_row.campaign.id = 1 807 lp_row.campaign.name = "Campaign A" 808 lp_row.metrics.conversions = 5.0 809 lp_row.metrics.conversions_value = 50000.0 810 lp_row.metrics.clicks = 100 811 812 client._search = AsyncMock( 813 side_effect=[ 814 [cv_row], 815 [cost_row], 816 [lp_row], 817 ] 818 ) 819 result = await client.get_conversion_performance() 820 assert len(result["landing_pages"]) == 1 821 assert result["landing_pages"][0]["landing_page_url"] == "https://example.com" 822 823 @pytest.mark.asyncio 824 async def test_date_range_tracking_in_summary( 825 self, client: _MockExtensionsClient 826 ) -> None: 827 """複数日のデータでfirst_date/last_dateが正しく設定される""" 828 rows = [] 829 for d in ["2024-01-02", "2024-01-01", "2024-01-03"]: 830 row = MagicMock() 831 row.campaign.id = 1 832 row.campaign.name = "C" 833 row.segments.conversion_action_name = "Purchase" 834 row.segments.date = d 835 row.metrics.conversions = 1.0 836 row.metrics.conversions_value = 1000.0 837 rows.append(row) 838 839 client._search = AsyncMock( 840 side_effect=[ 841 rows, 842 [], # cost 843 [], # lp 844 ] 845 ) 846 result = await client.get_conversion_performance() 847 action = result["actions"][0] 848 assert action["first_date"] == "2024-01-01" 849 assert action["last_date"] == "2024-01-03" 850 assert action["conversions"] == 3.0 851 852 853 # --------------------------------------------------------------------------- 854 # create_callout 正常系テスト 855 # --------------------------------------------------------------------------- 856 857 858 @pytest.mark.unit 859 class TestCreateCalloutSuccess: 860 @pytest.fixture() 861 def client(self) -> _MockExtensionsClient: 862 return _MockExtensionsClient() 863 864 @pytest.mark.asyncio 865 async def test_create_callout_success(self, client: _MockExtensionsClient) -> None: 866 """コールアウト作成が正常に完了する""" 867 with patch.object(client, "list_callouts", new_callable=AsyncMock) as mock_lc: 868 mock_lc.return_value = [{"id": str(i)} for i in range(5)] 869 870 # AssetService mock 871 asset_service = MagicMock() 872 asset_response = MagicMock() 873 asset_response.results = [ 874 MagicMock(resource_name="customers/123/assets/456") 875 ] 876 asset_service.mutate_assets.return_value = asset_response 877 878 # CampaignAssetService mock 879 ca_service = MagicMock() 880 ca_service.mutate_campaign_assets.return_value = MagicMock() 881 882 def get_service(name: str) -> MagicMock: 883 if name == "AssetService": 884 return asset_service 885 return ca_service 886 887 client._get_service = get_service 888 889 # client mock setup 890 asset_op = MagicMock() 891 client._client.get_type.return_value = asset_op 892 client._client.get_service.return_value = MagicMock( 893 campaign_path=MagicMock(return_value="customers/123/campaigns/789") 894 ) 895 client._client.enums.AssetFieldTypeEnum.CALLOUT = "CALLOUT" 896 897 result = await client.create_callout( 898 { 899 "campaign_id": "789", 900 "callout_text": "Free Shipping", 901 } 902 ) 903 904 assert result["resource_name"] == "customers/123/assets/456" 905 906 907 # --------------------------------------------------------------------------- 908 # remove_callout 正常系テスト 909 # --------------------------------------------------------------------------- 910 911 912 @pytest.mark.unit 913 class TestRemoveCallout: 914 @pytest.fixture() 915 def client(self) -> _MockExtensionsClient: 916 return _MockExtensionsClient() 917 918 @pytest.mark.asyncio 919 async def test_remove_callout_success(self, client: _MockExtensionsClient) -> None: 920 """コールアウト削除が正常に完了する""" 921 ca_service = MagicMock() 922 response = MagicMock() 923 response.results = [MagicMock(resource_name="customers/123/campaignAssets/del")] 924 ca_service.mutate_campaign_assets.return_value = response 925 926 client._get_service = lambda name: ca_service 927 928 op = MagicMock() 929 client._client.get_type.return_value = op 930 client._client.get_service.return_value = MagicMock( 931 campaign_asset_path=MagicMock( 932 return_value="customers/123/campaignAssets/456~789~CALLOUT" 933 ) 934 ) 935 936 result = await client.remove_callout( 937 { 938 "campaign_id": "123", 939 "asset_id": "456", 940 } 941 ) 942 assert result["resource_name"] == "customers/123/campaignAssets/del" 943 944 @pytest.mark.asyncio 945 async def test_remove_callout_invalid_campaign_id( 946 self, client: _MockExtensionsClient 947 ) -> None: 948 """無効なcampaign_idでバリデーションエラー""" 949 with pytest.raises(ValueError, match="Invalid"): 950 await client.remove_callout( 951 { 952 "campaign_id": "abc", 953 "asset_id": "456", 954 } 955 ) 956 957 @pytest.mark.asyncio 958 async def test_remove_callout_invalid_asset_id( 959 self, client: _MockExtensionsClient 960 ) -> None: 961 """無効なasset_idでバリデーションエラー""" 962 with pytest.raises(ValueError, match="Invalid"): 963 await client.remove_callout( 964 { 965 "campaign_id": "123", 966 "asset_id": "abc", 967 } 968 ) 969 970 971 # --------------------------------------------------------------------------- 972 # create_sitelink 正常系テスト 973 # --------------------------------------------------------------------------- 974 975 976 @pytest.mark.unit 977 class TestCreateSitelinkSuccess: 978 @pytest.fixture() 979 def client(self) -> _MockExtensionsClient: 980 return _MockExtensionsClient() 981 982 @pytest.mark.asyncio 983 async def test_create_sitelink_success(self, client: _MockExtensionsClient) -> None: 984 """サイトリンク作成が正常に完了する""" 985 with patch.object(client, "list_sitelinks", new_callable=AsyncMock) as mock_ls: 986 mock_ls.return_value = [ 987 {"id": str(i), "level": "campaign"} for i in range(5) 988 ] 989 990 asset_service = MagicMock() 991 asset_response = MagicMock() 992 asset_response.results = [ 993 MagicMock(resource_name="customers/123/assets/789") 994 ] 995 asset_service.mutate_assets.return_value = asset_response 996 997 ca_service = MagicMock() 998 ca_service.mutate_campaign_assets.return_value = MagicMock() 999 1000 def get_service(name: str) -> MagicMock: 1001 if name == "AssetService": 1002 return asset_service 1003 return ca_service 1004 1005 client._get_service = get_service 1006 1007 asset_op = MagicMock() 1008 asset_op.create.final_urls = [] 1009 client._client.get_type.return_value = asset_op 1010 client._client.get_service.return_value = MagicMock( 1011 campaign_path=MagicMock(return_value="customers/123/campaigns/456") 1012 ) 1013 client._client.enums.AssetFieldTypeEnum.SITELINK = "SITELINK" 1014 1015 result = await client.create_sitelink( 1016 { 1017 "campaign_id": "456", 1018 "link_text": "About Us", 1019 "final_url": "https://example.com/about", 1020 } 1021 ) 1022 1023 assert result["resource_name"] == "customers/123/assets/789" 1024 1025 @pytest.mark.asyncio 1026 async def test_create_sitelink_with_descriptions( 1027 self, client: _MockExtensionsClient 1028 ) -> None: 1029 """description1/description2を含むサイトリンク作成""" 1030 with patch.object(client, "list_sitelinks", new_callable=AsyncMock) as mock_ls: 1031 mock_ls.return_value = [] 1032 1033 asset_service = MagicMock() 1034 asset_response = MagicMock() 1035 asset_response.results = [ 1036 MagicMock(resource_name="customers/123/assets/789") 1037 ] 1038 asset_service.mutate_assets.return_value = asset_response 1039 1040 ca_service = MagicMock() 1041 ca_service.mutate_campaign_assets.return_value = MagicMock() 1042 1043 def get_service(name: str) -> MagicMock: 1044 if name == "AssetService": 1045 return asset_service 1046 return ca_service 1047 1048 client._get_service = get_service 1049 1050 asset_op = MagicMock() 1051 asset_op.create.final_urls = [] 1052 client._client.get_type.return_value = asset_op 1053 client._client.get_service.return_value = MagicMock( 1054 campaign_path=MagicMock(return_value="customers/123/campaigns/456") 1055 ) 1056 client._client.enums.AssetFieldTypeEnum.SITELINK = "SITELINK" 1057 1058 result = await client.create_sitelink( 1059 { 1060 "campaign_id": "456", 1061 "link_text": "About Us", 1062 "final_url": "https://example.com/about", 1063 "description1": "Learn more", 1064 "description2": "About our company", 1065 } 1066 ) 1067 1068 assert result["resource_name"] == "customers/123/assets/789" 1069 # description1/description2がセットされたことを確認 1070 asset_op.create.sitelink_asset.description1 = "Learn more" 1071 asset_op.create.sitelink_asset.description2 = "About our company" 1072 1073 @pytest.mark.asyncio 1074 async def test_create_sitelink_account_level_not_counted( 1075 self, client: _MockExtensionsClient 1076 ) -> None: 1077 """アカウントレベルのサイトリンクは上限カウントに含まれない""" 1078 with patch.object(client, "list_sitelinks", new_callable=AsyncMock) as mock_ls: 1079 # 19件キャンペーン + 5件アカウント = 24件だが、キャンペーンレベルは19なので作成可能 1080 mock_ls.return_value = [ 1081 {"id": str(i), "level": "campaign"} for i in range(19) 1082 ] + [{"id": str(i + 100), "level": "account"} for i in range(5)] 1083 1084 asset_service = MagicMock() 1085 asset_response = MagicMock() 1086 asset_response.results = [ 1087 MagicMock(resource_name="customers/123/assets/new") 1088 ] 1089 asset_service.mutate_assets.return_value = asset_response 1090 1091 ca_service = MagicMock() 1092 ca_service.mutate_campaign_assets.return_value = MagicMock() 1093 1094 def get_service(name: str) -> MagicMock: 1095 if name == "AssetService": 1096 return asset_service 1097 return ca_service 1098 1099 client._get_service = get_service 1100 1101 asset_op = MagicMock() 1102 asset_op.create.final_urls = [] 1103 client._client.get_type.return_value = asset_op 1104 client._client.get_service.return_value = MagicMock( 1105 campaign_path=MagicMock(return_value="customers/123/campaigns/456") 1106 ) 1107 client._client.enums.AssetFieldTypeEnum.SITELINK = "SITELINK" 1108 1109 result = await client.create_sitelink( 1110 { 1111 "campaign_id": "456", 1112 "link_text": "New Link", 1113 "final_url": "https://example.com/new", 1114 } 1115 ) 1116 1117 assert "resource_name" in result 1118 1119 1120 # --------------------------------------------------------------------------- 1121 # remove_sitelink 正常系テスト 1122 # --------------------------------------------------------------------------- 1123 1124 1125 @pytest.mark.unit 1126 class TestRemoveSitelink: 1127 @pytest.fixture() 1128 def client(self) -> _MockExtensionsClient: 1129 return _MockExtensionsClient() 1130 1131 @pytest.mark.asyncio 1132 async def test_remove_sitelink_success(self, client: _MockExtensionsClient) -> None: 1133 """サイトリンク削除が正常に完了する""" 1134 ca_service = MagicMock() 1135 response = MagicMock() 1136 response.results = [MagicMock(resource_name="customers/123/campaignAssets/del")] 1137 ca_service.mutate_campaign_assets.return_value = response 1138 1139 client._get_service = lambda name: ca_service 1140 1141 op = MagicMock() 1142 client._client.get_type.return_value = op 1143 client._client.get_service.return_value = MagicMock( 1144 campaign_asset_path=MagicMock( 1145 return_value="customers/123/campaignAssets/456~789~SITELINK" 1146 ) 1147 ) 1148 1149 result = await client.remove_sitelink( 1150 { 1151 "campaign_id": "123", 1152 "asset_id": "456", 1153 } 1154 ) 1155 assert result["resource_name"] == "customers/123/campaignAssets/del" 1156 1157 @pytest.mark.asyncio 1158 async def test_remove_sitelink_invalid_ids( 1159 self, client: _MockExtensionsClient 1160 ) -> None: 1161 """無効なIDでバリデーションエラー""" 1162 with pytest.raises(ValueError, match="Invalid"): 1163 await client.remove_sitelink( 1164 { 1165 "campaign_id": "abc", 1166 "asset_id": "456", 1167 } 1168 ) 1169 1170 1171 # --------------------------------------------------------------------------- 1172 # create_conversion_action 正常系テスト 1173 # --------------------------------------------------------------------------- 1174 1175 1176 @pytest.mark.unit 1177 class TestCreateConversionActionSuccess: 1178 @pytest.fixture() 1179 def client(self) -> _MockExtensionsClient: 1180 return _MockExtensionsClient() 1181 1182 @pytest.mark.asyncio 1183 async def test_create_basic(self, client: _MockExtensionsClient) -> None: 1184 """基本的なコンバージョンアクション作成""" 1185 ca_service = MagicMock() 1186 response = MagicMock() 1187 response.results = [ 1188 MagicMock(resource_name="customers/123/conversionActions/456") 1189 ] 1190 ca_service.mutate_conversion_actions.return_value = response 1191 1192 client._get_service = lambda name: ca_service 1193 1194 op = MagicMock() 1195 client._client.get_type.return_value = op 1196 client._client.enums.ConversionActionTypeEnum.WEBPAGE = "WEBPAGE" 1197 client._client.enums.ConversionActionCategoryEnum.DEFAULT = "DEFAULT" 1198 1199 result = await client.create_conversion_action({"name": "Purchase"}) 1200 assert result["resource_name"] == "customers/123/conversionActions/456" 1201 1202 @pytest.mark.asyncio 1203 async def test_create_with_value_settings( 1204 self, client: _MockExtensionsClient 1205 ) -> None: 1206 """value_settings付きコンバージョンアクション作成""" 1207 ca_service = MagicMock() 1208 response = MagicMock() 1209 response.results = [ 1210 MagicMock(resource_name="customers/123/conversionActions/789") 1211 ] 1212 ca_service.mutate_conversion_actions.return_value = response 1213 1214 client._get_service = lambda name: ca_service 1215 1216 op = MagicMock() 1217 client._client.get_type.return_value = op 1218 client._client.enums.ConversionActionTypeEnum.WEBPAGE = "WEBPAGE" 1219 client._client.enums.ConversionActionCategoryEnum.PURCHASE = "PURCHASE" 1220 1221 result = await client.create_conversion_action( 1222 { 1223 "name": "Purchase", 1224 "type": "WEBPAGE", 1225 "category": "PURCHASE", 1226 "default_value": 5000.0, 1227 "always_use_default_value": True, 1228 "click_through_lookback_window_days": 30, 1229 "view_through_lookback_window_days": 7, 1230 } 1231 ) 1232 assert result["resource_name"] == "customers/123/conversionActions/789" 1233 1234 1235 # --------------------------------------------------------------------------- 1236 # update_conversion_action 正常系テスト 1237 # --------------------------------------------------------------------------- 1238 1239 1240 @pytest.mark.unit 1241 class TestUpdateConversionActionSuccess: 1242 @pytest.fixture() 1243 def client(self) -> _MockExtensionsClient: 1244 return _MockExtensionsClient() 1245 1246 @pytest.mark.asyncio 1247 async def test_update_name(self, client: _MockExtensionsClient) -> None: 1248 """名前の更新""" 1249 ca_service = MagicMock() 1250 response = MagicMock() 1251 response.results = [ 1252 MagicMock(resource_name="customers/123/conversionActions/456") 1253 ] 1254 ca_service.mutate_conversion_actions.return_value = response 1255 1256 client._get_service = lambda name: ca_service 1257 1258 op = MagicMock() 1259 client._client.get_type.return_value = op 1260 client._client.get_service.return_value = MagicMock( 1261 conversion_action_path=MagicMock( 1262 return_value="customers/123/conversionActions/456" 1263 ) 1264 ) 1265 1266 result = await client.update_conversion_action( 1267 { 1268 "conversion_action_id": "456", 1269 "name": "New Name", 1270 } 1271 ) 1272 assert result["resource_name"] == "customers/123/conversionActions/456" 1273 1274 @pytest.mark.asyncio 1275 async def test_update_multiple_fields(self, client: _MockExtensionsClient) -> None: 1276 """複数フィールド同時更新""" 1277 ca_service = MagicMock() 1278 response = MagicMock() 1279 response.results = [ 1280 MagicMock(resource_name="customers/123/conversionActions/456") 1281 ] 1282 ca_service.mutate_conversion_actions.return_value = response 1283 1284 client._get_service = lambda name: ca_service 1285 1286 op = MagicMock() 1287 client._client.get_type.return_value = op 1288 client._client.get_service.return_value = MagicMock( 1289 conversion_action_path=MagicMock( 1290 return_value="customers/123/conversionActions/456" 1291 ) 1292 ) 1293 client._client.enums.ConversionActionCategoryEnum.PURCHASE = "PURCHASE" 1294 client._client.enums.ConversionActionStatusEnum.ENABLED = "ENABLED" 1295 1296 result = await client.update_conversion_action( 1297 { 1298 "conversion_action_id": "456", 1299 "name": "Updated", 1300 "category": "PURCHASE", 1301 "status": "ENABLED", 1302 "default_value": 1000.0, 1303 "always_use_default_value": False, 1304 "click_through_lookback_window_days": 60, 1305 "view_through_lookback_window_days": 14, 1306 } 1307 ) 1308 assert result["resource_name"] == "customers/123/conversionActions/456" 1309 1310 @pytest.mark.asyncio 1311 async def test_update_long_name_raises(self, client: _MockExtensionsClient) -> None: 1312 """更新時のname長チェック""" 1313 with pytest.raises(ValueError, match="256 characters"): 1314 await client.update_conversion_action( 1315 { 1316 "conversion_action_id": "456", 1317 "name": "x" * 257, 1318 } 1319 ) 1320 1321 @pytest.mark.asyncio 1322 async def test_update_lookback_window_out_of_range( 1323 self, client: _MockExtensionsClient 1324 ) -> None: 1325 """更新時のlookback windowバリデーション""" 1326 with pytest.raises(ValueError, match="1.+90"): 1327 await client.update_conversion_action( 1328 { 1329 "conversion_action_id": "456", 1330 "click_through_lookback_window_days": 91, 1331 } 1332 ) 1333 1334 @pytest.mark.asyncio 1335 async def test_update_view_through_out_of_range( 1336 self, client: _MockExtensionsClient 1337 ) -> None: 1338 """更新時のview_through_lookback_window_daysバリデーション""" 1339 with pytest.raises(ValueError, match="1.+30"): 1340 await client.update_conversion_action( 1341 { 1342 "conversion_action_id": "456", 1343 "view_through_lookback_window_days": 31, 1344 } 1345 ) 1346 1347 1348 # --------------------------------------------------------------------------- 1349 # remove_conversion_action 正常系テスト 1350 # --------------------------------------------------------------------------- 1351 1352 1353 @pytest.mark.unit 1354 class TestRemoveConversionAction: 1355 @pytest.fixture() 1356 def client(self) -> _MockExtensionsClient: 1357 return _MockExtensionsClient() 1358 1359 @pytest.mark.asyncio 1360 async def test_remove_success(self, client: _MockExtensionsClient) -> None: 1361 """コンバージョンアクション削除が正常に完了する""" 1362 ca_service = MagicMock() 1363 response = MagicMock() 1364 response.results = [ 1365 MagicMock(resource_name="customers/123/conversionActions/456") 1366 ] 1367 ca_service.mutate_conversion_actions.return_value = response 1368 1369 client._get_service = lambda name: ca_service 1370 1371 op = MagicMock() 1372 client._client.get_type.return_value = op 1373 client._client.get_service.return_value = MagicMock( 1374 conversion_action_path=MagicMock( 1375 return_value="customers/123/conversionActions/456" 1376 ) 1377 ) 1378 1379 result = await client.remove_conversion_action({"conversion_action_id": "456"}) 1380 assert result["resource_name"] == "customers/123/conversionActions/456" 1381 1382 @pytest.mark.asyncio 1383 async def test_remove_invalid_id(self, client: _MockExtensionsClient) -> None: 1384 """無効なIDでバリデーションエラー""" 1385 with pytest.raises(ValueError, match="Invalid"): 1386 await client.remove_conversion_action({"conversion_action_id": "abc"}) 1387 1388 1389 # --------------------------------------------------------------------------- 1390 # set_device_targeting 正常系テスト 1391 # --------------------------------------------------------------------------- 1392 1393 1394 @pytest.mark.unit 1395 class TestSetDeviceTargetingSuccess: 1396 @pytest.fixture() 1397 def client(self) -> _MockExtensionsClient: 1398 c = _MockExtensionsClient() 1399 c._extract_error_detail = MagicMock(return_value="error detail") 1400 return c 1401 1402 @pytest.mark.asyncio 1403 async def test_set_devices_update_existing( 1404 self, client: _MockExtensionsClient 1405 ) -> None: 1406 """既存criterionのbid_modifierを更新""" 1407 # 既存のDESKTOP criterionがある 1408 mock_row = MagicMock() 1409 mock_row.campaign_criterion.device.type_ = 4 # DESKTOP 1410 mock_row.campaign_criterion.bid_modifier = 1.0 1411 mock_row.campaign_criterion.criterion_id = "100" 1412 client._search = AsyncMock(return_value=[mock_row]) 1413 1414 cc_service = MagicMock() 1415 resp = MagicMock() 1416 resp.results = [MagicMock(resource_name="customers/123/campaignCriteria/100")] 1417 cc_service.mutate_campaign_criteria.return_value = resp 1418 cc_service.campaign_criterion_path = MagicMock( 1419 return_value="customers/123/campaignCriteria/100" 1420 ) 1421 1422 client._get_service = lambda name: cc_service 1423 1424 op = MagicMock() 1425 client._client.get_type.return_value = op 1426 client._client.get_service.return_value = MagicMock( 1427 campaign_path=MagicMock(return_value="customers/123/campaigns/456") 1428 ) 1429 client._client.enums.DeviceEnum.DESKTOP = "DESKTOP" 1430 client._client.enums.DeviceEnum.MOBILE = "MOBILE" 1431 client._client.enums.DeviceEnum.TABLET = "TABLET" 1432 1433 result = await client.set_device_targeting( 1434 { 1435 "campaign_id": "456", 1436 "enabled_devices": ["DESKTOP", "MOBILE"], 1437 } 1438 ) 1439 assert "DESKTOP" in result["enabled_devices"] 1440 assert "MOBILE" in result["enabled_devices"] 1441 assert "TABLET" in result["disabled_devices"] 1442 1443 @pytest.mark.asyncio 1444 async def test_set_devices_create_new(self, client: _MockExtensionsClient) -> None: 1445 """criterionが存在しない場合は新規作成""" 1446 client._search = AsyncMock(return_value=[]) 1447 1448 cc_service = MagicMock() 1449 resp = MagicMock() 1450 resp.results = [MagicMock(resource_name="customers/123/campaignCriteria/new")] 1451 cc_service.mutate_campaign_criteria.return_value = resp 1452 1453 client._get_service = lambda name: cc_service 1454 1455 op = MagicMock() 1456 client._client.get_type.return_value = op 1457 client._client.get_service.return_value = MagicMock( 1458 campaign_path=MagicMock(return_value="customers/123/campaigns/456") 1459 ) 1460 client._client.enums.DeviceEnum.DESKTOP = "DESKTOP" 1461 client._client.enums.DeviceEnum.MOBILE = "MOBILE" 1462 client._client.enums.DeviceEnum.TABLET = "TABLET" 1463 1464 result = await client.set_device_targeting( 1465 { 1466 "campaign_id": "456", 1467 "enabled_devices": ["MOBILE"], 1468 } 1469 ) 1470 assert "MOBILE" in result["enabled_devices"] 1471 assert len(result["updated"]) == 3 # 3デバイス分のmutate 1472 1473 @pytest.mark.asyncio 1474 async def test_set_devices_partial_failure( 1475 self, client: _MockExtensionsClient 1476 ) -> None: 1477 """一部デバイスの設定失敗時にerrorsを含むが結果は返す""" 1478 client._search = AsyncMock(return_value=[]) 1479 1480 cc_service = MagicMock() 1481 call_count = 0 1482 1483 def mutate_side_effect(**kwargs): 1484 nonlocal call_count 1485 call_count += 1 1486 if call_count == 2: 1487 raise RuntimeError("API error") 1488 resp = MagicMock() 1489 resp.results = [MagicMock(resource_name=f"res/{call_count}")] 1490 return resp 1491 1492 cc_service.mutate_campaign_criteria.side_effect = mutate_side_effect 1493 1494 client._get_service = lambda name: cc_service 1495 1496 op = MagicMock() 1497 client._client.get_type.return_value = op 1498 client._client.get_service.return_value = MagicMock( 1499 campaign_path=MagicMock(return_value="customers/123/campaigns/456") 1500 ) 1501 client._client.enums.DeviceEnum.DESKTOP = "DESKTOP" 1502 client._client.enums.DeviceEnum.MOBILE = "MOBILE" 1503 client._client.enums.DeviceEnum.TABLET = "TABLET" 1504 1505 result = await client.set_device_targeting( 1506 { 1507 "campaign_id": "456", 1508 "enabled_devices": ["DESKTOP"], 1509 } 1510 ) 1511 assert len(result["updated"]) == 2 1512 assert result["errors"] is not None 1513 assert len(result["errors"]) == 1 1514 1515 @pytest.mark.asyncio 1516 async def test_set_devices_all_fail_raises( 1517 self, client: _MockExtensionsClient 1518 ) -> None: 1519 """全デバイスの設定失敗時にValueErrorを送出""" 1520 client._search = AsyncMock(return_value=[]) 1521 1522 cc_service = MagicMock() 1523 cc_service.mutate_campaign_criteria.side_effect = RuntimeError("all fail") 1524 1525 client._get_service = lambda name: cc_service 1526 1527 op = MagicMock() 1528 client._client.get_type.return_value = op 1529 client._client.get_service.return_value = MagicMock( 1530 campaign_path=MagicMock(return_value="customers/123/campaigns/456") 1531 ) 1532 client._client.enums.DeviceEnum.DESKTOP = "DESKTOP" 1533 client._client.enums.DeviceEnum.MOBILE = "MOBILE" 1534 client._client.enums.DeviceEnum.TABLET = "TABLET" 1535 1536 with pytest.raises(ValueError, match="Failed to set all devices"): 1537 await client.set_device_targeting( 1538 { 1539 "campaign_id": "456", 1540 "enabled_devices": ["DESKTOP"], 1541 } 1542 ) 1543 1544 1545 # --------------------------------------------------------------------------- 1546 # update_bid_adjustment 正常系テスト 1547 # --------------------------------------------------------------------------- 1548 1549 1550 @pytest.mark.unit 1551 class TestUpdateBidAdjustmentSuccess: 1552 @pytest.fixture() 1553 def client(self) -> _MockExtensionsClient: 1554 return _MockExtensionsClient() 1555 1556 @pytest.mark.asyncio 1557 async def test_update_success(self, client: _MockExtensionsClient) -> None: 1558 """入札調整率の更新が正常に完了する""" 1559 cc_service = MagicMock() 1560 response = MagicMock() 1561 response.results = [ 1562 MagicMock(resource_name="customers/123/campaignCriteria/456") 1563 ] 1564 cc_service.mutate_campaign_criteria.return_value = response 1565 1566 client._get_service = lambda name: cc_service 1567 1568 op = MagicMock() 1569 client._client.get_type.return_value = op 1570 client._client.get_service.return_value = MagicMock( 1571 campaign_criterion_path=MagicMock( 1572 return_value="customers/123/campaignCriteria/456" 1573 ) 1574 ) 1575 1576 result = await client.update_bid_adjustment( 1577 { 1578 "campaign_id": "123", 1579 "criterion_id": "456", 1580 "bid_modifier": 1.5, 1581 } 1582 ) 1583 assert result["resource_name"] == "customers/123/campaignCriteria/456" 1584 1585 1586 # --------------------------------------------------------------------------- 1587 # update_location_targeting 正常系テスト 1588 # --------------------------------------------------------------------------- 1589 1590 1591 @pytest.mark.unit 1592 class TestUpdateLocationTargeting: 1593 @pytest.fixture() 1594 def client(self) -> _MockExtensionsClient: 1595 return _MockExtensionsClient() 1596 1597 @pytest.mark.asyncio 1598 async def test_add_locations(self, client: _MockExtensionsClient) -> None: 1599 """地域ターゲティングの追加""" 1600 cc_service = MagicMock() 1601 response = MagicMock() 1602 response.results = [ 1603 MagicMock(resource_name="customers/123/campaignCriteria/new") 1604 ] 1605 cc_service.mutate_campaign_criteria.return_value = response 1606 1607 client._get_service = lambda name: cc_service 1608 1609 op = MagicMock() 1610 client._client.get_type.return_value = op 1611 client._client.get_service.return_value = MagicMock( 1612 campaign_path=MagicMock(return_value="customers/123/campaigns/456"), 1613 campaign_criterion_path=MagicMock( 1614 return_value="customers/123/campaignCriteria/old" 1615 ), 1616 ) 1617 1618 result = await client.update_location_targeting( 1619 { 1620 "campaign_id": "456", 1621 "add_locations": ["2392"], 1622 } 1623 ) 1624 assert len(result) == 1 1625 assert result[0]["resource_name"] == "customers/123/campaignCriteria/new" 1626 1627 @pytest.mark.asyncio 1628 async def test_add_locations_with_full_path( 1629 self, client: _MockExtensionsClient 1630 ) -> None: 1631 """geoTargetConstants/ID形式での追加""" 1632 cc_service = MagicMock() 1633 response = MagicMock() 1634 response.results = [ 1635 MagicMock(resource_name="customers/123/campaignCriteria/new") 1636 ] 1637 cc_service.mutate_campaign_criteria.return_value = response 1638 1639 client._get_service = lambda name: cc_service 1640 1641 op = MagicMock() 1642 client._client.get_type.return_value = op 1643 client._client.get_service.return_value = MagicMock( 1644 campaign_path=MagicMock(return_value="customers/123/campaigns/456"), 1645 ) 1646 1647 result = await client.update_location_targeting( 1648 { 1649 "campaign_id": "456", 1650 "add_locations": ["geoTargetConstants/2392"], 1651 } 1652 ) 1653 assert len(result) == 1 1654 1655 @pytest.mark.asyncio 1656 async def test_remove_locations(self, client: _MockExtensionsClient) -> None: 1657 """地域ターゲティングの削除""" 1658 cc_service = MagicMock() 1659 response = MagicMock() 1660 response.results = [ 1661 MagicMock(resource_name="customers/123/campaignCriteria/del") 1662 ] 1663 cc_service.mutate_campaign_criteria.return_value = response 1664 1665 client._get_service = lambda name: cc_service 1666 1667 op = MagicMock() 1668 client._client.get_type.return_value = op 1669 client._client.get_service.return_value = MagicMock( 1670 campaign_criterion_path=MagicMock( 1671 return_value="customers/123/campaignCriteria/del" 1672 ), 1673 ) 1674 1675 result = await client.update_location_targeting( 1676 { 1677 "campaign_id": "456", 1678 "remove_criterion_ids": ["789"], 1679 } 1680 ) 1681 assert len(result) == 1 1682 1683 @pytest.mark.asyncio 1684 async def test_add_and_remove_locations( 1685 self, client: _MockExtensionsClient 1686 ) -> None: 1687 """追加と削除の同時操作""" 1688 cc_service = MagicMock() 1689 response = MagicMock() 1690 response.results = [ 1691 MagicMock(resource_name="customers/123/campaignCriteria/1"), 1692 MagicMock(resource_name="customers/123/campaignCriteria/2"), 1693 ] 1694 cc_service.mutate_campaign_criteria.return_value = response 1695 1696 client._get_service = lambda name: cc_service 1697 1698 op = MagicMock() 1699 client._client.get_type.return_value = op 1700 client._client.get_service.return_value = MagicMock( 1701 campaign_path=MagicMock(return_value="customers/123/campaigns/456"), 1702 campaign_criterion_path=MagicMock( 1703 return_value="customers/123/campaignCriteria/del" 1704 ), 1705 ) 1706 1707 result = await client.update_location_targeting( 1708 { 1709 "campaign_id": "456", 1710 "add_locations": ["2392"], 1711 "remove_criterion_ids": ["100"], 1712 } 1713 ) 1714 assert len(result) == 2 1715 1716 1717 # --------------------------------------------------------------------------- 1718 # update_schedule_targeting 正常系テスト 1719 # --------------------------------------------------------------------------- 1720 1721 1722 @pytest.mark.unit 1723 class TestUpdateScheduleTargeting: 1724 @pytest.fixture() 1725 def client(self) -> _MockExtensionsClient: 1726 return _MockExtensionsClient() 1727 1728 @pytest.mark.asyncio 1729 async def test_add_schedules(self, client: _MockExtensionsClient) -> None: 1730 """広告スケジュールの追加""" 1731 cc_service = MagicMock() 1732 response = MagicMock() 1733 response.results = [ 1734 MagicMock(resource_name="customers/123/campaignCriteria/new") 1735 ] 1736 cc_service.mutate_campaign_criteria.return_value = response 1737 1738 client._get_service = lambda name: cc_service 1739 1740 op = MagicMock() 1741 client._client.get_type.return_value = op 1742 client._client.get_service.return_value = MagicMock( 1743 campaign_path=MagicMock(return_value="customers/123/campaigns/456"), 1744 ) 1745 client._client.enums.DayOfWeekEnum.MONDAY = "MONDAY" 1746 client._client.enums.MinuteOfHourEnum.ZERO = "ZERO" 1747 1748 result = await client.update_schedule_targeting( 1749 { 1750 "campaign_id": "456", 1751 "add_schedules": [ 1752 {"day": "MONDAY", "start_hour": 9, "end_hour": 18}, 1753 ], 1754 } 1755 ) 1756 assert len(result) == 1 1757 1758 @pytest.mark.asyncio 1759 async def test_remove_schedules(self, client: _MockExtensionsClient) -> None: 1760 """広告スケジュールの削除""" 1761 cc_service = MagicMock() 1762 response = MagicMock() 1763 response.results = [ 1764 MagicMock(resource_name="customers/123/campaignCriteria/del") 1765 ] 1766 cc_service.mutate_campaign_criteria.return_value = response 1767 1768 client._get_service = lambda name: cc_service 1769 1770 op = MagicMock() 1771 client._client.get_type.return_value = op 1772 client._client.get_service.return_value = MagicMock( 1773 campaign_criterion_path=MagicMock( 1774 return_value="customers/123/campaignCriteria/del" 1775 ), 1776 ) 1777 1778 result = await client.update_schedule_targeting( 1779 { 1780 "campaign_id": "456", 1781 "remove_criterion_ids": ["789"], 1782 } 1783 ) 1784 assert len(result) == 1 1785 1786 @pytest.mark.asyncio 1787 async def test_add_schedule_default_hours( 1788 self, client: _MockExtensionsClient 1789 ) -> None: 1790 """start_hour/end_hour未指定時のデフォルト値""" 1791 cc_service = MagicMock() 1792 response = MagicMock() 1793 response.results = [ 1794 MagicMock(resource_name="customers/123/campaignCriteria/new") 1795 ] 1796 cc_service.mutate_campaign_criteria.return_value = response 1797 1798 client._get_service = lambda name: cc_service 1799 1800 op = MagicMock() 1801 client._client.get_type.return_value = op 1802 client._client.get_service.return_value = MagicMock( 1803 campaign_path=MagicMock(return_value="customers/123/campaigns/456"), 1804 ) 1805 client._client.enums.DayOfWeekEnum.TUESDAY = "TUESDAY" 1806 client._client.enums.MinuteOfHourEnum.ZERO = "ZERO" 1807 1808 result = await client.update_schedule_targeting( 1809 { 1810 "campaign_id": "456", 1811 "add_schedules": [ 1812 {"day": "TUESDAY"}, # start_hour/end_hour省略 1813 ], 1814 } 1815 ) 1816 assert len(result) == 1 1817 1818 1819 # --------------------------------------------------------------------------- 1820 # apply_recommendation テスト 1821 # --------------------------------------------------------------------------- 1822 1823 1824 @pytest.mark.unit 1825 class TestApplyRecommendation: 1826 @pytest.fixture() 1827 def client(self) -> _MockExtensionsClient: 1828 return _MockExtensionsClient() 1829 1830 @pytest.mark.asyncio 1831 async def test_apply_success(self, client: _MockExtensionsClient) -> None: 1832 """推奨事項適用が正常に完了する""" 1833 rec_service = MagicMock() 1834 response = MagicMock() 1835 response.results = [ 1836 MagicMock(resource_name="customers/123/recommendations/456") 1837 ] 1838 rec_service.apply_recommendation.return_value = response 1839 1840 client._get_service = lambda name: rec_service 1841 1842 op = MagicMock() 1843 client._client.get_type.return_value = op 1844 1845 result = await client.apply_recommendation( 1846 { 1847 "resource_name": "customers/123/recommendations/456", 1848 } 1849 ) 1850 assert result["resource_name"] == "customers/123/recommendations/456" 1851 1852 1853 # --------------------------------------------------------------------------- 1854 # list_recommendations フィルタテスト 1855 # --------------------------------------------------------------------------- 1856 1857 1858 @pytest.mark.unit 1859 class TestListRecommendationsFilters: 1860 @pytest.fixture() 1861 def client(self) -> _MockExtensionsClient: 1862 return _MockExtensionsClient() 1863 1864 @pytest.mark.asyncio 1865 async def test_with_recommendation_type_filter( 1866 self, client: _MockExtensionsClient 1867 ) -> None: 1868 """recommendation_type指定時のフィルタリング""" 1869 client._search = AsyncMock(return_value=[]) 1870 result = await client.list_recommendations(recommendation_type="KEYWORD") 1871 assert result == [] 1872 # _searchが呼ばれたクエリにKEYWORDが含まれているか確認 1873 call_args = client._search.call_args[0][0] 1874 assert "KEYWORD" in call_args 1875 1876 @pytest.mark.asyncio 1877 async def test_with_both_filters(self, client: _MockExtensionsClient) -> None: 1878 """campaign_idとrecommendation_type両方指定""" 1879 client._search = AsyncMock(return_value=[]) 1880 result = await client.list_recommendations( 1881 campaign_id="123", recommendation_type="KEYWORD" 1882 ) 1883 assert result == [] 1884 call_args = client._search.call_args[0][0] 1885 assert "123" in call_args 1886 assert "KEYWORD" in call_args 1887 1888 1889 # --------------------------------------------------------------------------- 1890 # list_location_targeting bid_modifier=None テスト 1891 # --------------------------------------------------------------------------- 1892 1893 1894 @pytest.mark.unit 1895 class TestListLocationTargetingBidModifier: 1896 @pytest.fixture() 1897 def client(self) -> _MockExtensionsClient: 1898 return _MockExtensionsClient() 1899 1900 @pytest.mark.asyncio 1901 async def test_bid_modifier_none(self, client: _MockExtensionsClient) -> None: 1902 """bid_modifierが未設定の場合はNoneを返す""" 1903 mock_row = MagicMock() 1904 mock_row.campaign_criterion.criterion_id = "1" 1905 mock_row.campaign_criterion.location.geo_target_constant = ( 1906 "geoTargetConstants/2392" 1907 ) 1908 mock_row.campaign_criterion.bid_modifier = 0 # Falsy 1909 client._search = AsyncMock(return_value=[mock_row]) 1910 1911 result = await client.list_location_targeting("123") 1912 assert result[0]["bid_modifier"] is None 1913 1914 1915 # --------------------------------------------------------------------------- 1916 # list_schedule_targeting bid_modifier=None テスト 1917 # --------------------------------------------------------------------------- 1918 1919 1920 @pytest.mark.unit 1921 class TestListScheduleTargetingBidModifier: 1922 @pytest.fixture() 1923 def client(self) -> _MockExtensionsClient: 1924 return _MockExtensionsClient() 1925 1926 @pytest.mark.asyncio 1927 async def test_bid_modifier_none(self, client: _MockExtensionsClient) -> None: 1928 """bid_modifierが未設定の場合はNoneを返す""" 1929 mock_row = MagicMock() 1930 mock_row.campaign_criterion.criterion_id = "1" 1931 mock_row.campaign_criterion.ad_schedule.day_of_week = "MONDAY" 1932 mock_row.campaign_criterion.ad_schedule.start_hour = 0 1933 mock_row.campaign_criterion.ad_schedule.end_hour = 24 1934 mock_row.campaign_criterion.ad_schedule.start_minute = "ZERO" 1935 mock_row.campaign_criterion.ad_schedule.end_minute = "ZERO" 1936 mock_row.campaign_criterion.bid_modifier = 0 # Falsy 1937 client._search = AsyncMock(return_value=[mock_row]) 1938 1939 result = await client.list_schedule_targeting("123") 1940 assert result[0]["bid_modifier"] is None 1941 1942 1943 # --------------------------------------------------------------------------- 1944 # get_bid_adjustments type_/type 互換性テスト 1945 # --------------------------------------------------------------------------- 1946 1947 1948 @pytest.mark.unit 1949 class TestGetBidAdjustmentsCompat: 1950 @pytest.fixture() 1951 def client(self) -> _MockExtensionsClient: 1952 return _MockExtensionsClient() 1953 1954 @pytest.mark.asyncio 1955 async def test_type_attribute_fallback(self, client: _MockExtensionsClient) -> None: 1956 """type_がない場合typeにフォールバックする""" 1957 mock_row = MagicMock(spec=["campaign_criterion"]) 1958 mock_row.campaign_criterion = MagicMock() 1959 mock_row.campaign_criterion.criterion_id = "1" 1960 # type_を持たない(specでtype_を除外) 1961 del mock_row.campaign_criterion.type_ 1962 mock_row.campaign_criterion.type = "DEVICE" 1963 mock_row.campaign_criterion.bid_modifier = 1.2 1964 mock_row.campaign_criterion.device.type_ = "MOBILE" 1965 1966 client._search = AsyncMock(return_value=[mock_row]) 1967 result = await client.get_bid_adjustments("123") 1968 assert result[0]["type"] == "DEVICE"