test_auth.py
1 """認証情報の読み込みモジュールのテスト(TDD: RED → GREEN → IMPROVE)""" 2 3 from __future__ import annotations 4 5 import dataclasses 6 import json 7 from pathlib import Path 8 from unittest.mock import MagicMock, patch 9 10 import pytest 11 12 from mureo.auth import ( 13 GoogleAdsCredentials, 14 MetaAdsCredentials, 15 create_google_ads_client, 16 create_meta_ads_client, 17 load_credentials, 18 load_google_ads_credentials, 19 load_meta_ads_credentials, 20 ) 21 22 # --------------------------------------------------------------------------- 23 # フィクスチャ 24 # --------------------------------------------------------------------------- 25 26 SAMPLE_CREDENTIALS = { 27 "google_ads": { 28 "developer_token": "dev-token-123", 29 "client_id": "client-id.apps.googleusercontent.com", 30 "client_secret": "client-secret-456", 31 "refresh_token": "refresh-token-789", 32 "login_customer_id": "1234567890", 33 }, 34 "meta_ads": { 35 "access_token": "meta-access-token-abc", 36 "app_id": "meta-app-id-111", 37 "app_secret": "meta-app-secret-222", 38 }, 39 } 40 41 42 @pytest.fixture() 43 def credentials_file(tmp_path: Path) -> Path: 44 """一時ディレクトリにcredentials.jsonを作成する""" 45 cred_path = tmp_path / "credentials.json" 46 cred_path.write_text(json.dumps(SAMPLE_CREDENTIALS), encoding="utf-8") 47 return cred_path 48 49 50 @pytest.fixture() 51 def google_only_credentials_file(tmp_path: Path) -> Path: 52 """Google Adsのみのcredentials.json""" 53 cred_path = tmp_path / "credentials.json" 54 data = {"google_ads": SAMPLE_CREDENTIALS["google_ads"]} 55 cred_path.write_text(json.dumps(data), encoding="utf-8") 56 return cred_path 57 58 59 @pytest.fixture() 60 def meta_only_credentials_file(tmp_path: Path) -> Path: 61 """Meta Adsのみのcredentials.json""" 62 cred_path = tmp_path / "credentials.json" 63 data = {"meta_ads": SAMPLE_CREDENTIALS["meta_ads"]} 64 cred_path.write_text(json.dumps(data), encoding="utf-8") 65 return cred_path 66 67 68 # --------------------------------------------------------------------------- 69 # 1. ファイルからGoogle Ads認証情報を読み込む 70 # --------------------------------------------------------------------------- 71 72 73 @pytest.mark.unit 74 def test_load_google_ads_credentials_from_file(credentials_file: Path) -> None: 75 creds = load_google_ads_credentials(path=credentials_file) 76 77 assert creds is not None 78 assert isinstance(creds, GoogleAdsCredentials) 79 assert creds.developer_token == "dev-token-123" 80 assert creds.client_id == "client-id.apps.googleusercontent.com" 81 assert creds.client_secret == "client-secret-456" 82 assert creds.refresh_token == "refresh-token-789" 83 assert creds.login_customer_id == "1234567890" 84 85 86 # --------------------------------------------------------------------------- 87 # 2. ファイルからMeta Ads認証情報を読み込む 88 # --------------------------------------------------------------------------- 89 90 91 @pytest.mark.unit 92 def test_load_meta_ads_credentials_from_file(credentials_file: Path) -> None: 93 creds = load_meta_ads_credentials(path=credentials_file) 94 95 assert creds is not None 96 assert isinstance(creds, MetaAdsCredentials) 97 assert creds.access_token == "meta-access-token-abc" 98 assert creds.app_id == "meta-app-id-111" 99 assert creds.app_secret == "meta-app-secret-222" 100 101 102 # --------------------------------------------------------------------------- 103 # 3. ファイルが存在しない場合 → None 104 # --------------------------------------------------------------------------- 105 106 107 @pytest.mark.unit 108 def test_load_credentials_file_not_found(tmp_path: Path) -> None: 109 nonexistent = tmp_path / "nonexistent.json" 110 result = load_credentials(path=nonexistent) 111 assert result == {} 112 113 114 @pytest.mark.unit 115 def test_load_google_ads_credentials_file_not_found( 116 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 117 ) -> None: 118 """ファイルなし + 環境変数もなし → None""" 119 monkeypatch.delenv("GOOGLE_ADS_DEVELOPER_TOKEN", raising=False) 120 monkeypatch.delenv("GOOGLE_ADS_CLIENT_ID", raising=False) 121 monkeypatch.delenv("GOOGLE_ADS_CLIENT_SECRET", raising=False) 122 monkeypatch.delenv("GOOGLE_ADS_REFRESH_TOKEN", raising=False) 123 monkeypatch.delenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID", raising=False) 124 125 nonexistent = tmp_path / "nonexistent.json" 126 creds = load_google_ads_credentials(path=nonexistent) 127 assert creds is None 128 129 130 @pytest.mark.unit 131 def test_load_meta_ads_credentials_file_not_found( 132 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 133 ) -> None: 134 """ファイルなし + 環境変数もなし → None""" 135 monkeypatch.delenv("META_ADS_ACCESS_TOKEN", raising=False) 136 monkeypatch.delenv("META_ADS_APP_ID", raising=False) 137 monkeypatch.delenv("META_ADS_APP_SECRET", raising=False) 138 139 nonexistent = tmp_path / "nonexistent.json" 140 creds = load_meta_ads_credentials(path=nonexistent) 141 assert creds is None 142 143 144 # --------------------------------------------------------------------------- 145 # 4. 環境変数フォールバック — Google Ads 146 # --------------------------------------------------------------------------- 147 148 149 @pytest.mark.unit 150 def test_load_google_ads_credentials_from_env( 151 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 152 ) -> None: 153 nonexistent = tmp_path / "nonexistent.json" 154 monkeypatch.setenv("GOOGLE_ADS_DEVELOPER_TOKEN", "env-dev-token") 155 monkeypatch.setenv("GOOGLE_ADS_CLIENT_ID", "env-client-id") 156 monkeypatch.setenv("GOOGLE_ADS_CLIENT_SECRET", "env-client-secret") 157 monkeypatch.setenv("GOOGLE_ADS_REFRESH_TOKEN", "env-refresh-token") 158 monkeypatch.setenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "9999999999") 159 160 creds = load_google_ads_credentials(path=nonexistent) 161 162 assert creds is not None 163 assert creds.developer_token == "env-dev-token" 164 assert creds.client_id == "env-client-id" 165 assert creds.client_secret == "env-client-secret" 166 assert creds.refresh_token == "env-refresh-token" 167 assert creds.login_customer_id == "9999999999" 168 169 170 @pytest.mark.unit 171 def test_load_google_ads_credentials_from_env_without_login_customer_id( 172 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 173 ) -> None: 174 """login_customer_idは省略可能""" 175 nonexistent = tmp_path / "nonexistent.json" 176 monkeypatch.setenv("GOOGLE_ADS_DEVELOPER_TOKEN", "env-dev-token") 177 monkeypatch.setenv("GOOGLE_ADS_CLIENT_ID", "env-client-id") 178 monkeypatch.setenv("GOOGLE_ADS_CLIENT_SECRET", "env-client-secret") 179 monkeypatch.setenv("GOOGLE_ADS_REFRESH_TOKEN", "env-refresh-token") 180 monkeypatch.delenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID", raising=False) 181 182 creds = load_google_ads_credentials(path=nonexistent) 183 184 assert creds is not None 185 assert creds.login_customer_id is None 186 187 188 # --------------------------------------------------------------------------- 189 # 5. 環境変数フォールバック — Meta Ads 190 # --------------------------------------------------------------------------- 191 192 193 @pytest.mark.unit 194 def test_load_meta_ads_credentials_from_env( 195 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 196 ) -> None: 197 nonexistent = tmp_path / "nonexistent.json" 198 monkeypatch.setenv("META_ADS_ACCESS_TOKEN", "env-meta-token") 199 monkeypatch.setenv("META_ADS_APP_ID", "env-app-id") 200 monkeypatch.setenv("META_ADS_APP_SECRET", "env-app-secret") 201 202 creds = load_meta_ads_credentials(path=nonexistent) 203 204 assert creds is not None 205 assert creds.access_token == "env-meta-token" 206 assert creds.app_id == "env-app-id" 207 assert creds.app_secret == "env-app-secret" 208 209 210 @pytest.mark.unit 211 def test_load_meta_ads_credentials_from_env_minimal( 212 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 213 ) -> None: 214 """app_id/app_secretは省略可能""" 215 nonexistent = tmp_path / "nonexistent.json" 216 monkeypatch.setenv("META_ADS_ACCESS_TOKEN", "env-meta-token") 217 monkeypatch.delenv("META_ADS_APP_ID", raising=False) 218 monkeypatch.delenv("META_ADS_APP_SECRET", raising=False) 219 220 creds = load_meta_ads_credentials(path=nonexistent) 221 222 assert creds is not None 223 assert creds.access_token == "env-meta-token" 224 assert creds.app_id is None 225 assert creds.app_secret is None 226 227 228 # --------------------------------------------------------------------------- 229 # 6. ファイルも環境変数もない → None 230 # --------------------------------------------------------------------------- 231 232 233 @pytest.mark.unit 234 def test_load_google_ads_credentials_missing( 235 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 236 ) -> None: 237 monkeypatch.delenv("GOOGLE_ADS_DEVELOPER_TOKEN", raising=False) 238 monkeypatch.delenv("GOOGLE_ADS_CLIENT_ID", raising=False) 239 monkeypatch.delenv("GOOGLE_ADS_CLIENT_SECRET", raising=False) 240 monkeypatch.delenv("GOOGLE_ADS_REFRESH_TOKEN", raising=False) 241 monkeypatch.delenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID", raising=False) 242 243 nonexistent = tmp_path / "nonexistent.json" 244 creds = load_google_ads_credentials(path=nonexistent) 245 assert creds is None 246 247 248 @pytest.mark.unit 249 def test_load_meta_ads_credentials_missing( 250 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 251 ) -> None: 252 monkeypatch.delenv("META_ADS_ACCESS_TOKEN", raising=False) 253 monkeypatch.delenv("META_ADS_APP_ID", raising=False) 254 monkeypatch.delenv("META_ADS_APP_SECRET", raising=False) 255 256 nonexistent = tmp_path / "nonexistent.json" 257 creds = load_meta_ads_credentials(path=nonexistent) 258 assert creds is None 259 260 261 # --------------------------------------------------------------------------- 262 # 7. frozen=True のイミュータビリティ確認 263 # --------------------------------------------------------------------------- 264 265 266 @pytest.mark.unit 267 def test_credentials_immutable() -> None: 268 google_creds = GoogleAdsCredentials( 269 developer_token="a", 270 client_id="b", 271 client_secret="c", 272 refresh_token="d", 273 ) 274 with pytest.raises(dataclasses.FrozenInstanceError): 275 google_creds.developer_token = "changed" # type: ignore[misc] 276 277 meta_creds = MetaAdsCredentials(access_token="x") 278 with pytest.raises(dataclasses.FrozenInstanceError): 279 meta_creds.access_token = "changed" # type: ignore[misc] 280 281 282 # --------------------------------------------------------------------------- 283 # 8. create_google_ads_client 284 # --------------------------------------------------------------------------- 285 286 287 @pytest.mark.unit 288 def test_create_google_ads_client() -> None: 289 creds = GoogleAdsCredentials( 290 developer_token="dev-tok", 291 client_id="cid", 292 client_secret="csec", 293 refresh_token="rtok", 294 login_customer_id="1234567890", 295 ) 296 297 with ( 298 patch("mureo.auth.Credentials") as mock_cred_cls, 299 patch("mureo.auth.GoogleAdsApiClient") as mock_client_cls, 300 ): 301 mock_cred_cls.return_value = MagicMock() 302 mock_client_instance = MagicMock() 303 mock_client_cls.return_value = mock_client_instance 304 305 client = create_google_ads_client(creds, customer_id="5555555555") 306 307 assert client is mock_client_instance 308 mock_cred_cls.assert_called_once_with( 309 token=None, 310 refresh_token="rtok", 311 client_id="cid", 312 client_secret="csec", 313 token_uri="https://oauth2.googleapis.com/token", 314 ) 315 mock_client_cls.assert_called_once_with( 316 credentials=mock_cred_cls.return_value, 317 customer_id="5555555555", 318 developer_token="dev-tok", 319 login_customer_id="1234567890", 320 throttler=None, 321 ) 322 323 324 @pytest.mark.unit 325 def test_create_google_ads_client_without_login_customer_id() -> None: 326 creds = GoogleAdsCredentials( 327 developer_token="dev-tok", 328 client_id="cid", 329 client_secret="csec", 330 refresh_token="rtok", 331 ) 332 333 with ( 334 patch("mureo.auth.Credentials") as mock_cred_cls, 335 patch("mureo.auth.GoogleAdsApiClient") as mock_client_cls, 336 ): 337 mock_cred_cls.return_value = MagicMock() 338 mock_client_cls.return_value = MagicMock() 339 340 create_google_ads_client(creds, customer_id="5555555555") 341 342 mock_client_cls.assert_called_once_with( 343 credentials=mock_cred_cls.return_value, 344 customer_id="5555555555", 345 developer_token="dev-tok", 346 login_customer_id=None, 347 throttler=None, 348 ) 349 350 351 # --------------------------------------------------------------------------- 352 # 9. create_meta_ads_client 353 # --------------------------------------------------------------------------- 354 355 356 @pytest.mark.unit 357 def test_create_meta_ads_client() -> None: 358 creds = MetaAdsCredentials( 359 access_token="meta-tok", 360 app_id="app123", 361 app_secret="secret456", 362 ) 363 364 with patch("mureo.auth.MetaAdsApiClient") as mock_client_cls: 365 mock_client_instance = MagicMock() 366 mock_client_cls.return_value = mock_client_instance 367 368 client = create_meta_ads_client(creds, account_id="act_12345") 369 370 assert client is mock_client_instance 371 mock_client_cls.assert_called_once_with( 372 access_token="meta-tok", 373 ad_account_id="act_12345", 374 throttler=None, 375 ) 376 377 378 # --------------------------------------------------------------------------- 379 # 10. 不正なJSON 380 # --------------------------------------------------------------------------- 381 382 383 @pytest.mark.unit 384 def test_load_credentials_invalid_json(tmp_path: Path) -> None: 385 bad_file = tmp_path / "credentials.json" 386 bad_file.write_text("{invalid json!!", encoding="utf-8") 387 388 result = load_credentials(path=bad_file) 389 assert result == {} 390 391 392 @pytest.mark.unit 393 def test_load_google_ads_credentials_invalid_json( 394 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 395 ) -> None: 396 """不正JSONファイル + 環境変数なし → None""" 397 monkeypatch.delenv("GOOGLE_ADS_DEVELOPER_TOKEN", raising=False) 398 monkeypatch.delenv("GOOGLE_ADS_CLIENT_ID", raising=False) 399 monkeypatch.delenv("GOOGLE_ADS_CLIENT_SECRET", raising=False) 400 monkeypatch.delenv("GOOGLE_ADS_REFRESH_TOKEN", raising=False) 401 402 bad_file = tmp_path / "credentials.json" 403 bad_file.write_text("{invalid}", encoding="utf-8") 404 405 creds = load_google_ads_credentials(path=bad_file) 406 assert creds is None 407 408 409 # --------------------------------------------------------------------------- 410 # 11. ファイル優先(ファイルと環境変数の両方がある場合はファイルを優先) 411 # --------------------------------------------------------------------------- 412 413 414 @pytest.mark.unit 415 def test_file_takes_precedence_over_env( 416 credentials_file: Path, monkeypatch: pytest.MonkeyPatch 417 ) -> None: 418 monkeypatch.setenv("GOOGLE_ADS_DEVELOPER_TOKEN", "env-should-not-be-used") 419 420 creds = load_google_ads_credentials(path=credentials_file) 421 422 assert creds is not None 423 assert creds.developer_token == "dev-token-123" 424 425 426 # --------------------------------------------------------------------------- 427 # 12. Google Adsキーがないファイル → 環境変数フォールバック 428 # --------------------------------------------------------------------------- 429 430 431 @pytest.mark.unit 432 def test_load_google_ads_from_file_without_google_key( 433 meta_only_credentials_file: Path, monkeypatch: pytest.MonkeyPatch 434 ) -> None: 435 """ファイルにgoogle_adsキーがない場合、環境変数にフォールバック""" 436 monkeypatch.setenv("GOOGLE_ADS_DEVELOPER_TOKEN", "env-dev") 437 monkeypatch.setenv("GOOGLE_ADS_CLIENT_ID", "env-cid") 438 monkeypatch.setenv("GOOGLE_ADS_CLIENT_SECRET", "env-csec") 439 monkeypatch.setenv("GOOGLE_ADS_REFRESH_TOKEN", "env-rtok") 440 441 creds = load_google_ads_credentials(path=meta_only_credentials_file) 442 443 assert creds is not None 444 assert creds.developer_token == "env-dev" 445 446 447 @pytest.mark.unit 448 def test_load_meta_ads_from_file_without_meta_key( 449 google_only_credentials_file: Path, monkeypatch: pytest.MonkeyPatch 450 ) -> None: 451 """ファイルにmeta_adsキーがない場合、環境変数にフォールバック""" 452 monkeypatch.setenv("META_ADS_ACCESS_TOKEN", "env-meta-tok") 453 454 creds = load_meta_ads_credentials(path=google_only_credentials_file) 455 456 assert creds is not None 457 assert creds.access_token == "env-meta-tok" 458 459 460 # --------------------------------------------------------------------------- 461 # 13. load_credentials — 正常読み込み 462 # --------------------------------------------------------------------------- 463 464 465 @pytest.mark.unit 466 def test_load_credentials_success(credentials_file: Path) -> None: 467 data = load_credentials(path=credentials_file) 468 assert "google_ads" in data 469 assert "meta_ads" in data 470 assert data["google_ads"]["developer_token"] == "dev-token-123" 471 472 473 # --------------------------------------------------------------------------- 474 # 14. デフォルトパス(~/.mureo/credentials.json) 475 # --------------------------------------------------------------------------- 476 477 478 @pytest.mark.unit 479 def test_load_credentials_default_path( 480 tmp_path: Path, monkeypatch: pytest.MonkeyPatch 481 ) -> None: 482 """デフォルトパスは ~/.mureo/credentials.json""" 483 mureo_dir = tmp_path / ".mureo" 484 mureo_dir.mkdir() 485 cred_path = mureo_dir / "credentials.json" 486 cred_path.write_text(json.dumps(SAMPLE_CREDENTIALS), encoding="utf-8") 487 488 monkeypatch.setenv("HOME", str(tmp_path)) 489 # Windowsも考慮して Path.home() をモック 490 monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 491 492 data = load_credentials() 493 assert "google_ads" in data