/ tests / test_auth.py
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