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