사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리. - Google 클라이언트·배치·DB·Teams 알림 모듈 분리 - 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가 - ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신 검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
343 lines
14 KiB
Python
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
|