"""Search Console 클라이언트 — 사이트맵 제출 · URL 검사. ★ 실제 Google API 를 호출하지 않는다(httpx.MockTransport). google-auth 도 이 저장소의 의존성이 아니라서(Codex 가 추가) 인증은 모듈 함수 `_service_account_credentials` / `_refresh_sync` 를 monkeypatch 해서 흉내 낸다 — 설치 여부와 무관하게 이 테스트가 돌아야 한다. 여기서 고정하는 것: 1. `inspect_url` 은 `inspectionResult.indexStatusResult` 만 돌려준다 2. ★ 그 필드가 없는 응답을 "미색인"으로 짐작하지 않고 예외를 올린다 3. 오류는 전부 `SearchConsoleError(code)` 로 정규화되고, 원본 예외 메시지·응답 본문이 `code` 에 섞여 나오지 않는다 4. 사이트맵 제출 URL 은 property/sitemap 주소를 완전 percent-encode 한다 """ import asyncio import httpx import pytest from services import search_console_client as scc from services.search_console_client import SearchConsoleClient, SearchConsoleError PROPERTY = "https://example.com/" SITEMAP = "https://example.com/sitemap.xml" PAGE = "https://example.com/s/butter/" def _auth(monkeypatch, *, token="test-token", load_error=None, refresh_error=None): """`_service_account_credentials`/`_refresh_sync` 를 흉내내 google-auth 없이 인증을 통과시킨다.""" state = {"loads": 0, "refreshes": 0} class _FakeCredentials: def __init__(self): self.token = None self.valid = False def fake_load(path): state["loads"] += 1 if load_error is not None: raise load_error return _FakeCredentials() def fake_refresh(creds): state["refreshes"] += 1 if refresh_error is not None: raise refresh_error creds.token = token creds.valid = bool(token) monkeypatch.setattr(scc, "_service_account_credentials", fake_load) monkeypatch.setattr(scc, "_refresh_sync", fake_refresh) return state def _client(handler, monkeypatch, **auth_kwargs) -> SearchConsoleClient: _auth(monkeypatch, **auth_kwargs) return SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler)) # ── 1) 사이트맵 제출 ────────────────────────────────────────────────────── async def test_사이트맵_제출은_URL을_완전_퍼센트인코딩한다(monkeypatch): """검증: property/sitemap 주소를 PUT 경로에 싣는다. 기대결과: 두 주소 모두 `/` `:` 까지 percent-encode 되어 경로 세그먼트 하나로 들어간다.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["method"] = request.method captured["url"] = str(request.url) captured["auth"] = request.headers.get("Authorization") return httpx.Response(200) client = _client(handler, monkeypatch, token="tok-abc") async with client: await client.submit_sitemap(PROPERTY, SITEMAP) assert captured["method"] == "PUT" assert captured["url"] == ( f"{scc.SITEMAPS_BASE}/https%3A%2F%2Fexample.com%2F" "/sitemaps/https%3A%2F%2Fexample.com%2Fsitemap.xml" ) assert captured["auth"] == "Bearer tok-abc" async def test_사이트맵_제출_성공은_아무것도_돌려주지_않는다(monkeypatch): """검증: 2xx 응답(빈 본문). 기대결과: 예외 없이 끝난다 — 반환값은 None.""" def handler(request): return httpx.Response(200) client = _client(handler, monkeypatch) async with client: assert await client.submit_sitemap(PROPERTY, SITEMAP) is None # ── 2) URL 검사 ─────────────────────────────────────────────────────────── async def test_URL_검사는_indexStatusResult만_돌려준다(monkeypatch): """검증: 응답에 indexStatusResult 외에 mobileUsabilityResult 등 다른 필드도 있다. 기대결과: indexStatusResult 만 뽑아 돌려준다.""" def handler(request: httpx.Request) -> httpx.Response: assert request.method == "POST" assert scc.INSPECT_URL in str(request.url) return httpx.Response(200, json={ "inspectionResult": { "indexStatusResult": {"verdict": "PASS", "coverageState": "Submitted and indexed"}, "mobileUsabilityResult": {"verdict": "PASS"}, } }) client = _client(handler, monkeypatch) async with client: result = await client.inspect_url(PROPERTY, PAGE) assert result == {"verdict": "PASS", "coverageState": "Submitted and indexed"} async def test_URL_검사_요청_본문은_inspectionUrl과_siteUrl이다(monkeypatch): """검증: 검사 요청 본문. 기대결과: {"inspectionUrl": 검사할 페이지, "siteUrl": 프로퍼티} 그대로.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["body"] = request.content return httpx.Response(200, json={"inspectionResult": {"indexStatusResult": {"verdict": "PASS"}}}) import json as _json client = _client(handler, monkeypatch) async with client: await client.inspect_url(PROPERTY, PAGE) assert _json.loads(captured["body"]) == {"inspectionUrl": PAGE, "siteUrl": PROPERTY} async def test_inspectionResult가_없으면_미색인으로_넘겨짚지_않는다(monkeypatch): """검증: ★ 응답 본문이 비어 있다({}). 기대결과: SearchConsoleError("missing_inspection_result") — 빈 dict 를 돌려주지 않는다. (호출측이 빈 dict 를 "미색인"으로 오판할 수 있으므로 여기서 예외로 끊는다.)""" def handler(request): return httpx.Response(200, json={}) client = _client(handler, monkeypatch) with pytest.raises(SearchConsoleError) as exc: async with client: await client.inspect_url(PROPERTY, PAGE) assert exc.value.code == "missing_inspection_result" async def test_indexStatusResult가_없으면_예외(monkeypatch): """검증: inspectionResult 는 있는데 indexStatusResult 가 없다(다른 결과만 있음). 기대결과: SearchConsoleError("missing_index_status_result").""" def handler(request): return httpx.Response(200, json={"inspectionResult": {"mobileUsabilityResult": {}}}) client = _client(handler, monkeypatch) with pytest.raises(SearchConsoleError) as exc: async with client: await client.inspect_url(PROPERTY, PAGE) assert exc.value.code == "missing_index_status_result" async def test_indexStatusResult가_빈_객체면_예외(monkeypatch): """검증: indexStatusResult 는 있지만 빈 dict({}) — 실제 검사가 안 된 응답이다. 기대결과: SearchConsoleError("missing_index_status_result") — 빈 결과를 색인 상태로 돌려주지 않는다.""" def handler(request): return httpx.Response(200, json={"inspectionResult": {"indexStatusResult": {}}}) client = _client(handler, monkeypatch) with pytest.raises(SearchConsoleError) as exc: async with client: await client.inspect_url(PROPERTY, PAGE) assert exc.value.code == "missing_index_status_result" async def test_비정상_JSON_본문은_invalid_json(monkeypatch): """검증: 200 인데 본문이 JSON 이 아니다. 기대결과: SearchConsoleError("invalid_json") — 파싱 실패가 조용히 넘어가지 않는다.""" def handler(request): return httpx.Response(200, text="not json") client = _client(handler, monkeypatch) with pytest.raises(SearchConsoleError) as exc: async with client: await client.inspect_url(PROPERTY, PAGE) assert exc.value.code == "invalid_json" # ── 3) HTTP 상태 코드 정규화 ───────────────────────────────────────────── @pytest.mark.parametrize( ("status", "code"), [(401, "unauthorized"), (403, "forbidden"), (429, "rate_limited"), (500, "server_error"), (503, "server_error")], ) async def test_HTTP_오류_상태코드는_code로_정규화된다(monkeypatch, status, code): """검증: 401/403/429/5xx 응답. 기대결과: SearchConsoleError.code 가 상태별로 정규화되고, Google 응답 본문은 code 에 섞이지 않는다.""" def handler(request): return httpx.Response(status, text="google 응답 본문(비밀은 아니지만 새면 안 된다)") client = _client(handler, monkeypatch) with pytest.raises(SearchConsoleError) as exc: async with client: await client.submit_sitemap(PROPERTY, SITEMAP) assert exc.value.code == code assert "google 응답 본문" not in str(exc.value) # ── 4) 전송 오류 ───────────────────────────────────────────────────────── async def test_타임아웃은_timeout으로_정규화(monkeypatch): """검증: 요청이 타임아웃된다. 기대결과: SearchConsoleError("timeout").""" def handler(request): raise httpx.ReadTimeout("timed out", request=request) client = _client(handler, monkeypatch) with pytest.raises(SearchConsoleError) as exc: async with client: await client.inspect_url(PROPERTY, PAGE) assert exc.value.code == "timeout" async def test_연결_실패는_transport_error로_정규화(monkeypatch): """검증: 연결 자체가 끊긴다(DNS 실패 등). 기대결과: SearchConsoleError("transport_error").""" def handler(request): raise httpx.ConnectError("연결 실패", request=request) client = _client(handler, monkeypatch) with pytest.raises(SearchConsoleError) as exc: async with client: await client.submit_sitemap(PROPERTY, SITEMAP) assert exc.value.code == "transport_error" # ── 5) 인증 실패 ───────────────────────────────────────────────────────── async def test_키파일_로딩_실패는_invalid_credentials_file(monkeypatch): """검증: 서비스 계정 키 파일을 못 읽는다(없음·손상). 기대결과: SearchConsoleError("invalid_credentials_file") — 원본 예외 메시지는 안 실린다.""" def handler(request): raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다") client = _client(handler, monkeypatch, load_error=ValueError("키 파일 내용: super-secret-key-material")) with pytest.raises(SearchConsoleError) as exc: async with client: await client.submit_sitemap(PROPERTY, SITEMAP) assert exc.value.code == "invalid_credentials_file" assert "super-secret-key-material" not in str(exc.value) async def test_토큰_갱신_실패는_auth_failed(monkeypatch): """검증: 키 파일은 읽었지만 Google 토큰 발급이 거부된다(폐기된 키 등). 기대결과: SearchConsoleError("auth_failed") — 원본 예외 메시지는 안 실린다.""" def handler(request): raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다") client = _client(handler, monkeypatch, refresh_error=RuntimeError("invalid_grant: token revoked=xyz")) with pytest.raises(SearchConsoleError) as exc: async with client: await client.inspect_url(PROPERTY, PAGE) assert exc.value.code == "auth_failed" assert "revoked=xyz" not in str(exc.value) async def test_토큰이_비면_auth_failed(monkeypatch): """검증: 갱신은 예외 없이 끝났지만 credentials.token 이 비어 있다. 기대결과: SearchConsoleError("auth_failed") — 빈 토큰으로 요청을 보내지 않는다.""" def handler(request): raise AssertionError("빈 토큰으로 네트워크를 타면 안 된다") client = _client(handler, monkeypatch, token="") with pytest.raises(SearchConsoleError) as exc: async with client: await client.submit_sitemap(PROPERTY, SITEMAP) assert exc.value.code == "auth_failed" async def test_인증정보_로딩과_유효한_토큰_갱신은_한_번만_한다(monkeypatch): """검증: 같은 클라이언트로 두 번 호출한다. 기대결과: 키 파일 로딩 1회 · 토큰 갱신 1회 — 두 번째 호출은 여전히 유효한(`credentials.valid`) 토큰을 그대로 재사용한다.""" def handler(request): return httpx.Response(200) state = _auth(monkeypatch) client = SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler)) async with client: await client.submit_sitemap(PROPERTY, SITEMAP) await client.submit_sitemap(PROPERTY, SITEMAP) assert state["loads"] == 1 assert state["refreshes"] == 1 async def test_동시_호출은_토큰_갱신을_한_번만_한다(monkeypatch): """검증: 아직 토큰이 없는 클라이언트를 두 요청이 동시에 부른다. 기대결과: 갱신 1회 — 잠금 없이 두 번째 요청이 갱신 중인 자격을 다시 갱신하면 Google 토큰 엔드포인트를 요청 수만큼 때리게 된다.""" import time as _time def handler(request): return httpx.Response(200) state = _auth(monkeypatch) def slow_refresh(creds): _time.sleep(0.05) state["refreshes"] += 1 creds.token = "tok" creds.valid = True monkeypatch.setattr(scc, "_refresh_sync", slow_refresh) client = SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler)) async with client: await asyncio.gather( client.submit_sitemap(PROPERTY, SITEMAP), client.submit_sitemap(PROPERTY, SITEMAP), ) assert state["refreshes"] == 1 # ── 6) 닫기 ────────────────────────────────────────────────────────────── async def test_async_컨텍스트매니저는_내부_httpx_클라이언트를_닫는다(monkeypatch): """검증: `async with` 블록을 빠져나간다. 기대결과: 내부 httpx.AsyncClient 가 닫힌다 — 커넥션을 남겨두지 않는다.""" def handler(request): return httpx.Response(200) client = _client(handler, monkeypatch) async with client: pass assert client._client.is_closed is True