/ tests / test_google_ads_extensions.py
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"