o2o-site-AEO/solution/backend/tests/test_search_console_client.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

310 lines
12 KiB
Python

"""Search Console 클라이언트 — 사이트맵 제출 · URL 검사."""
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 경로에 싣는다."""
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 응답(빈 본문)."""
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 등 다른 필드도 있다."""
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):
"""검증: 검사 요청 본문."""
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):
"""검증: ★ 응답 본문이 비어 있다({})."""
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 가 없다(다른 결과만 있음)."""
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({}) — 실제 검사가 안 된 응답이다."""
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 이 아니다."""
def handler(request):
return httpx.Response(200, text="<html>not json</html>")
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 응답."""
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):
"""검증: 요청이 타임아웃된다."""
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 실패 등)."""
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):
"""검증: 서비스 계정 키 파일을 못 읽는다(없음·손상)."""
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 토큰 발급이 거부된다(폐기된 키 등)."""
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 이 비어 있다."""
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):
"""검증: 같은 클라이언트로 두 번 호출한다."""
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):
"""검증: 아직 토큰이 없는 클라이언트를 두 요청이 동시에 부른다."""
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` 블록을 빠져나간다."""
def handler(request):
return httpx.Response(200)
client = _client(handler, monkeypatch)
async with client:
pass
assert client._client.is_closed is True