/ tests / test_auth_setup.py
test_auth_setup.py
   1  """Google Ads OAuthフロー + セットアップウィザードのテスト(TDD: RED -> GREEN -> IMPROVE)"""
   2  
   3  from __future__ import annotations
   4  
   5  import http.client
   6  import json
   7  import threading
   8  import time
   9  from pathlib import Path
  10  from unittest.mock import AsyncMock, MagicMock, patch
  11  
  12  import pytest
  13  
  14  from mureo.auth import GoogleAdsCredentials
  15  
  16  
  17  # ---------------------------------------------------------------------------
  18  # 1. ローカルサーバーがcallbackを受信できること(Meta Ads用に残す)
  19  # ---------------------------------------------------------------------------
  20  
  21  
  22  @pytest.mark.unit
  23  def test_oauth_callback_server() -> None:
  24      """ローカルHTTPサーバーがOAuthコールバックを受信できること"""
  25      from mureo.auth_setup import OAuthCallbackServer
  26  
  27      server = OAuthCallbackServer(port=0)  # 空きポート自動選択
  28      actual_port = server.server.server_address[1]
  29  
  30      # サーバーをバックグラウンドで起動
  31      server_thread = threading.Thread(target=server.wait_for_callback, daemon=True)
  32      server_thread.start()
  33  
  34      # コールバックリクエストを送信
  35      time.sleep(0.1)  # サーバー起動待ち
  36      conn = http.client.HTTPConnection("localhost", actual_port)
  37      conn.request("GET", "/callback?code=test-auth-code-123")
  38      response = conn.getresponse()
  39      conn.close()
  40  
  41      assert response.status == 200
  42      assert server.authorization_code == "test-auth-code-123"
  43  
  44  
  45  @pytest.mark.unit
  46  def test_oauth_callback_server_error() -> None:
  47      """OAuthコールバックでエラーパラメータが返された場合"""
  48      from mureo.auth_setup import OAuthCallbackServer
  49  
  50      server = OAuthCallbackServer(port=0)
  51      actual_port = server.server.server_address[1]
  52  
  53      server_thread = threading.Thread(target=server.wait_for_callback, daemon=True)
  54      server_thread.start()
  55  
  56      time.sleep(0.1)
  57      conn = http.client.HTTPConnection("localhost", actual_port)
  58      conn.request("GET", "/callback?error=access_denied")
  59      response = conn.getresponse()
  60      conn.close()
  61  
  62      assert response.status == 200
  63      assert server.authorization_code is None
  64      assert server.error == "access_denied"
  65  
  66  
  67  # ---------------------------------------------------------------------------
  68  # 4. credentials.json 新規保存
  69  # ---------------------------------------------------------------------------
  70  
  71  
  72  @pytest.mark.unit
  73  def test_save_credentials_new(tmp_path: Path) -> None:
  74      """新規のcredentials.jsonが正しく保存されること"""
  75      from mureo.auth_setup import save_credentials
  76  
  77      cred_path = tmp_path / "credentials.json"
  78  
  79      google_creds = GoogleAdsCredentials(
  80          developer_token="dev-tok",
  81          client_id="cid",
  82          client_secret="csec",
  83          refresh_token="rtok",
  84          login_customer_id="1234567890",
  85      )
  86  
  87      save_credentials(
  88          path=cred_path,
  89          google=google_creds,
  90          customer_id="1234567890",
  91      )
  92  
  93      assert cred_path.exists()
  94      data = json.loads(cred_path.read_text(encoding="utf-8"))
  95      assert data["google_ads"]["developer_token"] == "dev-tok"
  96      assert data["google_ads"]["client_id"] == "cid"
  97      assert data["google_ads"]["client_secret"] == "csec"
  98      assert data["google_ads"]["refresh_token"] == "rtok"
  99      assert data["google_ads"]["login_customer_id"] == "1234567890"
 100  
 101  
 102  # ---------------------------------------------------------------------------
 103  # 5. credentials.json 既存データとマージ保存
 104  # ---------------------------------------------------------------------------
 105  
 106  
 107  @pytest.mark.unit
 108  def test_save_credentials_merge(tmp_path: Path) -> None:
 109      """既存のcredentials.jsonにマージして保存されること"""
 110      from mureo.auth_setup import save_credentials
 111  
 112      cred_path = tmp_path / "credentials.json"
 113  
 114      # 既存のMeta Ads認証情報を事前に保存
 115      existing = {
 116          "meta_ads": {
 117              "access_token": "existing-meta-token",
 118              "app_id": "existing-app-id",
 119          }
 120      }
 121      cred_path.write_text(json.dumps(existing), encoding="utf-8")
 122  
 123      # Google Ads認証情報を追加
 124      google_creds = GoogleAdsCredentials(
 125          developer_token="dev-tok",
 126          client_id="cid",
 127          client_secret="csec",
 128          refresh_token="rtok",
 129      )
 130  
 131      save_credentials(path=cred_path, google=google_creds)
 132  
 133      data = json.loads(cred_path.read_text(encoding="utf-8"))
 134      # Google Adsが追加されている
 135      assert data["google_ads"]["developer_token"] == "dev-tok"
 136      # Meta Adsが保持されている
 137      assert data["meta_ads"]["access_token"] == "existing-meta-token"
 138  
 139  
 140  # ---------------------------------------------------------------------------
 141  # 6. ディレクトリが存在しない場合に作成
 142  # ---------------------------------------------------------------------------
 143  
 144  
 145  @pytest.mark.unit
 146  def test_save_credentials_creates_directory(tmp_path: Path) -> None:
 147      """~/.mureo/ ディレクトリが存在しない場合に自動作成されること"""
 148      from mureo.auth_setup import save_credentials
 149  
 150      nested_path = tmp_path / "nonexistent" / "dir" / "credentials.json"
 151  
 152      google_creds = GoogleAdsCredentials(
 153          developer_token="dev-tok",
 154          client_id="cid",
 155          client_secret="csec",
 156          refresh_token="rtok",
 157      )
 158  
 159      save_credentials(path=nested_path, google=google_creds)
 160  
 161      assert nested_path.exists()
 162      data = json.loads(nested_path.read_text(encoding="utf-8"))
 163      assert data["google_ads"]["developer_token"] == "dev-tok"
 164  
 165  
 166  # ---------------------------------------------------------------------------
 167  # 7. アカウント一覧取得(APIモック)
 168  # ---------------------------------------------------------------------------
 169  
 170  
 171  @pytest.mark.unit
 172  @pytest.mark.asyncio
 173  async def test_list_accessible_accounts() -> None:
 174      """Google Ads APIでアクセス可能なアカウント一覧を取得できること"""
 175      from mureo.auth_setup import list_accessible_accounts
 176  
 177      creds = GoogleAdsCredentials(
 178          developer_token="dev-tok",
 179          client_id="cid",
 180          client_secret="csec",
 181          refresh_token="rtok",
 182      )
 183  
 184      # Google Ads SDK直接呼び出しのモック
 185      mock_ga_client = MagicMock()
 186  
 187      # CustomerService(アカウント一覧取得用)
 188      mock_customer_service = MagicMock()
 189      mock_response = MagicMock()
 190      mock_response.resource_names = [
 191          "customers/1234567890",
 192          "customers/9876543210",
 193      ]
 194      mock_customer_service.list_accessible_customers.return_value = mock_response
 195  
 196      # GoogleAdsService(アカウント名取得用)
 197      # どちらも非マネージャーアカウント
 198      mock_ga_service = MagicMock()
 199      mock_row_1 = MagicMock()
 200      mock_row_1.customer.descriptive_name = "テストアカウント1"
 201      mock_row_1.customer.manager = False
 202      mock_row_2 = MagicMock()
 203      mock_row_2.customer.descriptive_name = "テストアカウント2"
 204      mock_row_2.customer.manager = False
 205      mock_ga_service.search.side_effect = [[mock_row_1], [mock_row_2]]
 206  
 207      def _get_service(name: str) -> MagicMock:
 208          if name == "CustomerService":
 209              return mock_customer_service
 210          return mock_ga_service
 211  
 212      mock_ga_client.get_service.side_effect = _get_service
 213  
 214      with patch(
 215          "google.ads.googleads.client.GoogleAdsClient", return_value=mock_ga_client
 216      ):
 217          accounts = await list_accessible_accounts(creds)
 218  
 219      assert len(accounts) == 2
 220      assert accounts[0]["id"] == "1234567890"
 221      assert accounts[0]["name"] == "テストアカウント1"
 222      assert accounts[0]["is_manager"] is False
 223      assert accounts[0]["parent_id"] is None
 224      assert accounts[1]["id"] == "9876543210"
 225      assert accounts[1]["name"] == "テストアカウント2"
 226      assert accounts[1]["is_manager"] is False
 227      assert accounts[1]["parent_id"] is None
 228  
 229  
 230  @pytest.mark.unit
 231  @pytest.mark.asyncio
 232  async def test_list_accessible_accounts_traverses_mcc_children() -> None:
 233      """MCCアカウントの配下の子アカウントも列挙できること"""
 234      from mureo.auth_setup import list_accessible_accounts
 235  
 236      creds = GoogleAdsCredentials(
 237          developer_token="dev-tok",
 238          client_id="cid",
 239          client_secret="csec",
 240          refresh_token="rtok",
 241      )
 242  
 243      mock_ga_client = MagicMock()
 244  
 245      # listAccessibleCustomersはMCC 1件だけを返す
 246      mock_customer_service = MagicMock()
 247      mock_response = MagicMock()
 248      mock_response.resource_names = ["customers/1111111111"]
 249      mock_customer_service.list_accessible_customers.return_value = mock_response
 250  
 251      # 1回目のsearch: MCCの情報
 252      mcc_row = MagicMock()
 253      mcc_row.customer.descriptive_name = "親MCC"
 254      mcc_row.customer.manager = True
 255  
 256      # 2回目のsearch: MCC配下の子アカウント2件
 257      child_row_1 = MagicMock()
 258      child_row_1.customer_client.id = 2222222222
 259      child_row_1.customer_client.descriptive_name = "子アカウントA"
 260      child_row_1.customer_client.manager = False
 261      child_row_2 = MagicMock()
 262      child_row_2.customer_client.id = 3333333333
 263      child_row_2.customer_client.descriptive_name = "子アカウントB"
 264      child_row_2.customer_client.manager = False
 265  
 266      mock_ga_service = MagicMock()
 267      mock_ga_service.search.side_effect = [
 268          [mcc_row],
 269          [child_row_1, child_row_2],
 270      ]
 271  
 272      def _get_service(name: str) -> MagicMock:
 273          if name == "CustomerService":
 274              return mock_customer_service
 275          return mock_ga_service
 276  
 277      mock_ga_client.get_service.side_effect = _get_service
 278  
 279      with patch(
 280          "google.ads.googleads.client.GoogleAdsClient", return_value=mock_ga_client
 281      ):
 282          accounts = await list_accessible_accounts(creds)
 283  
 284      assert len(accounts) == 3
 285      # 親MCC
 286      assert accounts[0]["id"] == "1111111111"
 287      assert accounts[0]["name"] == "親MCC"
 288      assert accounts[0]["is_manager"] is True
 289      assert accounts[0]["parent_id"] is None
 290      # 子アカウントA(parent_id = MCC)
 291      assert accounts[1]["id"] == "2222222222"
 292      assert accounts[1]["name"] == "子アカウントA"
 293      assert accounts[1]["is_manager"] is False
 294      assert accounts[1]["parent_id"] == "1111111111"
 295      # 子アカウントB
 296      assert accounts[2]["id"] == "3333333333"
 297      assert accounts[2]["name"] == "子アカウントB"
 298      assert accounts[2]["is_manager"] is False
 299      assert accounts[2]["parent_id"] == "1111111111"
 300  
 301  
 302  @pytest.mark.unit
 303  @pytest.mark.asyncio
 304  async def test_list_accessible_accounts_empty() -> None:
 305      """アクセス可能なアカウントがない場合は空リストを返すこと"""
 306      from mureo.auth_setup import list_accessible_accounts
 307  
 308      creds = GoogleAdsCredentials(
 309          developer_token="dev-tok",
 310          client_id="cid",
 311          client_secret="csec",
 312          refresh_token="rtok",
 313      )
 314  
 315      mock_ga_client = MagicMock()
 316      mock_customer_service = MagicMock()
 317      mock_response = MagicMock()
 318      mock_response.resource_names = []
 319      mock_customer_service.list_accessible_customers.return_value = mock_response
 320      mock_ga_client.get_service.return_value = mock_customer_service
 321  
 322      with patch(
 323          "google.ads.googleads.client.GoogleAdsClient", return_value=mock_ga_client
 324      ):
 325          accounts = await list_accessible_accounts(creds)
 326  
 327      assert accounts == []
 328  
 329  
 330  # ---------------------------------------------------------------------------
 331  # 8. セットアップ全体フロー(input/OAuth/APIすべてモック)
 332  # ---------------------------------------------------------------------------
 333  
 334  
 335  @pytest.mark.unit
 336  @pytest.mark.asyncio
 337  async def test_setup_google_ads_flow(tmp_path: Path) -> None:
 338      """Google Adsセットアップの全体フローが正しく動作すること"""
 339      from mureo.auth_setup import OAuthResult, setup_google_ads
 340  
 341      cred_path = tmp_path / "credentials.json"
 342  
 343      # ユーザー入力のモック
 344      user_inputs = iter(
 345          [
 346              "test-developer-token",  # Developer Token
 347              "test-client-id.apps.googleusercontent.com",  # Client ID
 348              "test-client-secret",  # Client Secret
 349          ]
 350      )
 351  
 352      mock_accounts = [
 353          {
 354              "id": "1234567890",
 355              "name": "Account 1234567890",
 356              "is_manager": False,
 357              "parent_id": None,
 358          },
 359          {
 360              "id": "9876543210",
 361              "name": "Account 9876543210",
 362              "is_manager": False,
 363              "parent_id": None,
 364          },
 365      ]
 366  
 367      mock_oauth_result = OAuthResult(
 368          refresh_token="1//test-refresh-token",
 369          access_token="ya29.test-access-token",
 370      )
 371  
 372      with (
 373          patch("mureo.auth_setup.input_func", side_effect=user_inputs),
 374          patch("mureo.auth_setup._select_account", return_value="1234567890"),
 375          patch(
 376              "mureo.auth_setup.run_google_oauth",
 377              new_callable=AsyncMock,
 378              return_value=mock_oauth_result,
 379          ),
 380          patch(
 381              "mureo.auth_setup.list_accessible_accounts",
 382              new_callable=AsyncMock,
 383              return_value=mock_accounts,
 384          ),
 385      ):
 386          result = await setup_google_ads(credentials_path=cred_path)
 387  
 388      assert result.developer_token == "test-developer-token"
 389      assert result.client_id == "test-client-id.apps.googleusercontent.com"
 390      assert result.client_secret == "test-client-secret"
 391      assert result.refresh_token == "1//test-refresh-token"
 392      assert result.login_customer_id == "1234567890"
 393  
 394      # credentials.jsonに保存されていること
 395      data = json.loads(cred_path.read_text(encoding="utf-8"))
 396      assert data["google_ads"]["developer_token"] == "test-developer-token"
 397      assert data["google_ads"]["login_customer_id"] == "1234567890"
 398  
 399  
 400  @pytest.mark.unit
 401  @pytest.mark.asyncio
 402  async def test_setup_google_ads_flow_no_accounts(tmp_path: Path) -> None:
 403      """アカウントが見つからない場合もcredentials.jsonが保存されること"""
 404      from mureo.auth_setup import OAuthResult, setup_google_ads
 405  
 406      cred_path = tmp_path / "credentials.json"
 407  
 408      user_inputs = iter(
 409          [
 410              "test-developer-token",
 411              "test-client-id",
 412              "test-client-secret",
 413          ]
 414      )
 415  
 416      mock_oauth_result = OAuthResult(
 417          refresh_token="1//test-refresh-token",
 418          access_token="ya29.test-access-token",
 419      )
 420  
 421      with (
 422          patch("mureo.auth_setup.input_func", side_effect=user_inputs),
 423          patch(
 424              "mureo.auth_setup.run_google_oauth",
 425              new_callable=AsyncMock,
 426              return_value=mock_oauth_result,
 427          ),
 428          patch(
 429              "mureo.auth_setup.list_accessible_accounts",
 430              new_callable=AsyncMock,
 431              return_value=[],
 432          ),
 433      ):
 434          result = await setup_google_ads(credentials_path=cred_path)
 435  
 436      assert result.developer_token == "test-developer-token"
 437      assert result.refresh_token == "1//test-refresh-token"
 438      assert result.login_customer_id is None
 439  
 440      data = json.loads(cred_path.read_text(encoding="utf-8"))
 441      assert data["google_ads"]["developer_token"] == "test-developer-token"
 442  
 443  
 444  @pytest.mark.unit
 445  @pytest.mark.asyncio
 446  async def test_setup_google_ads_flow_selects_mcc_child(tmp_path: Path) -> None:
 447      """MCC配下の子アカウントを選択した場合、login_customer_idは親MCCになること"""
 448      from mureo.auth_setup import OAuthResult, setup_google_ads
 449  
 450      cred_path = tmp_path / "credentials.json"
 451  
 452      user_inputs = iter(
 453          [
 454              "test-developer-token",
 455              "test-client-id",
 456              "test-client-secret",
 457          ]
 458      )
 459  
 460      # MCC + 2つの子アカウント(子はparent_id付き)
 461      mock_accounts = [
 462          {
 463              "id": "1111111111",
 464              "name": "親MCC",
 465              "is_manager": True,
 466              "parent_id": None,
 467          },
 468          {
 469              "id": "2222222222",
 470              "name": "子アカウントA",
 471              "is_manager": False,
 472              "parent_id": "1111111111",
 473          },
 474      ]
 475  
 476      mock_oauth_result = OAuthResult(
 477          refresh_token="1//test-refresh-token",
 478          access_token="ya29.test-access-token",
 479      )
 480  
 481      with (
 482          patch("mureo.auth_setup.input_func", side_effect=user_inputs),
 483          # 子アカウントAを選択
 484          patch("mureo.auth_setup._select_account", return_value="2222222222"),
 485          patch(
 486              "mureo.auth_setup.run_google_oauth",
 487              new_callable=AsyncMock,
 488              return_value=mock_oauth_result,
 489          ),
 490          patch(
 491              "mureo.auth_setup.list_accessible_accounts",
 492              new_callable=AsyncMock,
 493              return_value=mock_accounts,
 494          ),
 495      ):
 496          result = await setup_google_ads(credentials_path=cred_path)
 497  
 498      # login_customer_idは親MCCに設定されること
 499      assert result.login_customer_id == "1111111111"
 500      # customer_idは選択された子アカウントに設定されること
 501      assert result.customer_id == "2222222222"
 502  
 503      # credentials.jsonにも両方保存されていること
 504      data = json.loads(cred_path.read_text(encoding="utf-8"))
 505      assert data["google_ads"]["login_customer_id"] == "1111111111"
 506      assert data["google_ads"]["customer_id"] == "2222222222"
 507  
 508  
 509  # ---------------------------------------------------------------------------
 510  # 9. OAuthフロー全体(InstalledAppFlow)
 511  # ---------------------------------------------------------------------------
 512  
 513  
 514  @pytest.mark.unit
 515  @pytest.mark.asyncio
 516  async def test_run_google_oauth() -> None:
 517      """run_google_oauthがInstalledAppFlowでOAuth認証を実行すること"""
 518      from mureo.auth_setup import OAuthResult, run_google_oauth
 519  
 520      mock_credentials = MagicMock()
 521      mock_credentials.refresh_token = "1//mock-refresh"
 522      mock_credentials.token = "ya29.mock-access"
 523  
 524      mock_flow = MagicMock()
 525      mock_flow.run_local_server.return_value = mock_credentials
 526  
 527      with patch(
 528          "mureo.auth_setup.InstalledAppFlow.from_client_config",
 529          return_value=mock_flow,
 530      ) as mock_from_config:
 531          result = await run_google_oauth(
 532              client_id="test-cid",
 533              client_secret="test-csec",
 534          )
 535  
 536      assert isinstance(result, OAuthResult)
 537      assert result.refresh_token == "1//mock-refresh"
 538      assert result.access_token == "ya29.mock-access"
 539  
 540      # InstalledAppFlowが正しいclient_configで初期化されること
 541      mock_from_config.assert_called_once()
 542      call_args = mock_from_config.call_args
 543      client_config = call_args[0][0]
 544      assert client_config["installed"]["client_id"] == "test-cid"
 545      assert client_config["installed"]["client_secret"] == "test-csec"
 546      assert call_args[1]["scopes"] == [
 547          "https://www.googleapis.com/auth/adwords",
 548          "https://www.googleapis.com/auth/webmasters",
 549      ]
 550  
 551      # run_local_serverがport=0(自動選択), prompt="consent"で呼ばれること
 552      mock_flow.run_local_server.assert_called_once_with(port=0, prompt="consent")
 553  
 554  
 555  @pytest.mark.unit
 556  @pytest.mark.asyncio
 557  async def test_run_google_oauth_no_refresh_token() -> None:
 558      """refresh_tokenが取得できない場合にエラーが発生すること"""
 559      from mureo.auth_setup import run_google_oauth
 560  
 561      mock_credentials = MagicMock()
 562      mock_credentials.refresh_token = None
 563      mock_credentials.token = "ya29.mock-access"
 564  
 565      mock_flow = MagicMock()
 566      mock_flow.run_local_server.return_value = mock_credentials
 567  
 568      with patch(
 569          "mureo.auth_setup.InstalledAppFlow.from_client_config",
 570          return_value=mock_flow,
 571      ):
 572          with pytest.raises(RuntimeError, match="Failed to obtain refresh_token"):
 573              await run_google_oauth(
 574                  client_id="test-cid",
 575                  client_secret="test-csec",
 576              )
 577  
 578  
 579  # ---------------------------------------------------------------------------
 580  # 10. save_credentials でlogin_customer_idなし
 581  # ---------------------------------------------------------------------------
 582  
 583  
 584  @pytest.mark.unit
 585  def test_save_credentials_without_customer_id(tmp_path: Path) -> None:
 586      """customer_id未指定でもcredentials.jsonが正しく保存されること"""
 587      from mureo.auth_setup import save_credentials
 588  
 589      cred_path = tmp_path / "credentials.json"
 590  
 591      google_creds = GoogleAdsCredentials(
 592          developer_token="dev-tok",
 593          client_id="cid",
 594          client_secret="csec",
 595          refresh_token="rtok",
 596      )
 597  
 598      save_credentials(path=cred_path, google=google_creds)
 599  
 600      data = json.loads(cred_path.read_text(encoding="utf-8"))
 601      assert data["google_ads"]["developer_token"] == "dev-tok"
 602      assert data["google_ads"].get("login_customer_id") is None
 603  
 604  
 605  # ---------------------------------------------------------------------------
 606  # 11. ファイルパーミッション(credentials.jsonは600)
 607  # ---------------------------------------------------------------------------
 608  
 609  
 610  @pytest.mark.unit
 611  def test_save_credentials_file_permissions(tmp_path: Path) -> None:
 612      """credentials.jsonが0600パーミッションで保存されること"""
 613      import stat
 614  
 615      from mureo.auth_setup import save_credentials
 616  
 617      cred_path = tmp_path / "credentials.json"
 618  
 619      google_creds = GoogleAdsCredentials(
 620          developer_token="dev-tok",
 621          client_id="cid",
 622          client_secret="csec",
 623          refresh_token="rtok",
 624      )
 625  
 626      save_credentials(path=cred_path, google=google_creds)
 627  
 628      file_mode = cred_path.stat().st_mode
 629      # owner read/write のみ
 630      assert stat.S_IMODE(file_mode) == 0o600
 631  
 632  
 633  # ---------------------------------------------------------------------------
 634  # 12. OAuth stateパラメータ(CSRF対策)— Meta Ads用コールバックサーバー
 635  # ---------------------------------------------------------------------------
 636  
 637  
 638  @pytest.mark.unit
 639  def test_oauth_callback_server_validates_state() -> None:
 640      """コールバックサーバーがstateパラメータを正しく検証すること"""
 641      from mureo.auth_setup import OAuthCallbackServer
 642  
 643      server = OAuthCallbackServer(port=0, expected_state="correct-state")
 644      actual_port = server.server.server_address[1]
 645  
 646      server_thread = threading.Thread(target=server.wait_for_callback, daemon=True)
 647      server_thread.start()
 648  
 649      time.sleep(0.1)
 650      conn = http.client.HTTPConnection("localhost", actual_port)
 651      conn.request("GET", "/callback?code=test-code&state=correct-state")
 652      response = conn.getresponse()
 653      conn.close()
 654  
 655      assert response.status == 200
 656      assert server.authorization_code == "test-code"
 657  
 658  
 659  @pytest.mark.unit
 660  def test_oauth_state_mismatch() -> None:
 661      """stateパラメータが不一致の場合エラーになること"""
 662      from mureo.auth_setup import OAuthCallbackServer
 663  
 664      server = OAuthCallbackServer(port=0, expected_state="correct-state")
 665      actual_port = server.server.server_address[1]
 666  
 667      server_thread = threading.Thread(target=server.wait_for_callback, daemon=True)
 668      server_thread.start()
 669  
 670      time.sleep(0.1)
 671      conn = http.client.HTTPConnection("localhost", actual_port)
 672      conn.request("GET", "/callback?code=test-code&state=wrong-state")
 673      response = conn.getresponse()
 674      conn.close()
 675  
 676      assert response.status == 403
 677      assert server.authorization_code is None
 678      assert server.error is not None
 679      assert "state" in server.error.lower()
 680  
 681  
 682  @pytest.mark.unit
 683  def test_oauth_state_missing_in_callback() -> None:
 684      """コールバックにstateパラメータが無い場合エラーになること"""
 685      from mureo.auth_setup import OAuthCallbackServer
 686  
 687      server = OAuthCallbackServer(port=0, expected_state="expected-state")
 688      actual_port = server.server.server_address[1]
 689  
 690      server_thread = threading.Thread(target=server.wait_for_callback, daemon=True)
 691      server_thread.start()
 692  
 693      time.sleep(0.1)
 694      conn = http.client.HTTPConnection("localhost", actual_port)
 695      conn.request("GET", "/callback?code=test-code")
 696      response = conn.getresponse()
 697      conn.close()
 698  
 699      assert response.status == 403
 700      assert server.authorization_code is None
 701  
 702  
 703  # ---------------------------------------------------------------------------
 704  # 13. XSS防止(HTMLエスケープ)
 705  # ---------------------------------------------------------------------------
 706  
 707  
 708  @pytest.mark.unit
 709  def test_xss_prevention() -> None:
 710      """コールバックサーバーのHTML出力がエスケープされていること"""
 711      from mureo.auth_setup import OAuthCallbackServer
 712  
 713      server = OAuthCallbackServer(port=0)
 714      actual_port = server.server.server_address[1]
 715  
 716      server_thread = threading.Thread(target=server.wait_for_callback, daemon=True)
 717      server_thread.start()
 718  
 719      time.sleep(0.1)
 720      conn = http.client.HTTPConnection("localhost", actual_port)
 721      # XSS攻撃を模したerrorパラメータ
 722      conn.request(
 723          "GET",
 724          "/callback?error=%3Cscript%3Ealert(1)%3C/script%3E",
 725      )
 726      response = conn.getresponse()
 727      body = response.read().decode("utf-8")
 728      conn.close()
 729  
 730      # <script>タグがエスケープされていること
 731      assert "<script>" not in body
 732      assert "&lt;script&gt;" in body
 733  
 734  
 735  # ---------------------------------------------------------------------------
 736  # 14. InstalledAppFlowがrun_local_serverで例外を投げた場合
 737  # ---------------------------------------------------------------------------
 738  
 739  
 740  @pytest.mark.unit
 741  @pytest.mark.asyncio
 742  async def test_run_google_oauth_flow_exception() -> None:
 743      """InstalledAppFlow.run_local_server()が例外を投げた場合にそのまま伝播すること"""
 744      from mureo.auth_setup import run_google_oauth
 745  
 746      mock_flow = MagicMock()
 747      mock_flow.run_local_server.side_effect = Exception("Cannot open browser")
 748  
 749      with patch(
 750          "mureo.auth_setup.InstalledAppFlow.from_client_config",
 751          return_value=mock_flow,
 752      ):
 753          with pytest.raises(Exception, match="Cannot open browser"):
 754              await run_google_oauth(
 755                  client_id="test-cid",
 756                  client_secret="test-csec",
 757              )
 758  
 759  
 760  # ---------------------------------------------------------------------------
 761  # MCP設定の配置テスト
 762  # ---------------------------------------------------------------------------
 763  
 764  
 765  @pytest.mark.unit
 766  def test_install_mcp_config_global(tmp_path: Path) -> None:
 767      """グローバルMCP設定が正しく作成されること"""
 768      from mureo.auth_setup import install_mcp_config
 769  
 770      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 771          result = install_mcp_config(scope="global")
 772  
 773      assert result is not None
 774      settings = json.loads(result.read_text(encoding="utf-8"))
 775      assert "mureo" in settings["mcpServers"]
 776      assert settings["mcpServers"]["mureo"]["command"] == "python"
 777      assert settings["mcpServers"]["mureo"]["args"] == ["-m", "mureo.mcp"]
 778  
 779  
 780  @pytest.mark.unit
 781  def test_install_mcp_config_project(tmp_path: Path) -> None:
 782      """プロジェクトMCP設定が正しく作成されること"""
 783      from mureo.auth_setup import install_mcp_config
 784  
 785      with patch("mureo.auth_setup.Path.cwd", return_value=tmp_path):
 786          result = install_mcp_config(scope="project")
 787  
 788      assert result is not None
 789      assert result.name == ".mcp.json"
 790      settings = json.loads(result.read_text(encoding="utf-8"))
 791      assert "mureo" in settings["mcpServers"]
 792  
 793  
 794  @pytest.mark.unit
 795  def test_install_mcp_config_already_exists(tmp_path: Path) -> None:
 796      """既にmureoが設定済みならスキップすること"""
 797      from mureo.auth_setup import install_mcp_config
 798  
 799      # 既存設定を作成
 800      claude_dir = tmp_path / ".claude"
 801      claude_dir.mkdir()
 802      settings_path = claude_dir / "settings.json"
 803      settings_path.write_text(
 804          json.dumps(
 805              {
 806                  "mcpServers": {
 807                      "mureo": {"command": "python", "args": ["-m", "mureo.mcp"]}
 808                  }
 809              }
 810          )
 811      )
 812  
 813      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 814          result = install_mcp_config(scope="global")
 815  
 816      assert result is None  # スキップ
 817  
 818  
 819  @pytest.mark.unit
 820  def test_install_mcp_config_merges_existing(tmp_path: Path) -> None:
 821      """既存のsettings.jsonにマージされること"""
 822      from mureo.auth_setup import install_mcp_config
 823  
 824      # 他のMCPサーバーが既に設定済み
 825      claude_dir = tmp_path / ".claude"
 826      claude_dir.mkdir()
 827      settings_path = claude_dir / "settings.json"
 828      settings_path.write_text(
 829          json.dumps(
 830              {"mcpServers": {"other-tool": {"command": "node", "args": ["server.js"]}}}
 831          )
 832      )
 833  
 834      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 835          result = install_mcp_config(scope="global")
 836  
 837      assert result is not None
 838      settings = json.loads(result.read_text(encoding="utf-8"))
 839      assert "other-tool" in settings["mcpServers"]  # 既存は維持
 840      assert "mureo" in settings["mcpServers"]  # mureoが追加
 841  
 842  
 843  # ---------------------------------------------------------------------------
 844  # install_credential_guard テスト
 845  # ---------------------------------------------------------------------------
 846  
 847  
 848  @pytest.mark.unit
 849  def test_install_credential_guard_new(tmp_path: Path) -> None:
 850      """Credential guard hooks are added to empty settings."""
 851      from mureo.auth_setup import install_credential_guard
 852  
 853      claude_dir = tmp_path / ".claude"
 854      claude_dir.mkdir()
 855      settings_path = claude_dir / "settings.json"
 856      settings_path.write_text("{}")
 857  
 858      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 859          result = install_credential_guard()
 860  
 861      assert result is not None
 862      settings = json.loads(result.read_text(encoding="utf-8"))
 863      pre_tool_use = settings["hooks"]["PreToolUse"]
 864      assert len(pre_tool_use) == 2
 865      assert pre_tool_use[0]["matcher"] == "Read"
 866      assert pre_tool_use[1]["matcher"] == "Bash"
 867      assert "[mureo-credential-guard]" in pre_tool_use[0]["hooks"][0]["command"]
 868  
 869  
 870  @pytest.mark.unit
 871  def test_install_credential_guard_preserves_existing_hooks(tmp_path: Path) -> None:
 872      """Existing hooks are preserved when adding credential guard."""
 873      from mureo.auth_setup import install_credential_guard
 874  
 875      claude_dir = tmp_path / ".claude"
 876      claude_dir.mkdir()
 877      settings_path = claude_dir / "settings.json"
 878      existing = {
 879          "hooks": {
 880              "Stop": [
 881                  {"matcher": "", "hooks": [{"type": "command", "command": "echo done"}]}
 882              ],
 883              "PreToolUse": [
 884                  {
 885                      "matcher": "Write",
 886                      "hooks": [{"type": "command", "command": "echo check"}],
 887                  }
 888              ],
 889          },
 890          "someOtherSetting": True,
 891      }
 892      settings_path.write_text(json.dumps(existing))
 893  
 894      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 895          result = install_credential_guard()
 896  
 897      assert result is not None
 898      settings = json.loads(result.read_text(encoding="utf-8"))
 899      # Existing hooks preserved
 900      assert "Stop" in settings["hooks"]
 901      assert settings["someOtherSetting"] is True
 902      # Existing PreToolUse hook preserved + 2 mureo hooks appended
 903      pre_tool_use = settings["hooks"]["PreToolUse"]
 904      assert len(pre_tool_use) == 3
 905      assert pre_tool_use[0]["matcher"] == "Write"  # original
 906      assert pre_tool_use[1]["matcher"] == "Read"  # mureo
 907      assert pre_tool_use[2]["matcher"] == "Bash"  # mureo
 908  
 909  
 910  @pytest.mark.unit
 911  def test_install_credential_guard_skip_if_already_installed(tmp_path: Path) -> None:
 912      """Skip if mureo credential guard is already installed."""
 913      from mureo.auth_setup import install_credential_guard
 914  
 915      claude_dir = tmp_path / ".claude"
 916      claude_dir.mkdir()
 917      settings_path = claude_dir / "settings.json"
 918  
 919      # Install once
 920      settings_path.write_text("{}")
 921      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 922          install_credential_guard()
 923  
 924      # Install again — should skip
 925      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 926          result = install_credential_guard()
 927  
 928      assert result is None
 929      settings = json.loads(settings_path.read_text(encoding="utf-8"))
 930      # Still only 2 mureo hooks (no duplicates)
 931      assert len(settings["hooks"]["PreToolUse"]) == 2
 932  
 933  
 934  @pytest.mark.unit
 935  def test_install_credential_guard_skip_on_corrupt_json(tmp_path: Path) -> None:
 936      """Skip gracefully if settings.json is corrupt."""
 937      from mureo.auth_setup import install_credential_guard
 938  
 939      claude_dir = tmp_path / ".claude"
 940      claude_dir.mkdir()
 941      settings_path = claude_dir / "settings.json"
 942      settings_path.write_text("{invalid json")
 943  
 944      with patch("mureo.auth_setup.Path.home", return_value=tmp_path):
 945          result = install_credential_guard()
 946  
 947      assert result is None  # skipped, not crashed
 948      # File is untouched
 949      assert settings_path.read_text() == "{invalid json"
 950  
 951  
 952  # ---------------------------------------------------------------------------
 953  # _select_account テスト
 954  # ---------------------------------------------------------------------------
 955  
 956  
 957  @pytest.mark.unit
 958  def test_select_account_fallback_valid_choice() -> None:
 959      """simple-term-menuなしで番号入力による選択(行62-76)"""
 960      from mureo.auth_setup import _select_account
 961  
 962      accounts = [
 963          {"id": "111", "name": "Account A"},
 964          {"id": "222", "name": "Account B"},
 965      ]
 966  
 967      with patch("simple_term_menu.TerminalMenu", side_effect=ImportError):
 968          with patch("builtins.input", return_value="2"):
 969              result = _select_account(accounts)
 970  
 971      assert result == "222"
 972  
 973  
 974  @pytest.mark.unit
 975  def test_select_account_fallback_invalid_choice() -> None:
 976      """simple-term-menuなしで無効な番号入力(行75-76)"""
 977      from mureo.auth_setup import _select_account
 978  
 979      accounts = [
 980          {"id": "111", "name": "Account A"},
 981      ]
 982  
 983      with patch("simple_term_menu.TerminalMenu", side_effect=ImportError):
 984          with patch("builtins.input", return_value="999"):
 985              result = _select_account(accounts)
 986  
 987      assert result is None
 988  
 989  
 990  @pytest.mark.unit
 991  def test_select_account_fallback_non_numeric() -> None:
 992      """simple-term-menuなしで数値以外の入力(行73)"""
 993      from mureo.auth_setup import _select_account
 994  
 995      accounts = [
 996          {"id": "111", "name": "Account A"},
 997      ]
 998  
 999      with patch("simple_term_menu.TerminalMenu", side_effect=ImportError):
1000          with patch("builtins.input", return_value="abc"):
1001              result = _select_account(accounts)
1002  
1003      assert result is None
1004  
1005  
1006  @pytest.mark.unit
1007  def test_select_account_terminal_menu_cancel() -> None:
1008      """TerminalMenuでキャンセル(Noneが返る)(行56-58)"""
1009      from mureo.auth_setup import _select_account
1010  
1011      accounts = [
1012          {"id": "111", "name": "Account A"},
1013      ]
1014  
1015      mock_menu = MagicMock()
1016      mock_menu.show.return_value = None  # キャンセル
1017  
1018      with patch("simple_term_menu.TerminalMenu", return_value=mock_menu):
1019          result = _select_account(accounts)
1020  
1021      assert result is None
1022  
1023  
1024  @pytest.mark.unit
1025  def test_select_account_with_custom_label_fn() -> None:
1026      """label_fn指定時にカスタムラベルが使われる(行46-47)"""
1027      from mureo.auth_setup import _select_account
1028  
1029      accounts = [
1030          {"id": "111", "name": "Account A"},
1031      ]
1032  
1033      mock_menu = MagicMock()
1034      mock_menu.show.return_value = 0
1035  
1036      with patch("simple_term_menu.TerminalMenu", return_value=mock_menu) as MockTM:
1037          result = _select_account(accounts, label_fn=lambda a: f"Custom: {a['name']}")
1038  
1039      assert result == "111"
1040      # TerminalMenuに渡されたラベルを確認
1041      call_args = MockTM.call_args
1042      assert call_args[0][0] == ["Custom: Account A"]
1043  
1044  
1045  # ---------------------------------------------------------------------------
1046  # setup_mcp_config テスト
1047  # ---------------------------------------------------------------------------
1048  
1049  
1050  @pytest.mark.unit
1051  def test_setup_mcp_config_fallback_global() -> None:
1052      """simple-term-menuなしで番号入力でグローバル選択(行388-398)"""
1053      from mureo.auth_setup import setup_mcp_config
1054  
1055      with (
1056          patch("simple_term_menu.TerminalMenu", side_effect=ImportError),
1057          patch("mureo.auth_setup.input_func", return_value="1"),
1058          patch(
1059              "mureo.auth_setup.install_mcp_config", return_value=Path("/tmp/test")
1060          ) as mock_install,
1061      ):
1062          setup_mcp_config()
1063  
1064      mock_install.assert_called_once_with(scope="global")
1065  
1066  
1067  @pytest.mark.unit
1068  def test_setup_mcp_config_fallback_project() -> None:
1069      """simple-term-menuなしで番号入力でプロジェクト選択"""
1070      from mureo.auth_setup import setup_mcp_config
1071  
1072      with (
1073          patch("simple_term_menu.TerminalMenu", side_effect=ImportError),
1074          patch("mureo.auth_setup.input_func", return_value="2"),
1075          patch(
1076              "mureo.auth_setup.install_mcp_config", return_value=Path("/tmp/test")
1077          ) as mock_install,
1078      ):
1079          setup_mcp_config()
1080  
1081      mock_install.assert_called_once_with(scope="project")
1082  
1083  
1084  @pytest.mark.unit
1085  def test_setup_mcp_config_fallback_skip() -> None:
1086      """simple-term-menuなしで番号入力でスキップ(行395-396)"""
1087      from mureo.auth_setup import setup_mcp_config
1088  
1089      with (
1090          patch("simple_term_menu.TerminalMenu", side_effect=ImportError),
1091          patch("mureo.auth_setup.input_func", return_value="3"),
1092          patch("mureo.auth_setup.install_mcp_config") as mock_install,
1093      ):
1094          setup_mcp_config()
1095  
1096      mock_install.assert_not_called()
1097  
1098  
1099  @pytest.mark.unit
1100  def test_setup_mcp_config_fallback_default() -> None:
1101      """simple-term-menuなしで空入力時はデフォルト(グローバル)(行394)"""
1102      from mureo.auth_setup import setup_mcp_config
1103  
1104      with (
1105          patch("simple_term_menu.TerminalMenu", side_effect=ImportError),
1106          patch("mureo.auth_setup.input_func", return_value=""),
1107          patch(
1108              "mureo.auth_setup.install_mcp_config", return_value=Path("/tmp/test")
1109          ) as mock_install,
1110      ):
1111          setup_mcp_config()
1112  
1113      mock_install.assert_called_once_with(scope="global")
1114  
1115  
1116  @pytest.mark.unit
1117  def test_setup_mcp_config_terminal_menu_skip() -> None:
1118      """TerminalMenuでスキップ選択(index=2)(行383)"""
1119      from mureo.auth_setup import setup_mcp_config
1120  
1121      mock_menu = MagicMock()
1122      mock_menu.show.return_value = 2  # スキップ
1123  
1124      with (
1125          patch("simple_term_menu.TerminalMenu", return_value=mock_menu),
1126          patch("mureo.auth_setup.install_mcp_config") as mock_install,
1127      ):
1128          setup_mcp_config()
1129  
1130      mock_install.assert_not_called()
1131  
1132  
1133  @pytest.mark.unit
1134  def test_setup_mcp_config_terminal_menu_cancel() -> None:
1135      """TerminalMenuでキャンセル(None)"""
1136      from mureo.auth_setup import setup_mcp_config
1137  
1138      mock_menu = MagicMock()
1139      mock_menu.show.return_value = None
1140  
1141      with (
1142          patch("simple_term_menu.TerminalMenu", return_value=mock_menu),
1143          patch("mureo.auth_setup.install_mcp_config") as mock_install,
1144      ):
1145          setup_mcp_config()
1146  
1147      mock_install.assert_not_called()
1148  
1149  
1150  @pytest.mark.unit
1151  def test_setup_mcp_config_already_exists() -> None:
1152      """MCP設定が既に存在する場合(行403-404)"""
1153      from mureo.auth_setup import setup_mcp_config
1154  
1155      mock_menu = MagicMock()
1156      mock_menu.show.return_value = 0  # グローバル
1157  
1158      with (
1159          patch("simple_term_menu.TerminalMenu", return_value=mock_menu),
1160          patch("mureo.auth_setup.install_mcp_config", return_value=None) as mock_install,
1161      ):
1162          setup_mcp_config()
1163  
1164      mock_install.assert_called_once()
1165  
1166  
1167  # ---------------------------------------------------------------------------
1168  # OAuthCallbackServer 追加テスト
1169  # ---------------------------------------------------------------------------
1170  
1171  
1172  @pytest.mark.unit
1173  def test_oauth_callback_server_invalid_request() -> None:
1174      """不正なリクエスト(codeもerrorもない)の場合"""
1175      from mureo.auth_setup import OAuthCallbackServer
1176  
1177      server = OAuthCallbackServer(port=0)
1178      actual_port = server.server.server_address[1]
1179  
1180      server_thread = threading.Thread(target=server.wait_for_callback, daemon=True)
1181      server_thread.start()
1182  
1183      time.sleep(0.1)
1184      conn = http.client.HTTPConnection("localhost", actual_port)
1185      conn.request("GET", "/callback?unexpected=value")
1186      response = conn.getresponse()
1187      conn.close()
1188  
1189      assert response.status == 400
1190      assert server.authorization_code is None
1191      assert server.error is None
1192  
1193  
1194  @pytest.mark.unit
1195  def test_oauth_callback_server_shutdown() -> None:
1196      """shutdownメソッドが正常に動作する"""
1197      from mureo.auth_setup import OAuthCallbackServer
1198  
1199      server = OAuthCallbackServer(port=0)
1200      server.shutdown()  # 例外なく完了すること
1201  
1202  
1203  # ---------------------------------------------------------------------------
1204  # save_credentials Meta Ads テスト
1205  # ---------------------------------------------------------------------------
1206  
1207  
1208  @pytest.mark.unit
1209  def test_save_credentials_meta_ads(tmp_path: Path) -> None:
1210      """Meta Ads認証情報が正しく保存されること"""
1211      from mureo.auth import MetaAdsCredentials
1212      from mureo.auth_setup import save_credentials
1213  
1214      cred_path = tmp_path / "credentials.json"
1215  
1216      meta_creds = MetaAdsCredentials(
1217          access_token="meta-access-tok",
1218          app_id="app-123",
1219          app_secret="app-secret-456",
1220      )
1221  
1222      save_credentials(
1223          path=cred_path,
1224          meta=meta_creds,
1225          account_id="act_789",
1226      )
1227  
1228      data = json.loads(cred_path.read_text(encoding="utf-8"))
1229      assert data["meta_ads"]["access_token"] == "meta-access-tok"
1230      assert data["meta_ads"]["app_id"] == "app-123"
1231      assert data["meta_ads"]["app_secret"] == "app-secret-456"
1232      assert data["meta_ads"]["account_id"] == "act_789"
1233  
1234  
1235  @pytest.mark.unit
1236  def test_save_credentials_corrupted_existing(tmp_path: Path) -> None:
1237      """既存のcredentials.jsonが壊れている場合でも新規作成される"""
1238      from mureo.auth_setup import save_credentials
1239  
1240      cred_path = tmp_path / "credentials.json"
1241      cred_path.write_text("not valid json", encoding="utf-8")
1242  
1243      google_creds = GoogleAdsCredentials(
1244          developer_token="dev-tok",
1245          client_id="cid",
1246          client_secret="csec",
1247          refresh_token="rtok",
1248      )
1249  
1250      save_credentials(path=cred_path, google=google_creds)
1251  
1252      data = json.loads(cred_path.read_text(encoding="utf-8"))
1253      assert data["google_ads"]["developer_token"] == "dev-tok"
1254  
1255  
1256  # ---------------------------------------------------------------------------
1257  # Meta Ads OAuth テスト
1258  # ---------------------------------------------------------------------------
1259  
1260  
1261  @pytest.mark.unit
1262  def test_generate_meta_auth_url() -> None:
1263      """Meta OAuth認証URLが正しく生成されること"""
1264      from mureo.auth_setup import _generate_meta_auth_url
1265  
1266      url = _generate_meta_auth_url(app_id="test-app-id", port=8888, state="abc123")
1267  
1268      assert "test-app-id" in url
1269      assert "localhost%3A8888" in url or "localhost:8888" in url
1270      assert "state=abc123" in url
1271      assert "ads_management" in url
1272  
1273  
1274  @pytest.mark.unit
1275  def test_generate_meta_auth_url_no_state() -> None:
1276      """state=Noneの場合はstateパラメータが含まれない"""
1277      from mureo.auth_setup import _generate_meta_auth_url
1278  
1279      url = _generate_meta_auth_url(app_id="test-app-id", port=8888, state=None)
1280  
1281      assert "state=" not in url
1282  
1283  
1284  @pytest.mark.unit
1285  @pytest.mark.asyncio
1286  async def test_exchange_code_for_short_token() -> None:
1287      """Short-Lived Tokenの取得"""
1288      from mureo.auth_setup import _exchange_code_for_short_token
1289  
1290      mock_response = MagicMock()
1291      mock_response.json.return_value = {"access_token": "short-lived-tok"}
1292      mock_response.raise_for_status = MagicMock()
1293  
1294      with patch("httpx.AsyncClient") as MockClient:
1295          instance = AsyncMock()
1296          instance.get = AsyncMock(return_value=mock_response)
1297          instance.__aenter__ = AsyncMock(return_value=instance)
1298          instance.__aexit__ = AsyncMock(return_value=False)
1299          MockClient.return_value = instance
1300  
1301          result = await _exchange_code_for_short_token(
1302              code="test-code",
1303              app_id="test-app",
1304              app_secret="test-secret",
1305              redirect_uri="http://localhost:8888/callback",
1306          )
1307  
1308      assert result == "short-lived-tok"
1309  
1310  
1311  @pytest.mark.unit
1312  @pytest.mark.asyncio
1313  async def test_exchange_code_for_short_token_error() -> None:
1314      """Short-Lived Token取得失敗時にRuntimeError"""
1315      from mureo.auth_setup import _exchange_code_for_short_token
1316  
1317      with patch("httpx.AsyncClient") as MockClient:
1318          instance = AsyncMock()
1319          instance.get = AsyncMock(side_effect=RuntimeError("connection error"))
1320          instance.__aenter__ = AsyncMock(return_value=instance)
1321          instance.__aexit__ = AsyncMock(return_value=False)
1322          MockClient.return_value = instance
1323  
1324          with pytest.raises(RuntimeError, match="Short-Lived Token"):
1325              await _exchange_code_for_short_token(
1326                  code="test-code",
1327                  app_id="test-app",
1328                  app_secret="test-secret",
1329                  redirect_uri="http://localhost:8888/callback",
1330              )
1331  
1332  
1333  @pytest.mark.unit
1334  @pytest.mark.asyncio
1335  async def test_exchange_short_for_long_token() -> None:
1336      """Long-Lived Tokenへの変換"""
1337      from mureo.auth_setup import MetaOAuthResult, _exchange_short_for_long_token
1338  
1339      mock_response = MagicMock()
1340      mock_response.json.return_value = {
1341          "access_token": "long-lived-tok",
1342          "expires_in": 5184000,
1343      }
1344      mock_response.raise_for_status = MagicMock()
1345  
1346      with patch("httpx.AsyncClient") as MockClient:
1347          instance = AsyncMock()
1348          instance.get = AsyncMock(return_value=mock_response)
1349          instance.__aenter__ = AsyncMock(return_value=instance)
1350          instance.__aexit__ = AsyncMock(return_value=False)
1351          MockClient.return_value = instance
1352  
1353          result = await _exchange_short_for_long_token(
1354              short_token="short-tok",
1355              app_id="test-app",
1356              app_secret="test-secret",
1357          )
1358  
1359      assert isinstance(result, MetaOAuthResult)
1360      assert result.access_token == "long-lived-tok"
1361      assert result.expires_in == 5184000
1362  
1363  
1364  @pytest.mark.unit
1365  @pytest.mark.asyncio
1366  async def test_exchange_short_for_long_token_error() -> None:
1367      """Long-Lived Token変換失敗時にRuntimeError"""
1368      from mureo.auth_setup import _exchange_short_for_long_token
1369  
1370      with patch("httpx.AsyncClient") as MockClient:
1371          instance = AsyncMock()
1372          instance.get = AsyncMock(side_effect=RuntimeError("api error"))
1373          instance.__aenter__ = AsyncMock(return_value=instance)
1374          instance.__aexit__ = AsyncMock(return_value=False)
1375          MockClient.return_value = instance
1376  
1377          with pytest.raises(RuntimeError, match="Long-Lived Token"):
1378              await _exchange_short_for_long_token(
1379                  short_token="short-tok",
1380                  app_id="test-app",
1381                  app_secret="test-secret",
1382              )
1383  
1384  
1385  @pytest.mark.unit
1386  @pytest.mark.asyncio
1387  async def test_list_meta_ad_accounts() -> None:
1388      """Meta広告アカウント一覧取得"""
1389      from mureo.auth_setup import list_meta_ad_accounts
1390  
1391      mock_response = MagicMock()
1392      mock_response.json.return_value = {
1393          "data": [
1394              {"id": "act_111", "name": "Test Account", "account_status": 1},
1395          ]
1396      }
1397      mock_response.raise_for_status = MagicMock()
1398  
1399      with patch("httpx.AsyncClient") as MockClient:
1400          instance = AsyncMock()
1401          instance.get = AsyncMock(return_value=mock_response)
1402          instance.__aenter__ = AsyncMock(return_value=instance)
1403          instance.__aexit__ = AsyncMock(return_value=False)
1404          MockClient.return_value = instance
1405  
1406          accounts = await list_meta_ad_accounts("test-token")
1407  
1408      assert len(accounts) == 1
1409      assert accounts[0]["id"] == "act_111"
1410  
1411  
1412  @pytest.mark.unit
1413  @pytest.mark.asyncio
1414  async def test_list_meta_ad_accounts_error() -> None:
1415      """Meta広告アカウント一覧取得失敗時にRuntimeError"""
1416      from mureo.auth_setup import list_meta_ad_accounts
1417  
1418      with patch("httpx.AsyncClient") as MockClient:
1419          instance = AsyncMock()
1420          instance.get = AsyncMock(side_effect=RuntimeError("api error"))
1421          instance.__aenter__ = AsyncMock(return_value=instance)
1422          instance.__aexit__ = AsyncMock(return_value=False)
1423          MockClient.return_value = instance
1424  
1425          with pytest.raises(RuntimeError, match="Failed to retrieve ad account list"):
1426              await list_meta_ad_accounts("test-token")
1427  
1428  
1429  @pytest.mark.unit
1430  @pytest.mark.asyncio
1431  async def test_run_meta_oauth() -> None:
1432      """Meta OAuthフロー全体"""
1433      from mureo.auth_setup import MetaOAuthResult, run_meta_oauth
1434  
1435      with (
1436          patch("mureo.auth_setup.OAuthCallbackServer") as MockServer,
1437          patch("mureo.auth_setup.webbrowser.open"),
1438          patch(
1439              "mureo.auth_setup._exchange_code_for_short_token",
1440              new_callable=AsyncMock,
1441              return_value="short-token",
1442          ),
1443          patch(
1444              "mureo.auth_setup._exchange_short_for_long_token",
1445              new_callable=AsyncMock,
1446              return_value=MetaOAuthResult(
1447                  access_token="long-lived-tok", expires_in=5184000
1448              ),
1449          ),
1450      ):
1451          server_instance = MockServer.return_value
1452          server_instance.server.server_address = ("localhost", 9999)
1453          server_instance.error = None
1454          server_instance.authorization_code = "test-auth-code"
1455          # wait_for_callbackは即座に返る
1456          server_instance.wait_for_callback = MagicMock()
1457  
1458          result = await run_meta_oauth(
1459              app_id="test-app-id",
1460              app_secret="test-app-secret",
1461          )
1462  
1463      assert isinstance(result, MetaOAuthResult)
1464      assert result.access_token == "long-lived-tok"
1465  
1466  
1467  @pytest.mark.unit
1468  @pytest.mark.asyncio
1469  async def test_run_meta_oauth_error() -> None:
1470      """Meta OAuthフローでエラーが返された場合"""
1471      from mureo.auth_setup import run_meta_oauth
1472  
1473      with (
1474          patch("mureo.auth_setup.OAuthCallbackServer") as MockServer,
1475          patch("mureo.auth_setup.webbrowser.open"),
1476      ):
1477          server_instance = MockServer.return_value
1478          server_instance.server.server_address = ("localhost", 9999)
1479          server_instance.error = "access_denied"
1480          server_instance.authorization_code = None
1481          server_instance.wait_for_callback = MagicMock()
1482  
1483          with pytest.raises(RuntimeError, match="access_denied"):
1484              await run_meta_oauth(
1485                  app_id="test-app-id",
1486                  app_secret="test-app-secret",
1487              )
1488  
1489  
1490  @pytest.mark.unit
1491  @pytest.mark.asyncio
1492  async def test_run_meta_oauth_timeout() -> None:
1493      """Meta OAuthフローでタイムアウトした場合"""
1494      from mureo.auth_setup import run_meta_oauth
1495  
1496      with (
1497          patch("mureo.auth_setup.OAuthCallbackServer") as MockServer,
1498          patch("mureo.auth_setup.webbrowser.open"),
1499      ):
1500          server_instance = MockServer.return_value
1501          server_instance.server.server_address = ("localhost", 9999)
1502          server_instance.error = None
1503          server_instance.authorization_code = None  # タイムアウト
1504          server_instance.wait_for_callback = MagicMock()
1505  
1506          with pytest.raises(RuntimeError, match="Authentication timed out"):
1507              await run_meta_oauth(
1508                  app_id="test-app-id",
1509                  app_secret="test-app-secret",
1510              )
1511  
1512  
1513  @pytest.mark.unit
1514  @pytest.mark.asyncio
1515  async def test_setup_meta_ads_flow(tmp_path: Path) -> None:
1516      """Meta Adsセットアップの全体フロー"""
1517      from mureo.auth_setup import MetaOAuthResult, setup_meta_ads
1518  
1519      cred_path = tmp_path / "credentials.json"
1520  
1521      user_inputs = iter(["test-app-id", "test-app-secret"])
1522  
1523      mock_oauth_result = MetaOAuthResult(
1524          access_token="long-lived-token",
1525          expires_in=5184000,
1526      )
1527  
1528      mock_accounts = [
1529          {"id": "act_111", "name": "Account 1", "account_status": 1},
1530          {"id": "act_222", "name": "Account 2", "account_status": 1},
1531      ]
1532  
1533      with (
1534          patch("mureo.auth_setup.input_func", side_effect=user_inputs),
1535          patch(
1536              "mureo.auth_setup.run_meta_oauth",
1537              new_callable=AsyncMock,
1538              return_value=mock_oauth_result,
1539          ),
1540          patch(
1541              "mureo.auth_setup.list_meta_ad_accounts",
1542              new_callable=AsyncMock,
1543              return_value=mock_accounts,
1544          ),
1545          patch("mureo.auth_setup._select_account", return_value="act_222"),
1546      ):
1547          result = await setup_meta_ads(credentials_path=cred_path)
1548  
1549      assert result.access_token == "long-lived-token"
1550      assert result.app_id == "test-app-id"
1551  
1552      data = json.loads(cred_path.read_text(encoding="utf-8"))
1553      assert data["meta_ads"]["access_token"] == "long-lived-token"
1554      assert data["meta_ads"]["account_id"] == "act_222"
1555  
1556  
1557  @pytest.mark.unit
1558  @pytest.mark.asyncio
1559  async def test_setup_meta_ads_single_account(tmp_path: Path) -> None:
1560      """1アカウントのみの場合は自動選択"""
1561      from mureo.auth_setup import MetaOAuthResult, setup_meta_ads
1562  
1563      cred_path = tmp_path / "credentials.json"
1564  
1565      user_inputs = iter(["test-app-id", "test-app-secret"])
1566  
1567      mock_oauth_result = MetaOAuthResult(
1568          access_token="long-lived-token",
1569          expires_in=5184000,
1570      )
1571  
1572      mock_accounts = [
1573          {"id": "act_111", "name": "Single Account", "account_status": 1},
1574      ]
1575  
1576      with (
1577          patch("mureo.auth_setup.input_func", side_effect=user_inputs),
1578          patch(
1579              "mureo.auth_setup.run_meta_oauth",
1580              new_callable=AsyncMock,
1581              return_value=mock_oauth_result,
1582          ),
1583          patch(
1584              "mureo.auth_setup.list_meta_ad_accounts",
1585              new_callable=AsyncMock,
1586              return_value=mock_accounts,
1587          ),
1588      ):
1589          result = await setup_meta_ads(credentials_path=cred_path)
1590  
1591      data = json.loads(cred_path.read_text(encoding="utf-8"))
1592      assert data["meta_ads"]["account_id"] == "act_111"
1593  
1594  
1595  @pytest.mark.unit
1596  @pytest.mark.asyncio
1597  async def test_setup_meta_ads_no_accounts(tmp_path: Path) -> None:
1598      """アカウントが見つからない場合はRuntimeError"""
1599      from mureo.auth_setup import MetaOAuthResult, setup_meta_ads
1600  
1601      cred_path = tmp_path / "credentials.json"
1602  
1603      user_inputs = iter(["test-app-id", "test-app-secret"])
1604  
1605      mock_oauth_result = MetaOAuthResult(
1606          access_token="long-lived-token",
1607          expires_in=5184000,
1608      )
1609  
1610      with (
1611          patch("mureo.auth_setup.input_func", side_effect=user_inputs),
1612          patch(
1613              "mureo.auth_setup.run_meta_oauth",
1614              new_callable=AsyncMock,
1615              return_value=mock_oauth_result,
1616          ),
1617          patch(
1618              "mureo.auth_setup.list_meta_ad_accounts",
1619              new_callable=AsyncMock,
1620              return_value=[],
1621          ),
1622      ):
1623          with pytest.raises(RuntimeError, match="No ad accounts found"):
1624              await setup_meta_ads(credentials_path=cred_path)
1625  
1626  
1627  @pytest.mark.unit
1628  @pytest.mark.asyncio
1629  async def test_setup_meta_ads_cancel_selection(tmp_path: Path) -> None:
1630      """アカウント選択をキャンセルした場合はデフォルトアカウントを使用"""
1631      from mureo.auth_setup import MetaOAuthResult, setup_meta_ads
1632  
1633      cred_path = tmp_path / "credentials.json"
1634  
1635      user_inputs = iter(["test-app-id", "test-app-secret"])
1636  
1637      mock_oauth_result = MetaOAuthResult(
1638          access_token="long-lived-token",
1639          expires_in=5184000,
1640      )
1641  
1642      mock_accounts = [
1643          {"id": "act_111", "name": "Account 1", "account_status": 1},
1644          {"id": "act_222", "name": "Account 2", "account_status": 1},
1645      ]
1646  
1647      with (
1648          patch("mureo.auth_setup.input_func", side_effect=user_inputs),
1649          patch(
1650              "mureo.auth_setup.run_meta_oauth",
1651              new_callable=AsyncMock,
1652              return_value=mock_oauth_result,
1653          ),
1654          patch(
1655              "mureo.auth_setup.list_meta_ad_accounts",
1656              new_callable=AsyncMock,
1657              return_value=mock_accounts,
1658          ),
1659          patch("mureo.auth_setup._select_account", return_value=None),
1660      ):
1661          result = await setup_meta_ads(credentials_path=cred_path)
1662  
1663      data = json.loads(cred_path.read_text(encoding="utf-8"))
1664      # キャンセル時は最初のアカウントがデフォルト
1665      assert data["meta_ads"]["account_id"] == "act_111"
1666  
1667  
1668  @pytest.mark.unit
1669  def test_resolve_default_path() -> None:
1670      """デフォルトパスの解決"""
1671      from mureo.auth_setup import _resolve_default_path
1672  
1673      path = _resolve_default_path()
1674      assert path.name == "credentials.json"
1675      assert ".mureo" in str(path)