여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
310 lines
12 KiB
Python
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
|