/ tests / test_google_ads_analysis.py
test_google_ads_analysis.py
   1  """Google Ads 分析 Mixin 群のユニットテスト。
   2  
   3  対象モジュール:
   4  - _analysis_constants.py
   5  - _analysis_performance.py
   6  - _analysis_search_terms.py
   7  - _analysis_keywords.py
   8  - _analysis_budget.py
   9  - _analysis_rsa.py
  10  - _analysis_auction.py
  11  - _analysis_btob.py
  12  
  13  DB/外部API/LLM呼び出しは一切行わず、
  14  _run_query / _run_report / _search 等をモックして検証する。
  15  """
  16  
  17  from __future__ import annotations
  18  
  19  from datetime import date, timedelta
  20  from types import SimpleNamespace
  21  from typing import Any
  22  from unittest.mock import AsyncMock
  23  
  24  import pytest
  25  
  26  from mureo.google_ads._analysis_auction import _AuctionAnalysisMixin
  27  from mureo.google_ads._analysis_btob import _BtoBAnalysisMixin
  28  from mureo.google_ads._analysis_budget import _BudgetAnalysisMixin
  29  from mureo.google_ads._analysis_constants import (
  30      _INFORMATIONAL_PATTERNS,
  31      _MATCH_TYPE_MAP,
  32      _STATUS_MAP,
  33      _calc_change_rate,
  34      _extract_ngrams,
  35      _get_comparison_date_ranges,
  36      _resolve_enum,
  37      _safe_metrics,
  38  )
  39  from mureo.google_ads._analysis_keywords import _KeywordsAnalysisMixin
  40  from mureo.google_ads._analysis_performance import _PerformanceAnalysisMixin
  41  from mureo.google_ads._analysis_rsa import _RsaAnalysisMixin
  42  from mureo.google_ads._analysis_search_terms import (
  43      _SearchTermsAnalysisMixin,
  44      _build_add_candidate,
  45      _build_exclude_candidate,
  46      _is_informational_term,
  47  )
  48  
  49  
  50  # =====================================================================
  51  # モッククライアント
  52  # =====================================================================
  53  
  54  
  55  class MockAnalysisClient(
  56      _PerformanceAnalysisMixin,
  57      _SearchTermsAnalysisMixin,
  58      _KeywordsAnalysisMixin,
  59      _BudgetAnalysisMixin,
  60      _RsaAnalysisMixin,
  61      _AuctionAnalysisMixin,
  62      _BtoBAnalysisMixin,
  63  ):
  64      """テスト用に全Mixinを統合し、親クラスメソッドをモックするクラス。"""
  65  
  66      def __init__(self) -> None:
  67          self._customer_id = "1234567890"
  68          self._client = None  # type: ignore[assignment]
  69  
  70          # モック可能な関数群
  71          self.get_campaign = AsyncMock(return_value=None)
  72          self.list_campaigns = AsyncMock(return_value=[])
  73          self.get_performance_report = AsyncMock(return_value=[])
  74          self.get_search_terms_report = AsyncMock(return_value=[])
  75          self.list_recommendations = AsyncMock(return_value=[])
  76          self.list_change_history = AsyncMock(return_value=[])
  77          self.list_negative_keywords = AsyncMock(return_value=[])
  78          self.list_keywords = AsyncMock(return_value=[])
  79          self.get_ad_performance_report = AsyncMock(return_value=[])
  80          self.get_budget = AsyncMock(return_value=None)
  81          self.list_schedule_targeting = AsyncMock(return_value=[])
  82          self.diagnose_campaign_delivery = AsyncMock(return_value={})
  83          self._search = AsyncMock(return_value=[])
  84  
  85      @staticmethod
  86      def _validate_id(value: str, field_name: str) -> str:
  87          if not value:
  88              raise ValueError(f"{field_name} は必須です")
  89          return value
  90  
  91      def _period_to_date_clause(self, period: str) -> str:
  92          return f"DURING {period}"
  93  
  94      def _get_service(self, service_name: str) -> Any:
  95          return None
  96  
  97  
  98  # =====================================================================
  99  # _analysis_constants テスト
 100  # =====================================================================
 101  
 102  
 103  class TestAnalysisConstants:
 104      """_analysis_constants.py の関数テスト。"""
 105  
 106      @pytest.mark.unit
 107      def test_calc_change_rate_normal(self) -> None:
 108          assert _calc_change_rate(120, 100) == 20.0
 109  
 110      @pytest.mark.unit
 111      def test_calc_change_rate_decrease(self) -> None:
 112          assert _calc_change_rate(80, 100) == -20.0
 113  
 114      @pytest.mark.unit
 115      def test_calc_change_rate_previous_zero(self) -> None:
 116          assert _calc_change_rate(100, 0) is None
 117  
 118      @pytest.mark.unit
 119      def test_calc_change_rate_both_zero(self) -> None:
 120          assert _calc_change_rate(0, 0) is None
 121  
 122      @pytest.mark.unit
 123      def test_safe_metrics_with_data(self) -> None:
 124          perf = [{"metrics": {"impressions": 100, "clicks": 10}}]
 125          result = _safe_metrics(perf)
 126          assert result["impressions"] == 100
 127  
 128      @pytest.mark.unit
 129      def test_safe_metrics_empty(self) -> None:
 130          result = _safe_metrics([])
 131          assert result["impressions"] == 0
 132          assert result["clicks"] == 0
 133          assert result["cost"] == 0
 134  
 135      @pytest.mark.unit
 136      def test_safe_metrics_no_metrics_key(self) -> None:
 137          result = _safe_metrics([{"other": "data"}])
 138          assert result == {}
 139  
 140      @pytest.mark.unit
 141      def test_extract_ngrams_unigram(self) -> None:
 142          assert _extract_ngrams("hello world", 1) == ["hello", "world"]
 143  
 144      @pytest.mark.unit
 145      def test_extract_ngrams_bigram(self) -> None:
 146          assert _extract_ngrams("a b c", 2) == ["a b", "b c"]
 147  
 148      @pytest.mark.unit
 149      def test_extract_ngrams_trigram(self) -> None:
 150          assert _extract_ngrams("a b c d", 3) == ["a b c", "b c d"]
 151  
 152      @pytest.mark.unit
 153      def test_extract_ngrams_short_text(self) -> None:
 154          result = _extract_ngrams("hello", 2)
 155          assert result == ["hello"]
 156  
 157      @pytest.mark.unit
 158      def test_extract_ngrams_empty(self) -> None:
 159          result = _extract_ngrams("", 1)
 160          assert result == []
 161  
 162      @pytest.mark.unit
 163      def test_get_comparison_date_ranges_last_7_days(self) -> None:
 164          current, previous = _get_comparison_date_ranges("LAST_7_DAYS")
 165          assert "BETWEEN" in current
 166          assert "BETWEEN" in previous
 167  
 168      @pytest.mark.unit
 169      def test_get_comparison_date_ranges_last_30_days(self) -> None:
 170          current, previous = _get_comparison_date_ranges("LAST_30_DAYS")
 171          assert "BETWEEN" in current
 172          assert "BETWEEN" in previous
 173  
 174      @pytest.mark.unit
 175      def test_get_comparison_date_ranges_unknown_period(self) -> None:
 176          """不明な期間はデフォルト7日で処理される。"""
 177          current, previous = _get_comparison_date_ranges("UNKNOWN_PERIOD")
 178          assert "BETWEEN" in current
 179  
 180      @pytest.mark.unit
 181      def test_get_comparison_date_ranges_no_overlap(self) -> None:
 182          """当期と前期が重複しないことを検証。"""
 183          current, previous = _get_comparison_date_ranges("LAST_7_DAYS")
 184          # BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD' からdateを抽出
 185          import re
 186  
 187          dates = re.findall(r"\d{4}-\d{2}-\d{2}", current + previous)
 188          cur_start, cur_end, prev_start, prev_end = [
 189              date.fromisoformat(d) for d in dates
 190          ]
 191          # 前期の終了日 < 当期の開始日
 192          assert prev_end < cur_start
 193  
 194      @pytest.mark.unit
 195      def test_resolve_enum_int(self) -> None:
 196          assert _resolve_enum(2, _MATCH_TYPE_MAP) == "EXACT"
 197          assert _resolve_enum(4, _MATCH_TYPE_MAP) == "BROAD"
 198  
 199      @pytest.mark.unit
 200      def test_resolve_enum_unknown_int(self) -> None:
 201          assert _resolve_enum(99, _MATCH_TYPE_MAP) == "99"
 202  
 203      @pytest.mark.unit
 204      def test_resolve_enum_with_name_attr(self) -> None:
 205          class FakeEnum:
 206              name = "PHRASE"
 207  
 208          assert _resolve_enum(FakeEnum(), _MATCH_TYPE_MAP) == "PHRASE"
 209  
 210      @pytest.mark.unit
 211      def test_resolve_enum_str_fallback(self) -> None:
 212          assert _resolve_enum("EXACT", _MATCH_TYPE_MAP) == "EXACT"
 213  
 214      @pytest.mark.unit
 215      def test_status_map_values(self) -> None:
 216          assert _STATUS_MAP[2] == "ENABLED"
 217          assert _STATUS_MAP[3] == "PAUSED"
 218  
 219      @pytest.mark.unit
 220      def test_informational_patterns(self) -> None:
 221          assert "とは" in _INFORMATIONAL_PATTERNS
 222          assert "比較" in _INFORMATIONAL_PATTERNS
 223  
 224  
 225  # =====================================================================
 226  # _analysis_performance テスト
 227  # =====================================================================
 228  
 229  
 230  class TestPerformanceAnalysisMixin:
 231      """_PerformanceAnalysisMixin のテスト。"""
 232  
 233      def _make_client(self) -> MockAnalysisClient:
 234          return MockAnalysisClient()
 235  
 236      @pytest.mark.unit
 237      async def test_resolve_target_cpa_explicit(self) -> None:
 238          client = self._make_client()
 239          cpa, source = await client._resolve_target_cpa("123", explicit=5000.0)
 240          assert cpa == 5000.0
 241          assert source == "explicit"
 242  
 243      @pytest.mark.unit
 244      async def test_resolve_target_cpa_from_bidding(self) -> None:
 245          client = self._make_client()
 246          client.get_campaign.return_value = {"bidding_details": {"target_cpa": 3000}}
 247          cpa, source = await client._resolve_target_cpa("123")
 248          assert cpa == 3000.0
 249          assert source == "bidding_strategy"
 250  
 251      @pytest.mark.unit
 252      async def test_resolve_target_cpa_from_actual(self) -> None:
 253          client = self._make_client()
 254          client.get_campaign.return_value = {"bidding_details": {}}
 255          client.get_performance_report.return_value = [
 256              {"metrics": {"cost": 10000, "conversions": 5}}
 257          ]
 258          cpa, source = await client._resolve_target_cpa("123")
 259          assert cpa == 2000.0
 260          assert source == "actual"
 261  
 262      @pytest.mark.unit
 263      async def test_resolve_target_cpa_none(self) -> None:
 264          client = self._make_client()
 265          client.get_campaign.return_value = {"bidding_details": {}}
 266          client.get_performance_report.return_value = [
 267              {"metrics": {"cost": 0, "conversions": 0}}
 268          ]
 269          cpa, source = await client._resolve_target_cpa("123")
 270          assert cpa is None
 271          assert source == "none"
 272  
 273      @pytest.mark.unit
 274      async def test_resolve_target_cpa_exception_fallback(self) -> None:
 275          client = self._make_client()
 276          client.get_campaign.side_effect = Exception("API error")
 277          client.get_performance_report.return_value = [
 278              {"metrics": {"cost": 6000, "conversions": 3}}
 279          ]
 280          cpa, source = await client._resolve_target_cpa("123")
 281          assert cpa == 2000.0
 282          assert source == "actual"
 283  
 284      @pytest.mark.unit
 285      async def test_analyze_performance_campaign_not_found(self) -> None:
 286          client = self._make_client()
 287          client.get_campaign.return_value = None
 288          result = await client.analyze_performance("999")
 289          assert "error" in result
 290  
 291      @pytest.mark.unit
 292      async def test_analyze_performance_basic(self) -> None:
 293          client = self._make_client()
 294          client.get_campaign.return_value = {
 295              "id": "123",
 296              "name": "Test Campaign",
 297              "status": "ENABLED",
 298          }
 299          client.get_performance_report.return_value = [
 300              {
 301                  "metrics": {
 302                      "impressions": 1000,
 303                      "clicks": 100,
 304                      "cost": 5000,
 305                      "conversions": 10,
 306                  }
 307              }
 308          ]
 309          client.get_search_terms_report.return_value = []
 310          client.list_recommendations.return_value = []
 311          client.list_change_history.return_value = []
 312  
 313          result = await client.analyze_performance("123")
 314          assert result["campaign_id"] == "123"
 315          assert "campaign" in result
 316          assert "issues" in result
 317          assert "insights" in result
 318  
 319      @pytest.mark.unit
 320      async def test_analyze_performance_paused_campaign(self) -> None:
 321          client = self._make_client()
 322          client.get_campaign.return_value = {
 323              "id": "123",
 324              "name": "Paused",
 325              "status": "PAUSED",
 326          }
 327          client.get_performance_report.return_value = [
 328              {"metrics": {"impressions": 0, "clicks": 0, "cost": 0, "conversions": 0}}
 329          ]
 330          result = await client.analyze_performance("123")
 331          assert any("PAUSED" in i for i in result["issues"])
 332  
 333      @pytest.mark.unit
 334      def test_generate_performance_insights_cost_increase(self) -> None:
 335          changes = {
 336              "impressions_change_pct": -25.0,
 337              "clicks_change_pct": -25.0,
 338              "cost_change_pct": 35.0,
 339              "conversions_change_pct": -30.0,
 340          }
 341          current_m = {"cost": 10000, "conversions": 5}
 342          previous_m = {"cost": 8000, "conversions": 8}
 343          insights, cpa_info = _PerformanceAnalysisMixin._generate_performance_insights(
 344              changes, current_m, previous_m
 345          )
 346          assert len(insights) >= 3
 347          assert "cpa_current" in cpa_info
 348          assert "cpa_previous" in cpa_info
 349  
 350      @pytest.mark.unit
 351      def test_generate_performance_insights_no_issues(self) -> None:
 352          changes = {
 353              "impressions_change_pct": 5.0,
 354              "clicks_change_pct": 5.0,
 355              "cost_change_pct": 5.0,
 356              "conversions_change_pct": 5.0,
 357          }
 358          current_m = {"cost": 10000, "conversions": 10}
 359          previous_m = {"cost": 9500, "conversions": 10}
 360          insights, cpa_info = _PerformanceAnalysisMixin._generate_performance_insights(
 361              changes, current_m, previous_m
 362          )
 363          assert len(insights) == 0
 364  
 365      @pytest.mark.unit
 366      def test_generate_performance_insights_zero_conversions(self) -> None:
 367          changes = {
 368              "impressions_change_pct": 0,
 369              "clicks_change_pct": 0,
 370              "cost_change_pct": 0,
 371              "conversions_change_pct": 0,
 372          }
 373          current_m = {"cost": 1000, "conversions": 0}
 374          previous_m = {"cost": 1000, "conversions": 0}
 375          _, cpa_info = _PerformanceAnalysisMixin._generate_performance_insights(
 376              changes, current_m, previous_m
 377          )
 378          assert "cpa_current" not in cpa_info
 379          assert "cpa_previous" not in cpa_info
 380  
 381      @pytest.mark.unit
 382      def test_build_cost_breakdown_cpc_increase(self) -> None:
 383          current_m = {"average_cpc": 150, "clicks": 100}
 384          previous_m = {"average_cpc": 100, "clicks": 100}
 385          breakdown, findings, cpc_change, clicks_change = (
 386              _PerformanceAnalysisMixin._build_cost_breakdown(current_m, previous_m)
 387          )
 388          assert breakdown["cpc_current"] == 150
 389          assert cpc_change is not None and cpc_change > 10
 390          assert any("CPC" in f for f in findings)
 391  
 392      @pytest.mark.unit
 393      def test_build_cost_breakdown_clicks_increase(self) -> None:
 394          current_m = {"average_cpc": 100, "clicks": 150}
 395          previous_m = {"average_cpc": 100, "clicks": 100}
 396          breakdown, findings, _, clicks_change = (
 397              _PerformanceAnalysisMixin._build_cost_breakdown(current_m, previous_m)
 398          )
 399          assert clicks_change is not None and clicks_change > 20
 400          assert any("click" in f.lower() for f in findings)
 401  
 402      @pytest.mark.unit
 403      async def test_investigate_cost_increase_basic(self) -> None:
 404          client = self._make_client()
 405          client.get_performance_report.return_value = [
 406              {
 407                  "metrics": {
 408                      "impressions": 1000,
 409                      "clicks": 100,
 410                      "cost": 5000,
 411                      "conversions": 5,
 412                      "average_cpc": 50,
 413                  }
 414              }
 415          ]
 416          client.get_search_terms_report.return_value = []
 417          client.list_change_history.return_value = []
 418          client.list_negative_keywords.return_value = []
 419          result = await client.investigate_cost_increase("123")
 420          assert result["campaign_id"] == "123"
 421          assert "findings" in result
 422          assert "recommended_actions" in result
 423  
 424      @pytest.mark.unit
 425      async def test_health_check_all_campaigns(self) -> None:
 426          client = self._make_client()
 427          client.list_campaigns.return_value = [
 428              {
 429                  "id": "1",
 430                  "name": "Camp A",
 431                  "status": "ENABLED",
 432                  "primary_status": "ELIGIBLE",
 433              },
 434              {
 435                  "id": "2",
 436                  "name": "Camp B",
 437                  "status": "ENABLED",
 438                  "primary_status": "NOT_ELIGIBLE",
 439              },
 440              {"id": "3", "name": "Camp C", "status": "PAUSED"},
 441          ]
 442          client.diagnose_campaign_delivery.return_value = {
 443              "issues": ["test issue"],
 444              "warnings": [],
 445              "recommendations": [],
 446          }
 447  
 448          result = await client.health_check_all_campaigns()
 449          assert result["total_campaigns"] == 3
 450          assert result["enabled_count"] == 2
 451          assert result["paused_count"] == 1
 452          assert len(result["healthy_campaigns"]) == 1
 453          assert len(result["problem_campaigns"]) == 1
 454          assert "message" in result["summary"]
 455  
 456      @pytest.mark.unit
 457      async def test_health_check_all_healthy(self) -> None:
 458          client = self._make_client()
 459          client.list_campaigns.return_value = [
 460              {
 461                  "id": "1",
 462                  "name": "Good",
 463                  "status": "ENABLED",
 464                  "primary_status": "ELIGIBLE",
 465              },
 466          ]
 467          result = await client.health_check_all_campaigns()
 468          assert "normally" in result["summary"]["message"]
 469  
 470      @pytest.mark.unit
 471      async def test_compare_ad_performance_basic(self) -> None:
 472          client = self._make_client()
 473          client.get_ad_performance_report.return_value = [
 474              {
 475                  "ad_id": "ad1",
 476                  "status": "ENABLED",
 477                  "metrics": {
 478                      "impressions": 500,
 479                      "clicks": 50,
 480                      "conversions": 5,
 481                      "cost": 1000,
 482                  },
 483              },
 484              {
 485                  "ad_id": "ad2",
 486                  "status": "ENABLED",
 487                  "metrics": {
 488                      "impressions": 500,
 489                      "clicks": 30,
 490                      "conversions": 2,
 491                      "cost": 800,
 492                  },
 493              },
 494          ]
 495          result = await client.compare_ad_performance("ag1")
 496          assert len(result["ads"]) == 2
 497          assert result["ads"][0]["rank"] == 1
 498          assert result["winner"] is not None
 499  
 500      @pytest.mark.unit
 501      async def test_compare_ad_performance_insufficient_data(self) -> None:
 502          client = self._make_client()
 503          client.get_ad_performance_report.return_value = [
 504              {
 505                  "ad_id": "ad1",
 506                  "status": "ENABLED",
 507                  "metrics": {
 508                      "impressions": 50,
 509                      "clicks": 5,
 510                      "conversions": 0,
 511                      "cost": 100,
 512                  },
 513              },
 514          ]
 515          result = await client.compare_ad_performance("ag1")
 516          assert result["ads"][0]["verdict"] == "INSUFFICIENT_DATA"
 517          assert "comparison" in result["recommendation"]
 518  
 519      @pytest.mark.unit
 520      async def test_compare_ad_performance_empty(self) -> None:
 521          client = self._make_client()
 522          client.get_ad_performance_report.return_value = []
 523          result = await client.compare_ad_performance("ag1")
 524          assert len(result["ads"]) == 0
 525  
 526      @pytest.mark.unit
 527      async def test_analyze_search_term_changes(self) -> None:
 528          client = self._make_client()
 529          client.get_search_terms_report.side_effect = [
 530              # 当期
 531              [
 532                  {"search_term": "new term", "metrics": {"cost": 500, "conversions": 0}},
 533                  {"search_term": "old term", "metrics": {"cost": 300, "conversions": 1}},
 534              ],
 535              # 前期
 536              [
 537                  {"search_term": "old term", "metrics": {"cost": 200, "conversions": 1}},
 538              ],
 539          ]
 540          result = await client._analyze_search_term_changes("123")
 541          assert len(result["new_search_terms"]) == 1
 542          assert result["new_search_terms"][0]["search_term"] == "new term"
 543          assert result["finding"] is not None
 544  
 545  
 546  # =====================================================================
 547  # _analysis_search_terms テスト
 548  # =====================================================================
 549  
 550  
 551  class TestSearchTermsAnalysisMixin:
 552      """_SearchTermsAnalysisMixin のテスト。"""
 553  
 554      def _make_client(self) -> MockAnalysisClient:
 555          return MockAnalysisClient()
 556  
 557      @pytest.mark.unit
 558      def test_is_informational_term_true(self) -> None:
 559          assert _is_informational_term("SEOとは何か") is True
 560          assert _is_informational_term("ツール比較サイト") is True
 561  
 562      @pytest.mark.unit
 563      def test_is_informational_term_false(self) -> None:
 564          assert _is_informational_term("広告代理店") is False
 565  
 566      @pytest.mark.unit
 567      def test_build_add_candidate(self) -> None:
 568          result = _build_add_candidate(
 569              "keyword", 2.0, 30, 1000, 0.05, "EXACT", 90, "test"
 570          )
 571          assert result["action"] == "add"
 572          assert result["match_type"] == "EXACT"
 573          assert result["score"] == 90
 574  
 575      @pytest.mark.unit
 576      def test_build_exclude_candidate(self) -> None:
 577          result = _build_exclude_candidate(
 578              "keyword", 0.0, 50, 2000, 0.01, "PHRASE", 80, "test"
 579          )
 580          assert result["action"] == "exclude"
 581          assert result["match_type"] == "PHRASE"
 582  
 583      @pytest.mark.unit
 584      def test_route_by_newness_new(self) -> None:
 585          client = self._make_client()
 586          entry = {"action": "exclude", "reason": "test"}
 587          main: list[dict[str, Any]] = []
 588          watch: list[dict[str, Any]] = []
 589          client._route_by_newness(entry, "term", True, main, watch)
 590          assert len(watch) == 1
 591          assert entry["action"] == "watch"
 592          assert "New term" in entry["reason"]
 593  
 594      @pytest.mark.unit
 595      def test_route_by_newness_existing(self) -> None:
 596          client = self._make_client()
 597          entry = {"action": "exclude", "reason": "test"}
 598          main: list[dict[str, Any]] = []
 599          watch: list[dict[str, Any]] = []
 600          client._route_by_newness(entry, "term", False, main, watch)
 601          assert len(main) == 1
 602          assert len(watch) == 0
 603  
 604      @pytest.mark.unit
 605      async def test_analyze_search_terms_basic(self) -> None:
 606          client = self._make_client()
 607          client.list_keywords.return_value = [{"text": "広告 運用"}]
 608          client.get_search_terms_report.return_value = [
 609              {
 610                  "search_term": "広告 運用",
 611                  "metrics": {
 612                      "cost": 500,
 613                      "conversions": 2,
 614                      "clicks": 10,
 615                      "impressions": 100,
 616                  },
 617              },
 618              {
 619                  "search_term": "広告 代理店",
 620                  "metrics": {
 621                      "cost": 300,
 622                      "conversions": 1,
 623                      "clicks": 5,
 624                      "impressions": 80,
 625                  },
 626              },
 627              {
 628                  "search_term": "無駄語句",
 629                  "metrics": {
 630                      "cost": 200,
 631                      "conversions": 0,
 632                      "clicks": 8,
 633                      "impressions": 50,
 634                  },
 635              },
 636          ]
 637          result = await client.analyze_search_terms("123")
 638          assert result["campaign_id"] == "123"
 639          assert result["registered_keywords_count"] == 1
 640          assert result["search_terms_count"] == 3
 641          assert 0 <= result["overlap_rate"] <= 1
 642          assert "ngram_distribution" in result
 643          assert len(result["keyword_candidates"]) >= 1
 644          assert len(result["negative_candidates"]) >= 1
 645  
 646      @pytest.mark.unit
 647      async def test_analyze_search_terms_empty(self) -> None:
 648          client = self._make_client()
 649          client.list_keywords.return_value = []
 650          client.get_search_terms_report.return_value = []
 651          result = await client.analyze_search_terms("123")
 652          assert result["search_terms_count"] == 0
 653          assert result["overlap_rate"] == 0.0
 654  
 655      @pytest.mark.unit
 656      async def test_suggest_negative_keywords_basic(self) -> None:
 657          client = self._make_client()
 658          client.get_campaign.return_value = {"bidding_details": {"target_cpa": 3000}}
 659          # 当期
 660          terms_current = [
 661              {
 662                  "search_term": "expensive term",
 663                  "metrics": {
 664                      "cost": 5000,
 665                      "conversions": 0,
 666                      "clicks": 20,
 667                      "impressions": 500,
 668                      "ctr": 0.04,
 669                  },
 670              },
 671              {
 672                  "search_term": "cheap term",
 673                  "metrics": {
 674                      "cost": 100,
 675                      "conversions": 0,
 676                      "clicks": 2,
 677                      "impressions": 50,
 678                      "ctr": 0.04,
 679                  },
 680              },
 681              {
 682                  "search_term": "good term",
 683                  "metrics": {
 684                      "cost": 2000,
 685                      "conversions": 3,
 686                      "clicks": 10,
 687                      "impressions": 200,
 688                      "ctr": 0.05,
 689                  },
 690              },
 691          ]
 692          # 前期
 693          terms_prev = [
 694              {"search_term": "expensive term", "metrics": {}},
 695              {"search_term": "cheap term", "metrics": {}},
 696              {"search_term": "good term", "metrics": {}},
 697          ]
 698          client.get_search_terms_report.side_effect = [terms_current, terms_prev]
 699          client.list_negative_keywords.return_value = []
 700  
 701          result = await client.suggest_negative_keywords(
 702              "123", use_intent_analysis=False
 703          )
 704          assert result["target_cpa"] == 3000.0
 705          assert result["target_cpa_source"] == "bidding_strategy"
 706          # expensive term: cost 5000 > 3000*1.5=4500 → 除外候補
 707          assert len(result["suggestions"]) >= 1
 708          assert result["suggestions"][0]["search_term"] == "expensive term"
 709  
 710      @pytest.mark.unit
 711      async def test_suggest_negative_keywords_informational(self) -> None:
 712          """情報収集パターンはCPA閾値に関わらず除外候補になる。"""
 713          client = self._make_client()
 714          client.get_campaign.return_value = {"bidding_details": {"target_cpa": 10000}}
 715          terms = [
 716              {
 717                  "search_term": "SEOとは",
 718                  "metrics": {
 719                      "cost": 100,
 720                      "conversions": 0,
 721                      "clicks": 5,
 722                      "impressions": 200,
 723                      "ctr": 0.025,
 724                  },
 725              },
 726          ]
 727          client.get_search_terms_report.side_effect = [
 728              terms,  # 当期
 729              terms,  # 前期(同じ→既存語句扱い)
 730          ]
 731          client.list_negative_keywords.return_value = []
 732          result = await client.suggest_negative_keywords(
 733              "123", use_intent_analysis=False
 734          )
 735          assert len(result["suggestions"]) >= 1
 736          assert "PHRASE" == result["suggestions"][0]["recommended_match_type"]
 737  
 738      @pytest.mark.unit
 739      async def test_review_search_terms_classification(self) -> None:
 740          """多段階ルールによる分類テスト。"""
 741          client = self._make_client()
 742          client.get_campaign.return_value = {"bidding_details": {"target_cpa": 3000}}
 743          terms = [
 744              # Rule 1: CV>=2 & 未登録 → add
 745              {
 746                  "search_term": "high cv term",
 747                  "metrics": {
 748                      "conversions": 3,
 749                      "clicks": 20,
 750                      "cost": 2000,
 751                      "impressions": 500,
 752                  },
 753              },
 754              # Rule 4: CV=0 & cost >= CPA*2 → exclude
 755              {
 756                  "search_term": "waste term",
 757                  "metrics": {
 758                      "conversions": 0,
 759                      "clicks": 40,
 760                      "cost": 7000,
 761                      "impressions": 1000,
 762                  },
 763              },
 764              # Rule 6: 情報収集 & CV=0 → exclude
 765              {
 766                  "search_term": "SEOとは",
 767                  "metrics": {
 768                      "conversions": 0,
 769                      "clicks": 5,
 770                      "cost": 200,
 771                      "impressions": 100,
 772                  },
 773              },
 774          ]
 775          client.get_search_terms_report.side_effect = [
 776              terms,  # 当期
 777              terms,  # 前期
 778          ]
 779          client.list_keywords.return_value = []
 780          client.list_negative_keywords.return_value = []
 781  
 782          result = await client.review_search_terms("123", use_intent_analysis=False)
 783          assert result["summary"]["add_count"] >= 1
 784          assert result["summary"]["exclude_count"] >= 1
 785  
 786      @pytest.mark.unit
 787      def test_classify_search_term_rule1_add(self) -> None:
 788          """Rule 1: CV>=2 & 未登録 → add EXACT (score=90)。"""
 789          client = self._make_client()
 790          add: list[dict[str, Any]] = []
 791          exclude: list[dict[str, Any]] = []
 792          watch: list[dict[str, Any]] = []
 793          client._classify_search_term(
 794              {
 795                  "search_term": "good kw",
 796                  "metrics": {
 797                      "conversions": 3,
 798                      "clicks": 20,
 799                      "cost": 1000,
 800                      "impressions": 500,
 801                  },
 802              },
 803              keyword_texts=set(),
 804              existing_neg_texts=set(),
 805              prev_term_set=set(),
 806              resolved_cpa=3000.0,
 807              add_candidates=add,
 808              exclude_candidates=exclude,
 809              watch_candidates=watch,
 810          )
 811          assert len(add) == 1
 812          assert add[0]["score"] == 90
 813          assert add[0]["match_type"] == "EXACT"
 814  
 815      @pytest.mark.unit
 816      def test_classify_search_term_rule2_add(self) -> None:
 817          """Rule 2: CV=1 & CPA<=目標CPA → add EXACT (score=70)。"""
 818          client = self._make_client()
 819          add: list[dict[str, Any]] = []
 820          client._classify_search_term(
 821              {
 822                  "search_term": "decent kw",
 823                  "metrics": {
 824                      "conversions": 1,
 825                      "clicks": 10,
 826                      "cost": 2000,
 827                      "impressions": 200,
 828                  },
 829              },
 830              keyword_texts=set(),
 831              existing_neg_texts=set(),
 832              prev_term_set=set(),
 833              resolved_cpa=3000.0,
 834              add_candidates=add,
 835              exclude_candidates=[],
 836              watch_candidates=[],
 837          )
 838          assert len(add) == 1
 839          assert add[0]["score"] == 70
 840  
 841      @pytest.mark.unit
 842      def test_classify_search_term_rule3_add_high_ctr(self) -> None:
 843          """Rule 3: CV=0 & Click>=20 & CTR>=3% → add PHRASE (score=50)。"""
 844          client = self._make_client()
 845          add: list[dict[str, Any]] = []
 846          client._classify_search_term(
 847              {
 848                  "search_term": "high ctr",
 849                  "metrics": {
 850                      "conversions": 0,
 851                      "clicks": 25,
 852                      "cost": 500,
 853                      "impressions": 500,
 854                  },
 855              },
 856              keyword_texts=set(),
 857              existing_neg_texts=set(),
 858              prev_term_set=set(),
 859              resolved_cpa=3000.0,
 860              add_candidates=add,
 861              exclude_candidates=[],
 862              watch_candidates=[],
 863          )
 864          assert len(add) == 1
 865          assert add[0]["score"] == 50
 866          assert add[0]["match_type"] == "PHRASE"
 867  
 868      @pytest.mark.unit
 869      def test_classify_search_term_rule4_exclude(self) -> None:
 870          """Rule 4: CV=0 & cost>=CPA*2 → exclude EXACT (score=80)。"""
 871          client = self._make_client()
 872          exclude: list[dict[str, Any]] = []
 873          # clicks=10, impressions=1000 → CTR=1% でRule3にマッチしない
 874          client._classify_search_term(
 875              {
 876                  "search_term": "waste",
 877                  "metrics": {
 878                      "conversions": 0,
 879                      "clicks": 10,
 880                      "cost": 7000,
 881                      "impressions": 1000,
 882                  },
 883              },
 884              keyword_texts=set(),
 885              existing_neg_texts=set(),
 886              prev_term_set={"waste"},  # 既存語句
 887              resolved_cpa=3000.0,
 888              add_candidates=[],
 889              exclude_candidates=exclude,
 890              watch_candidates=[],
 891          )
 892          assert len(exclude) == 1
 893          assert exclude[0]["score"] == 80
 894  
 895      @pytest.mark.unit
 896      def test_classify_search_term_rule5_low_ctr(self) -> None:
 897          """Rule 5: CV=0 & Click>=30 & CTR<1% → exclude EXACT (score=60)。"""
 898          client = self._make_client()
 899          exclude: list[dict[str, Any]] = []
 900          client._classify_search_term(
 901              {
 902                  "search_term": "low ctr",
 903                  "metrics": {
 904                      "conversions": 0,
 905                      "clicks": 40,
 906                      "cost": 500,
 907                      "impressions": 5000,
 908                  },
 909              },
 910              keyword_texts=set(),
 911              existing_neg_texts=set(),
 912              prev_term_set={"low ctr"},
 913              resolved_cpa=None,
 914              add_candidates=[],
 915              exclude_candidates=exclude,
 916              watch_candidates=[],
 917          )
 918          assert len(exclude) == 1
 919          assert exclude[0]["score"] == 60
 920  
 921      @pytest.mark.unit
 922      def test_classify_search_term_already_excluded(self) -> None:
 923          """既に除外登録済みの語句はスキップされる。"""
 924          client = self._make_client()
 925          exclude: list[dict[str, Any]] = []
 926          client._classify_search_term(
 927              {
 928                  "search_term": "waste",
 929                  "metrics": {
 930                      "conversions": 0,
 931                      "clicks": 30,
 932                      "cost": 7000,
 933                      "impressions": 1000,
 934                  },
 935              },
 936              keyword_texts=set(),
 937              existing_neg_texts={"waste"},
 938              prev_term_set={"waste"},
 939              resolved_cpa=3000.0,
 940              add_candidates=[],
 941              exclude_candidates=exclude,
 942              watch_candidates=[],
 943          )
 944          assert len(exclude) == 0
 945  
 946      @pytest.mark.unit
 947      def test_classify_search_term_no_match(self) -> None:
 948          """どのルールにもマッチしない場合はどのリストにも追加されない。"""
 949          client = self._make_client()
 950          add: list[dict[str, Any]] = []
 951          exclude: list[dict[str, Any]] = []
 952          watch: list[dict[str, Any]] = []
 953          client._classify_search_term(
 954              {
 955                  "search_term": "neutral",
 956                  "metrics": {
 957                      "conversions": 0,
 958                      "clicks": 5,
 959                      "cost": 100,
 960                      "impressions": 200,
 961                  },
 962              },
 963              keyword_texts=set(),
 964              existing_neg_texts=set(),
 965              prev_term_set={"neutral"},
 966              resolved_cpa=3000.0,
 967              add_candidates=add,
 968              exclude_candidates=exclude,
 969              watch_candidates=watch,
 970          )
 971          assert len(add) == 0
 972          assert len(exclude) == 0
 973          assert len(watch) == 0
 974  
 975  
 976  # =====================================================================
 977  # _analysis_keywords テスト
 978  # =====================================================================
 979  
 980  
 981  class TestKeywordsAnalysisMixin:
 982      """_KeywordsAnalysisMixin のテスト。"""
 983  
 984      def _make_client(self) -> MockAnalysisClient:
 985          return MockAnalysisClient()
 986  
 987      def _make_gaql_row(
 988          self,
 989          criterion_id: int = 1,
 990          text: str = "test kw",
 991          match_type: int = 2,
 992          status: int = 2,
 993          ad_group_id: int = 100,
 994          ad_group_name: str = "AdGroup1",
 995          impressions: int = 100,
 996          clicks: int = 10,
 997          cost_micros: int = 5_000_000,
 998          conversions: float = 1.0,
 999      ) -> SimpleNamespace:
1000          """GAQL レスポンス行をSimpleNamespaceで模倣する。"""
1001          return SimpleNamespace(
1002              ad_group_criterion=SimpleNamespace(
1003                  criterion_id=criterion_id,
1004                  keyword=SimpleNamespace(text=text, match_type=match_type),
1005                  status=status,
1006              ),
1007              ad_group=SimpleNamespace(id=ad_group_id, name=ad_group_name),
1008              metrics=SimpleNamespace(
1009                  impressions=impressions,
1010                  clicks=clicks,
1011                  cost_micros=cost_micros,
1012                  conversions=conversions,
1013              ),
1014          )
1015  
1016      @pytest.mark.unit
1017      async def test_get_keyword_performance(self) -> None:
1018          client = self._make_client()
1019          client._search.return_value = [
1020              self._make_gaql_row(
1021                  criterion_id=1,
1022                  text="kw1",
1023                  match_type=2,
1024                  cost_micros=3_000_000,
1025                  conversions=2.0,
1026              ),
1027          ]
1028          results = await client._get_keyword_performance("123")
1029          assert len(results) == 1
1030          assert results[0]["text"] == "kw1"
1031          assert results[0]["match_type"] == "EXACT"
1032          assert results[0]["metrics"]["cost"] == 3.0
1033  
1034      @pytest.mark.unit
1035      async def test_get_keyword_performance_exception(self) -> None:
1036          client = self._make_client()
1037          client._search.side_effect = Exception("API error")
1038          results = await client._get_keyword_performance("123")
1039          assert results == []
1040  
1041      @pytest.mark.unit
1042      def test_evaluate_keyword_rule1_broad_no_cv(self) -> None:
1043          """Rule 1: BROAD & CV=0 & コスト>目標CPA → narrow_to_phrase。"""
1044          kw = {
1045              "text": "broad kw",
1046              "criterion_id": "1",
1047              "ad_group_id": "100",
1048              "match_type": "BROAD",
1049              "metrics": {
1050                  "conversions": 0,
1051                  "clicks": 20,
1052                  "cost": 5000,
1053                  "impressions": 300,
1054              },
1055          }
1056          rec = _KeywordsAnalysisMixin._evaluate_keyword(kw, 3000.0, 0.02)
1057          assert rec is not None
1058          assert rec["action"] == "narrow_to_phrase"
1059          assert rec["priority"] == "HIGH"
1060  
1061      @pytest.mark.unit
1062      def test_evaluate_keyword_rule2_no_cv_many_clicks(self) -> None:
1063          """Rule 2: CV=0 & Click>50 → pause。"""
1064          kw = {
1065              "text": "kw",
1066              "criterion_id": "1",
1067              "ad_group_id": "100",
1068              "match_type": "EXACT",
1069              "metrics": {
1070                  "conversions": 0,
1071                  "clicks": 60,
1072                  "cost": 3000,
1073                  "impressions": 1000,
1074              },
1075          }
1076          rec = _KeywordsAnalysisMixin._evaluate_keyword(kw, 3000.0, 0.02)
1077          assert rec is not None
1078          assert rec["action"] == "pause"
1079          assert rec["priority"] == "HIGH"
1080  
1081      @pytest.mark.unit
1082      def test_evaluate_keyword_rule3_phrase_high_cvr(self) -> None:
1083          """Rule 3: PHRASE & CVR>avg*1.5 → add_exact。"""
1084          kw = {
1085              "text": "kw",
1086              "criterion_id": "1",
1087              "ad_group_id": "100",
1088              "match_type": "PHRASE",
1089              "metrics": {
1090                  "conversions": 5,
1091                  "clicks": 20,
1092                  "cost": 3000,
1093                  "impressions": 500,
1094              },
1095          }
1096          # CVR = 5/20 = 0.25, avg_cvr = 0.05, avg*1.5 = 0.075
1097          rec = _KeywordsAnalysisMixin._evaluate_keyword(kw, 3000.0, 0.05)
1098          assert rec is not None
1099          assert rec["action"] == "add_exact"
1100          assert rec["priority"] == "MEDIUM"
1101  
1102      @pytest.mark.unit
1103      def test_evaluate_keyword_rule4_exact_low_imp(self) -> None:
1104          """Rule 4: EXACT & Imp<50 → expand_to_phrase。"""
1105          kw = {
1106              "text": "kw",
1107              "criterion_id": "1",
1108              "ad_group_id": "100",
1109              "match_type": "EXACT",
1110              "metrics": {"conversions": 1, "clicks": 3, "cost": 500, "impressions": 30},
1111          }
1112          rec = _KeywordsAnalysisMixin._evaluate_keyword(kw, 3000.0, 0.02)
1113          assert rec is not None
1114          assert rec["action"] == "expand_to_phrase"
1115          assert rec["priority"] == "LOW"
1116  
1117      @pytest.mark.unit
1118      def test_evaluate_keyword_no_action(self) -> None:
1119          """どのルールにもマッチしない → None。"""
1120          kw = {
1121              "text": "kw",
1122              "criterion_id": "1",
1123              "ad_group_id": "100",
1124              "match_type": "EXACT",
1125              "metrics": {
1126                  "conversions": 2,
1127                  "clicks": 20,
1128                  "cost": 2000,
1129                  "impressions": 500,
1130              },
1131          }
1132          rec = _KeywordsAnalysisMixin._evaluate_keyword(kw, 3000.0, 0.02)
1133          assert rec is None
1134  
1135      @pytest.mark.unit
1136      async def test_audit_keywords(self) -> None:
1137          client = self._make_client()
1138          client.get_campaign.return_value = {"bidding_details": {"target_cpa": 3000}}
1139          client._search.return_value = [
1140              self._make_gaql_row(
1141                  text="broad kw",
1142                  match_type=4,
1143                  cost_micros=5_000_000,
1144                  conversions=0,
1145                  clicks=10,
1146              ),
1147              self._make_gaql_row(
1148                  criterion_id=2,
1149                  text="good kw",
1150                  match_type=2,
1151                  cost_micros=2_000_000,
1152                  conversions=3,
1153                  clicks=20,
1154              ),
1155          ]
1156          result = await client.audit_keywords("123")
1157          assert result["total_keywords"] == 2
1158          assert "recommendations" in result
1159          assert "summary" in result
1160  
1161      @pytest.mark.unit
1162      async def test_find_cross_adgroup_duplicates(self) -> None:
1163          client = self._make_client()
1164          # 同じキーワード・マッチタイプが2つの広告グループに存在
1165          client._search.return_value = [
1166              self._make_gaql_row(
1167                  criterion_id=1,
1168                  text="dup kw",
1169                  match_type=2,
1170                  ad_group_id=100,
1171                  ad_group_name="AG1",
1172                  conversions=3,
1173              ),
1174              self._make_gaql_row(
1175                  criterion_id=2,
1176                  text="dup kw",
1177                  match_type=2,
1178                  ad_group_id=200,
1179                  ad_group_name="AG2",
1180                  conversions=0,
1181              ),
1182          ]
1183          result = await client.find_cross_adgroup_duplicates("123")
1184          assert result["duplicate_groups_count"] == 1
1185          assert result["total_removable_keywords"] == 1
1186          group = result["duplicate_groups"][0]
1187          assert group["keep"]["ad_group_id"] == "100"  # better performance
1188          assert len(group["remove"]) == 1
1189  
1190      @pytest.mark.unit
1191      async def test_find_cross_adgroup_duplicates_no_duplicates(self) -> None:
1192          client = self._make_client()
1193          client._search.return_value = [
1194              self._make_gaql_row(
1195                  criterion_id=1, text="kw1", match_type=2, ad_group_id=100
1196              ),
1197              self._make_gaql_row(
1198                  criterion_id=2, text="kw2", match_type=2, ad_group_id=100
1199              ),
1200          ]
1201          result = await client.find_cross_adgroup_duplicates("123")
1202          assert result["duplicate_groups_count"] == 0
1203  
1204      @pytest.mark.unit
1205      async def test_find_cross_adgroup_duplicates_error(self) -> None:
1206          client = self._make_client()
1207          client._search.side_effect = Exception("API error")
1208          result = await client.find_cross_adgroup_duplicates("123")
1209          assert "error" in result
1210  
1211      @pytest.mark.unit
1212      def test_extract_duplicate_groups(self) -> None:
1213          groups = {
1214              "kw1|EXACT": [
1215                  {
1216                      "ad_group_id": "100",
1217                      "ad_group_name": "AG1",
1218                      "criterion_id": "1",
1219                      "text": "kw1",
1220                      "match_type": "EXACT",
1221                      "status": "ENABLED",
1222                      "metrics": {
1223                          "conversions": 5,
1224                          "clicks": 30,
1225                          "impressions": 500,
1226                          "cost": 3000,
1227                      },
1228                  },
1229                  {
1230                      "ad_group_id": "200",
1231                      "ad_group_name": "AG2",
1232                      "criterion_id": "2",
1233                      "text": "kw1",
1234                      "match_type": "EXACT",
1235                      "status": "ENABLED",
1236                      "metrics": {
1237                          "conversions": 0,
1238                          "clicks": 5,
1239                          "impressions": 100,
1240                          "cost": 500,
1241                      },
1242                  },
1243              ],
1244              "kw2|PHRASE": [
1245                  {
1246                      "ad_group_id": "100",
1247                      "ad_group_name": "AG1",
1248                      "criterion_id": "3",
1249                      "text": "kw2",
1250                      "match_type": "PHRASE",
1251                      "status": "ENABLED",
1252                      "metrics": {
1253                          "conversions": 1,
1254                          "clicks": 10,
1255                          "impressions": 200,
1256                          "cost": 1000,
1257                      },
1258                  },
1259              ],
1260          }
1261          dups, removable, waste = _KeywordsAnalysisMixin._extract_duplicate_groups(
1262              groups
1263          )
1264          assert len(dups) == 1
1265          assert removable == 1
1266          assert waste == 500  # CV=0のremovableのcost
1267  
1268  
1269  # =====================================================================
1270  # _analysis_budget テスト
1271  # =====================================================================
1272  
1273  
1274  class TestBudgetAnalysisMixin:
1275      """_BudgetAnalysisMixin のテスト。"""
1276  
1277      def _make_client(self) -> MockAnalysisClient:
1278          return MockAnalysisClient()
1279  
1280      @pytest.mark.unit
1281      async def test_analyze_budget_efficiency(self) -> None:
1282          client = self._make_client()
1283          client.list_campaigns.return_value = [
1284              {"id": "1", "name": "Efficient", "status": "ENABLED"},
1285              {"id": "2", "name": "Inefficient", "status": "ENABLED"},
1286          ]
1287          client.get_performance_report.side_effect = [
1288              [{"metrics": {"cost": 3000, "conversions": 10}}],  # Camp 1
1289              [{"metrics": {"cost": 7000, "conversions": 2}}],  # Camp 2
1290          ]
1291          result = await client.analyze_budget_efficiency()
1292          assert result["total_cost"] == 10000
1293          assert result["total_conversions"] == 12.0
1294          assert len(result["campaigns"]) == 2
1295          # Camp 1 は効率的(cv_share/cost_share > 1.2)
1296          camp1 = next(c for c in result["campaigns"] if c["campaign_id"] == "1")
1297          assert camp1["verdict"] == "EFFICIENT"
1298  
1299      @pytest.mark.unit
1300      async def test_analyze_budget_efficiency_no_campaigns(self) -> None:
1301          client = self._make_client()
1302          client.list_campaigns.return_value = []
1303          result = await client.analyze_budget_efficiency()
1304          assert result["total_cost"] == 0
1305          assert result["campaigns"] == []
1306  
1307      @pytest.mark.unit
1308      async def test_analyze_budget_efficiency_zero_cost(self) -> None:
1309          client = self._make_client()
1310          client.list_campaigns.return_value = [
1311              {"id": "1", "name": "No Cost", "status": "ENABLED"},
1312          ]
1313          client.get_performance_report.return_value = [
1314              {"metrics": {"cost": 0, "conversions": 0}}
1315          ]
1316          result = await client.analyze_budget_efficiency()
1317          camp = result["campaigns"][0]
1318          assert camp["verdict"] == "NO_COST"
1319  
1320      @pytest.mark.unit
1321      async def test_suggest_budget_reallocation(self) -> None:
1322          client = self._make_client()
1323          client.list_campaigns.return_value = [
1324              {"id": "1", "name": "Efficient", "status": "ENABLED"},
1325              {"id": "2", "name": "Inefficient", "status": "ENABLED"},
1326          ]
1327          client.get_performance_report.side_effect = [
1328              [{"metrics": {"cost": 3000, "conversions": 10}}],
1329              [{"metrics": {"cost": 7000, "conversions": 2}}],
1330          ]
1331          client.get_budget.side_effect = [
1332              {"daily_budget": 5000, "id": "b1"},
1333              {"daily_budget": 10000, "id": "b2"},
1334          ]
1335          result = await client.suggest_budget_reallocation()
1336          assert "reallocation_plan" in result
1337          plan = result["reallocation_plan"]
1338          # 非効率キャンペーンから削減 → 効率キャンペーンへ増額
1339          decreases = [p for p in plan if p["action"] == "DECREASE"]
1340          increases = [p for p in plan if p["action"] == "INCREASE"]
1341          assert len(decreases) >= 1
1342          assert len(increases) >= 1
1343  
1344      @pytest.mark.unit
1345      async def test_suggest_budget_reallocation_no_data(self) -> None:
1346          client = self._make_client()
1347          client.list_campaigns.return_value = []
1348          result = await client.suggest_budget_reallocation()
1349          assert result["reallocation_plan"] == []
1350          assert "Insufficient data" in result["summary"]
1351  
1352  
1353  # =====================================================================
1354  # _analysis_rsa テスト
1355  # =====================================================================
1356  
1357  
1358  class TestRsaAnalysisMixin:
1359      """_RsaAnalysisMixin のテスト。"""
1360  
1361      def _make_client(self) -> MockAnalysisClient:
1362          return MockAnalysisClient()
1363  
1364      def _make_asset_row(
1365          self,
1366          text: str = "見出しテスト",
1367          field_type: str = "HEADLINE",
1368          perf_label: str = "BEST",
1369          impressions: int = 1000,
1370          clicks: int = 100,
1371          conversions: float = 5.0,
1372          cost_micros: int = 3_000_000,
1373      ) -> SimpleNamespace:
1374          class _FieldType:
1375              def __str__(self) -> str:
1376                  return f"FieldType.{field_type}"
1377  
1378          class _PerfLabel:
1379              def __str__(self) -> str:
1380                  return f"PerfLabel.{perf_label}"
1381  
1382          return SimpleNamespace(
1383              ad_group_ad_asset_view=SimpleNamespace(
1384                  ad_group_ad="ad1",
1385                  asset="asset1",
1386                  field_type=_FieldType(),
1387                  performance_label=_PerfLabel(),
1388                  enabled=True,
1389              ),
1390              asset=SimpleNamespace(text_asset=SimpleNamespace(text=text)),
1391              metrics=SimpleNamespace(
1392                  impressions=impressions,
1393                  clicks=clicks,
1394                  conversions=conversions,
1395                  cost_micros=cost_micros,
1396              ),
1397          )
1398  
1399      @pytest.mark.unit
1400      async def test_analyze_rsa_assets_basic(self) -> None:
1401          client = self._make_client()
1402          client._search.return_value = [
1403              self._make_asset_row("Best Headline", "HEADLINE", "BEST", 1000, 100, 5.0),
1404              self._make_asset_row("Low Headline", "HEADLINE", "LOW", 500, 20, 0.0),
1405              self._make_asset_row("Best Desc", "DESCRIPTION", "BEST", 800, 80, 4.0),
1406          ]
1407          result = await client.analyze_rsa_assets("123")
1408          assert len(result["headlines"]) == 2
1409          assert len(result["descriptions"]) == 1
1410          assert len(result["best_headlines"]) == 1
1411          assert len(result["worst_headlines"]) == 1
1412          assert result["best_headlines"][0]["text"] == "Best Headline"
1413  
1414      @pytest.mark.unit
1415      async def test_analyze_rsa_assets_empty(self) -> None:
1416          client = self._make_client()
1417          client._search.return_value = []
1418          result = await client.analyze_rsa_assets("123")
1419          assert result["headlines"] == []
1420          assert result["descriptions"] == []
1421          assert any("not yet been accumulated" in i for i in result["insights"])
1422  
1423      @pytest.mark.unit
1424      async def test_audit_rsa_assets_few_headlines(self) -> None:
1425          client = self._make_client()
1426          client._search.return_value = [
1427              self._make_asset_row("H1", "HEADLINE", "BEST"),
1428              self._make_asset_row("H2", "HEADLINE", "GOOD"),
1429          ]
1430          result = await client.audit_rsa_assets("123")
1431          # 2本しかないので add_headlines 推奨
1432          rec_types = [r["type"] for r in result["recommendations"]]
1433          assert "add_headlines" in rec_types
1434  
1435      @pytest.mark.unit
1436      async def test_audit_rsa_assets_no_data(self) -> None:
1437          client = self._make_client()
1438          client._search.return_value = []
1439          result = await client.audit_rsa_assets("123")
1440          assert result["message"] == "No RSA asset data available"
1441  
1442      @pytest.mark.unit
1443      async def test_audit_rsa_assets_error(self) -> None:
1444          client = self._make_client()
1445          client._search.side_effect = Exception("API Error")
1446          result = await client.audit_rsa_assets("123")
1447          assert "error" in result
1448  
1449      @pytest.mark.unit
1450      def test_count_label_distribution(self) -> None:
1451          headlines = [
1452              {"performance_label": "BEST"},
1453              {"performance_label": "BEST"},
1454              {"performance_label": "LOW"},
1455          ]
1456          descriptions = [
1457              {"performance_label": "GOOD"},
1458          ]
1459          dist = _RsaAnalysisMixin._count_label_distribution(headlines, descriptions)
1460          assert dist["BEST"] == 2
1461          assert dist["LOW"] == 1
1462          assert dist["GOOD"] == 1
1463  
1464      @pytest.mark.unit
1465      def test_check_asset_counts_below_threshold(self) -> None:
1466          recs: list[dict[str, Any]] = []
1467          _RsaAnalysisMixin._check_asset_counts(5, 2, recs)
1468          types = [r["type"] for r in recs]
1469          assert "add_headlines" in types
1470          assert "add_descriptions" in types
1471  
1472      @pytest.mark.unit
1473      def test_check_asset_counts_above_threshold(self) -> None:
1474          recs: list[dict[str, Any]] = []
1475          _RsaAnalysisMixin._check_asset_counts(10, 4, recs)
1476          assert len(recs) == 0
1477  
1478      @pytest.mark.unit
1479      def test_recommend_asset_replacements(self) -> None:
1480          recs: list[dict[str, Any]] = []
1481          worst_h = [{"text": "bad headline", "performance_label": "LOW"}]
1482          worst_d = [{"text": "bad desc", "performance_label": "POOR"}]
1483          _RsaAnalysisMixin._recommend_asset_replacements(worst_h, worst_d, recs)
1484          assert len(recs) == 2
1485          assert recs[0]["type"] == "replace_headline"
1486          assert recs[1]["type"] == "replace_description"
1487  
1488  
1489  # =====================================================================
1490  # _analysis_auction テスト
1491  # =====================================================================
1492  
1493  
1494  class TestAuctionAnalysisMixin:
1495      """_AuctionAnalysisMixin のテスト。"""
1496  
1497      def _make_client(self) -> MockAnalysisClient:
1498          return MockAnalysisClient()
1499  
1500      # --- デバイス分析 ---
1501  
1502      def _make_device_row(
1503          self,
1504          device: str = "DESKTOP",
1505          impressions: int = 1000,
1506          clicks: int = 100,
1507          cost_micros: int = 5_000_000,
1508          conversions: float = 5.0,
1509          ctr: float = 0.10,
1510          average_cpc: int = 50_000,
1511      ) -> SimpleNamespace:
1512          class _Device:
1513              def __str__(self) -> str:
1514                  return f"Device.{device}"
1515  
1516          return SimpleNamespace(
1517              segments=SimpleNamespace(device=_Device()),
1518              metrics=SimpleNamespace(
1519                  impressions=impressions,
1520                  clicks=clicks,
1521                  cost_micros=cost_micros,
1522                  conversions=conversions,
1523                  ctr=ctr,
1524                  average_cpc=average_cpc,
1525              ),
1526          )
1527  
1528      @pytest.mark.unit
1529      async def test_analyze_device_performance_basic(self) -> None:
1530          client = self._make_client()
1531          client.get_campaign.return_value = {"name": "Test Campaign"}
1532          client._search.return_value = [
1533              self._make_device_row("DESKTOP", 1000, 100, 5_000_000, 5.0, 0.10, 50_000),
1534              self._make_device_row("MOBILE", 800, 60, 4_000_000, 2.0, 0.075, 66_667),
1535          ]
1536          result = await client.analyze_device_performance("123")
1537          assert len(result["devices"]) == 2
1538          assert result["devices"][0]["cost"] >= result["devices"][1]["cost"]
1539  
1540      @pytest.mark.unit
1541      async def test_analyze_device_performance_not_found(self) -> None:
1542          client = self._make_client()
1543          client.get_campaign.return_value = None
1544          result = await client.analyze_device_performance("999")
1545          assert "error" in result
1546  
1547      @pytest.mark.unit
1548      async def test_analyze_device_performance_no_data(self) -> None:
1549          client = self._make_client()
1550          client.get_campaign.return_value = {"name": "Test"}
1551          client._search.return_value = []
1552          result = await client.analyze_device_performance("123")
1553          assert result["devices"] == []
1554          assert "No" in result["message"]
1555  
1556      @pytest.mark.unit
1557      def test_generate_device_insights_cv0_cost(self) -> None:
1558          devices = [
1559              {
1560                  "device_type": "TABLET",
1561                  "conversions": 0,
1562                  "cost": 1000,
1563                  "cpa": None,
1564                  "ctr": 5.0,
1565              },
1566          ]
1567          insights = _AuctionAnalysisMixin._generate_device_insights(devices)
1568          assert any("TABLET" in i and "0 conversions" in i for i in insights)
1569  
1570      @pytest.mark.unit
1571      def test_generate_device_insights_cpa_gap(self) -> None:
1572          devices = [
1573              {
1574                  "device_type": "DESKTOP",
1575                  "conversions": 5,
1576                  "cost": 5000,
1577                  "cpa": 1000,
1578                  "ctr": 10.0,
1579              },
1580              {
1581                  "device_type": "MOBILE",
1582                  "conversions": 2,
1583                  "cost": 4000,
1584                  "cpa": 2000,
1585                  "ctr": 5.0,
1586              },
1587          ]
1588          insights = _AuctionAnalysisMixin._generate_device_insights(devices)
1589          assert any("x that of" in i for i in insights)
1590  
1591      @pytest.mark.unit
1592      def test_generate_device_insights_mobile_low_ctr(self) -> None:
1593          devices = [
1594              {
1595                  "device_type": "DESKTOP",
1596                  "conversions": 5,
1597                  "cost": 5000,
1598                  "cpa": 1000,
1599                  "ctr": 10.0,
1600              },
1601              {
1602                  "device_type": "MOBILE",
1603                  "conversions": 2,
1604                  "cost": 2000,
1605                  "cpa": 1000,
1606                  "ctr": 3.0,
1607              },
1608          ]
1609          insights = _AuctionAnalysisMixin._generate_device_insights(devices)
1610          assert any("Mobile CTR" in i for i in insights)
1611  
1612      # --- CPC トレンド ---
1613  
1614      @pytest.mark.unit
1615      def test_calculate_cpc_trend_rising(self) -> None:
1616          values = [100, 110, 120, 130, 140, 150, 160]
1617          trend = _AuctionAnalysisMixin._calculate_cpc_trend(values)
1618          assert trend["direction"] == "rising"
1619          assert trend["slope_per_day"] > 0
1620  
1621      @pytest.mark.unit
1622      def test_calculate_cpc_trend_stable(self) -> None:
1623          values = [100, 101, 99, 100, 101, 99, 100]
1624          trend = _AuctionAnalysisMixin._calculate_cpc_trend(values)
1625          assert trend["direction"] == "stable"
1626  
1627      @pytest.mark.unit
1628      def test_calculate_cpc_trend_falling(self) -> None:
1629          values = [160, 150, 140, 130, 120, 110, 100]
1630          trend = _AuctionAnalysisMixin._calculate_cpc_trend(values)
1631          assert trend["direction"] == "falling"
1632  
1633      @pytest.mark.unit
1634      def test_calculate_cpc_trend_insufficient(self) -> None:
1635          trend = _AuctionAnalysisMixin._calculate_cpc_trend([100])
1636          assert trend["direction"] == "insufficient_data"
1637  
1638      @pytest.mark.unit
1639      def test_calculate_cpc_trend_empty(self) -> None:
1640          trend = _AuctionAnalysisMixin._calculate_cpc_trend([])
1641          assert trend["direction"] == "insufficient_data"
1642          assert trend["avg_cpc"] == 0.0
1643  
1644      @pytest.mark.unit
1645      def test_generate_cpc_insights_rising(self) -> None:
1646          trend = {"direction": "rising", "change_rate_per_day_pct": 2.5, "avg_cpc": 100}
1647          insights = _AuctionAnalysisMixin._generate_cpc_insights([100], trend, [])
1648          assert any("rising trend" in i for i in insights)
1649  
1650      @pytest.mark.unit
1651      def test_generate_cpc_insights_falling(self) -> None:
1652          trend = {
1653              "direction": "falling",
1654              "change_rate_per_day_pct": -2.5,
1655              "avg_cpc": 100,
1656          }
1657          insights = _AuctionAnalysisMixin._generate_cpc_insights([100], trend, [])
1658          assert any("declining trend" in i for i in insights)
1659  
1660      @pytest.mark.unit
1661      def test_generate_cpc_insights_weekly_spike(self) -> None:
1662          # 14日分のデータ: 前7日=100, 直近7日=130 → 30%急騰
1663          values = [100] * 7 + [130] * 7
1664          trend = {"direction": "rising", "change_rate_per_day_pct": 1.5, "avg_cpc": 115}
1665          insights = _AuctionAnalysisMixin._generate_cpc_insights(values, trend, [])
1666          assert any("surged" in i for i in insights)
1667  
1668      @pytest.mark.unit
1669      def test_generate_cpc_insights_spike_detection(self) -> None:
1670          daily_data = [
1671              {"date": "2026-03-01", "average_cpc": 100},
1672              {"date": "2026-03-02", "average_cpc": 300},  # spike: > 100*2
1673          ]
1674          trend = {"direction": "stable", "change_rate_per_day_pct": 0, "avg_cpc": 100}
1675          insights = _AuctionAnalysisMixin._generate_cpc_insights(
1676              [100, 300], trend, daily_data
1677          )
1678          assert any("anomal" in i for i in insights)
1679  
1680      @pytest.mark.unit
1681      async def test_detect_cpc_trend_not_found(self) -> None:
1682          client = self._make_client()
1683          client.get_campaign.return_value = None
1684          result = await client.detect_cpc_trend("999")
1685          assert "error" in result
1686  
1687      @pytest.mark.unit
1688      async def test_detect_cpc_trend_no_data(self) -> None:
1689          client = self._make_client()
1690          client.get_campaign.return_value = {"name": "Test"}
1691          client._search.return_value = []
1692          result = await client.detect_cpc_trend("123")
1693          assert result["daily_data"] == []
1694          assert result["trend"] is None
1695  
1696      @pytest.mark.unit
1697      async def test_detect_cpc_trend_with_data(self) -> None:
1698          client = self._make_client()
1699          client.get_campaign.return_value = {"name": "Test"}
1700          rows = []
1701          for i in range(7):
1702              rows.append(
1703                  SimpleNamespace(
1704                      segments=SimpleNamespace(date=f"2026-03-{20+i:02d}"),
1705                      metrics=SimpleNamespace(
1706                          average_cpc=(100 + i * 10) * 1_000_000,
1707                          clicks=50,
1708                          impressions=500,
1709                          cost_micros=5000 * 1_000_000,
1710                      ),
1711                  )
1712              )
1713          client._search.return_value = rows
1714          result = await client.detect_cpc_trend("123")
1715          assert result["data_points"] == 7
1716          assert result["trend"]["direction"] in ("rising", "stable", "falling")
1717  
1718      # --- オークション分析 (impression share metrics) ---
1719  
1720      def _make_auction_row(
1721          self,
1722          campaign_id: str = "123",
1723          campaign_name: str = "Test",
1724          is_pct: float = 0.5,
1725          rank_lost: float = 0.1,
1726          budget_lost: float = 0.1,
1727          top_is: float = 0.4,
1728          abs_top_is: float = 0.1,
1729          rank_lost_top: float = 0.05,
1730          budget_lost_top: float = 0.05,
1731          rank_lost_abs_top: float = 0.02,
1732          budget_lost_abs_top: float = 0.02,
1733      ) -> SimpleNamespace:
1734          return SimpleNamespace(
1735              campaign=SimpleNamespace(id=int(campaign_id), name=campaign_name),
1736              metrics=SimpleNamespace(
1737                  search_impression_share=is_pct,
1738                  search_rank_lost_impression_share=rank_lost,
1739                  search_budget_lost_impression_share=budget_lost,
1740                  search_top_impression_share=top_is,
1741                  search_absolute_top_impression_share=abs_top_is,
1742                  search_rank_lost_top_impression_share=rank_lost_top,
1743                  search_budget_lost_top_impression_share=budget_lost_top,
1744                  search_rank_lost_absolute_top_impression_share=rank_lost_abs_top,
1745                  search_budget_lost_absolute_top_impression_share=budget_lost_abs_top,
1746              ),
1747          )
1748  
1749      @pytest.mark.unit
1750      async def test_get_auction_insights_basic(self) -> None:
1751          client = self._make_client()
1752          client._search.return_value = [
1753              self._make_auction_row(is_pct=0.6, abs_top_is=0.25),
1754          ]
1755          result = await client.get_auction_insights("123")
1756          assert len(result) == 1
1757          assert result[0]["search_impression_share"] == 60.0
1758          assert result[0]["search_abs_top_is"] == 25.0
1759          assert "note" in result[0]
1760  
1761      @pytest.mark.unit
1762      async def test_get_auction_insights_error(self) -> None:
1763          client = self._make_client()
1764          client._search.side_effect = Exception("API error")
1765          result = await client.get_auction_insights("123")
1766          assert len(result) == 1
1767          assert result[0]["error"] == "auction_insights_unavailable"
1768          assert "API error" in result[0]["reason"]
1769  
1770      @pytest.mark.unit
1771      async def test_analyze_auction_insights_not_found(self) -> None:
1772          client = self._make_client()
1773          client.get_campaign.return_value = None
1774          result = await client.analyze_auction_insights("999")
1775          assert "error" in result
1776  
1777      @pytest.mark.unit
1778      async def test_analyze_auction_insights_no_data(self) -> None:
1779          client = self._make_client()
1780          client.get_campaign.return_value = {"name": "Test"}
1781          client._search.return_value = []
1782          result = await client.analyze_auction_insights("123")
1783          assert "No" in result["message"]
1784  
1785      @pytest.mark.unit
1786      async def test_analyze_auction_insights_low_is(self) -> None:
1787          client = self._make_client()
1788          client.get_campaign.return_value = {"name": "Test"}
1789          client._search.return_value = [
1790              self._make_auction_row(is_pct=0.3, rank_lost=0.3, abs_top_is=0.1),
1791          ]
1792          result = await client.analyze_auction_insights("123")
1793          assert any("impression share" in i for i in result["insights"])
1794  
1795      @pytest.mark.unit
1796      async def test_analyze_auction_insights_low_abs_top(self) -> None:
1797          client = self._make_client()
1798          client.get_campaign.return_value = {"name": "Test"}
1799          client._search.return_value = [
1800              self._make_auction_row(is_pct=0.6, abs_top_is=0.1),
1801          ]
1802          result = await client.analyze_auction_insights("123")
1803          assert any("absolute top" in i.lower() for i in result["insights"])
1804  
1805  
1806  # =====================================================================
1807  # _analysis_btob テスト
1808  # =====================================================================
1809  
1810  
1811  class TestBtoBAnalysisMixin:
1812      """_BtoBAnalysisMixin のテスト。"""
1813  
1814      def _make_client(self) -> MockAnalysisClient:
1815          return MockAnalysisClient()
1816  
1817      @pytest.mark.unit
1818      async def test_suggest_btob_optimizations_not_found(self) -> None:
1819          client = self._make_client()
1820          client.get_campaign.return_value = None
1821          result = await client.suggest_btob_optimizations("999")
1822          assert "error" in result
1823  
1824      @pytest.mark.unit
1825      async def test_check_schedule_no_schedule(self) -> None:
1826          client = self._make_client()
1827          client.list_schedule_targeting.return_value = []
1828          suggestions: list[dict[str, Any]] = []
1829          await client._check_schedule_for_btob("123", suggestions)
1830          assert len(suggestions) == 1
1831          assert suggestions[0]["category"] == "schedule"
1832          assert suggestions[0]["priority"] == "HIGH"
1833  
1834      @pytest.mark.unit
1835      async def test_check_schedule_weekend(self) -> None:
1836          client = self._make_client()
1837          client.list_schedule_targeting.return_value = [
1838              {"day_of_week": "MONDAY"},
1839              {"day_of_week": "SATURDAY"},
1840          ]
1841          suggestions: list[dict[str, Any]] = []
1842          await client._check_schedule_for_btob("123", suggestions)
1843          assert len(suggestions) == 1
1844          assert suggestions[0]["priority"] == "MEDIUM"
1845          assert "weekend" in suggestions[0]["message"]
1846  
1847      @pytest.mark.unit
1848      async def test_check_schedule_weekday_only(self) -> None:
1849          client = self._make_client()
1850          client.list_schedule_targeting.return_value = [
1851              {"day_of_week": "MONDAY"},
1852              {"day_of_week": "TUESDAY"},
1853          ]
1854          suggestions: list[dict[str, Any]] = []
1855          await client._check_schedule_for_btob("123", suggestions)
1856          assert len(suggestions) == 0
1857  
1858      @pytest.mark.unit
1859      async def test_check_schedule_exception(self) -> None:
1860          client = self._make_client()
1861          client.list_schedule_targeting.side_effect = Exception("error")
1862          suggestions: list[dict[str, Any]] = []
1863          await client._check_schedule_for_btob("123", suggestions)
1864          assert len(suggestions) == 0
1865  
1866      @pytest.mark.unit
1867      async def test_check_device_mobile_higher_cpa(self) -> None:
1868          client = self._make_client()
1869          # analyze_device_performance をモック
1870          client.analyze_device_performance = AsyncMock(
1871              return_value={
1872                  "devices": [
1873                      {
1874                          "device_type": "DESKTOP",
1875                          "cpa": 3000,
1876                          "conversions": 5,
1877                          "cost": 15000,
1878                      },
1879                      {
1880                          "device_type": "MOBILE",
1881                          "cpa": 5000,
1882                          "conversions": 2,
1883                          "cost": 10000,
1884                      },
1885                  ]
1886              }
1887          )
1888          suggestions: list[dict[str, Any]] = []
1889          await client._check_device_for_btob("123", "LAST_30_DAYS", suggestions)
1890          assert len(suggestions) >= 1
1891          assert suggestions[0]["category"] == "device"
1892  
1893      @pytest.mark.unit
1894      async def test_check_device_tablet_cv0(self) -> None:
1895          client = self._make_client()
1896          client.analyze_device_performance = AsyncMock(
1897              return_value={
1898                  "devices": [
1899                      {
1900                          "device_type": "DESKTOP",
1901                          "cpa": 3000,
1902                          "conversions": 5,
1903                          "cost": 15000,
1904                      },
1905                      {
1906                          "device_type": "MOBILE",
1907                          "cpa": 3000,
1908                          "conversions": 3,
1909                          "cost": 9000,
1910                      },
1911                      {
1912                          "device_type": "TABLET",
1913                          "cpa": None,
1914                          "conversions": 0,
1915                          "cost": 2000,
1916                      },
1917                  ]
1918              }
1919          )
1920          suggestions: list[dict[str, Any]] = []
1921          await client._check_device_for_btob("123", "LAST_30_DAYS", suggestions)
1922          assert any(
1923              s["category"] == "device" and "tablet" in s["message"] for s in suggestions
1924          )
1925  
1926      @pytest.mark.unit
1927      async def test_check_search_terms_high_info_ratio(self) -> None:
1928          client = self._make_client()
1929          # 30%が情報収集系
1930          terms = [
1931              {"search_term": "SEOとは"},
1932              {"search_term": "ツール比較"},
1933              {"search_term": "無料ツール"},
1934              {"search_term": "広告代理店"},
1935              {"search_term": "マーケティング会社"},
1936              {"search_term": "SaaS導入"},
1937              {"search_term": "営業支援"},
1938              {"search_term": "CRM選定"},
1939              {"search_term": "MA導入"},
1940              {"search_term": "業務効率化"},
1941          ]
1942          client.get_search_terms_report.return_value = terms
1943          suggestions: list[dict[str, Any]] = []
1944          await client._check_search_terms_for_btob("123", "LAST_30_DAYS", suggestions)
1945          assert len(suggestions) >= 1
1946          assert suggestions[0]["category"] == "search_terms"
1947  
1948      @pytest.mark.unit
1949      async def test_check_search_terms_low_info_ratio(self) -> None:
1950          client = self._make_client()
1951          terms = [
1952              {"search_term": "広告代理店"},
1953              {"search_term": "マーケティング会社"},
1954              {"search_term": "SaaS導入"},
1955              {"search_term": "CRM選定"},
1956              {"search_term": "MA導入"},
1957          ]
1958          client.get_search_terms_report.return_value = terms
1959          suggestions: list[dict[str, Any]] = []
1960          await client._check_search_terms_for_btob("123", "LAST_30_DAYS", suggestions)
1961          assert len(suggestions) == 0
1962  
1963      @pytest.mark.unit
1964      async def test_check_search_terms_empty(self) -> None:
1965          client = self._make_client()
1966          client.get_search_terms_report.return_value = []
1967          suggestions: list[dict[str, Any]] = []
1968          await client._check_search_terms_for_btob("123", "LAST_30_DAYS", suggestions)
1969          assert len(suggestions) == 0
1970  
1971      @pytest.mark.unit
1972      async def test_check_search_terms_exception(self) -> None:
1973          client = self._make_client()
1974          client.get_search_terms_report.side_effect = Exception("error")
1975          suggestions: list[dict[str, Any]] = []
1976          await client._check_search_terms_for_btob("123", "LAST_30_DAYS", suggestions)
1977          assert len(suggestions) == 0
1978  
1979      @pytest.mark.unit
1980      async def test_suggest_btob_optimizations_full(self) -> None:
1981          client = self._make_client()
1982          client.get_campaign.return_value = {"name": "BtoB Campaign"}
1983          client.list_schedule_targeting.return_value = []
1984          client.analyze_device_performance = AsyncMock(
1985              return_value={
1986                  "devices": [
1987                      {
1988                          "device_type": "DESKTOP",
1989                          "cpa": 3000,
1990                          "conversions": 5,
1991                          "cost": 15000,
1992                      },
1993                      {
1994                          "device_type": "MOBILE",
1995                          "cpa": 5000,
1996                          "conversions": 2,
1997                          "cost": 10000,
1998                      },
1999                  ]
2000              }
2001          )
2002          client.get_search_terms_report.return_value = [
2003              {"search_term": "SEOとは"},
2004              {"search_term": "広告代理店"},
2005          ]
2006          result = await client.suggest_btob_optimizations("123")
2007          assert result["campaign_name"] == "BtoB Campaign"
2008          assert result["suggestion_count"] >= 1