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