o2o-site-AEO/solution/backend/tests/test_search_console_client.py
Mina Choi 3f47d5ecd2 [feat] solution/backend: 서치콘솔 자동 제출·색인 상태 추적 추가
사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리.

- Google 클라이언트·배치·DB·Teams 알림 모듈 분리
- 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가
- ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신

검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
2026-09-15 14:50:30 +09:00

343 lines
14 KiB
Python

"""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="<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 응답.
기대결과: 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