운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것, 크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버): 사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이 429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일 이유가 없었다. - solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은 dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡 진행(useGenerationJob) 그대로 - solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속. db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN. worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를 Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가. collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다. - postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션 검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
279 lines
12 KiB
Python
279 lines
12 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
import uuid
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.database.model.models import places, sites, site_versions, site_search_status
|
|
from crud import search_console_crud as store
|
|
from services import search_console_service as service
|
|
from services.search_console_client import SearchConsoleError
|
|
from services.search_console_alerts import alert_reason, send_alert
|
|
from services.search_console_settings import SearchConsoleSettings, belongs_to_property, load_settings
|
|
|
|
NOW = datetime.now(timezone.utc)
|
|
SETTINGS = SearchConsoleSettings("https://example.com/", "/test/key.json")
|
|
|
|
|
|
def status(**changes):
|
|
return SimpleNamespace(site_id=uuid.uuid4(), page_url="https://example.com/s/stay",
|
|
property_url=SETTINGS.property_url, published_at=NOW - timedelta(days=8),
|
|
sitemap_submitted_at=None, inspected_at=None, inspection=None,
|
|
first_indexed_at=None, error_code=None, failures=0, alerted_at=None,
|
|
next_check_at=NOW, **changes)
|
|
|
|
|
|
@pytest.mark.parametrize("url,prop,expected", [
|
|
("https://example.com/s/a", "https://example.com/", True),
|
|
("https://example.com.evil.test/s/a", "https://example.com/", False),
|
|
("https://sub.example.com/s/a", "sc-domain:example.com", True),
|
|
("https://notexample.com/s/a", "sc-domain:example.com", False),
|
|
("http://example.com/s/a", "sc-domain:example.com", False),
|
|
("https://example.com/s/a?secret=x", "sc-domain:example.com", False),
|
|
])
|
|
def test_property_scope(url, prop, expected):
|
|
assert belongs_to_property(url, prop) is expected
|
|
|
|
|
|
def test_disabled_needs_no_credentials(monkeypatch):
|
|
monkeypatch.delenv("GSC_ENABLED", raising=False)
|
|
assert load_settings() is None
|
|
monkeypatch.setenv("GSC_ENABLED", "1")
|
|
monkeypatch.delenv("GSC_PROPERTY_URL", raising=False)
|
|
with pytest.raises(ValueError):
|
|
load_settings()
|
|
|
|
|
|
async def test_submit_and_inspect_are_separate(monkeypatch):
|
|
row = status()
|
|
client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock(return_value={"verdict": "NEUTRAL"}))
|
|
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={row.page_url}))
|
|
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
|
|
await service.check_site(client, row, SETTINGS, set())
|
|
assert row.sitemap_submitted_at
|
|
assert row.inspected_at
|
|
assert row.first_indexed_at is None
|
|
assert row.next_check_at > NOW
|
|
assert row.error_code is None
|
|
|
|
|
|
async def test_missing_sitemap_url_retries_without_google_call(monkeypatch):
|
|
row = status()
|
|
client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock())
|
|
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value=set()))
|
|
await service.check_site(client, row, SETTINGS, set())
|
|
assert row.error_code == "URL_NOT_IN_SITEMAP"
|
|
assert row.sitemap_submitted_at is None
|
|
assert row.failures == 1
|
|
client.submit_sitemap.assert_not_awaited()
|
|
client.inspect_url.assert_not_awaited()
|
|
|
|
|
|
async def test_one_sitemap_submission_per_batch(monkeypatch):
|
|
client = SimpleNamespace(submit_sitemap=AsyncMock())
|
|
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={status().page_url}))
|
|
seen = set()
|
|
await service.submit_sitemap(client, status(), seen)
|
|
await service.submit_sitemap(client, status(), seen)
|
|
client.submit_sitemap.assert_awaited_once()
|
|
|
|
|
|
async def test_inspection_failure_preserves_previous_observation():
|
|
row = status()
|
|
row.sitemap_submitted_at = NOW
|
|
row.inspection = {"verdict": "PASS"}
|
|
row.inspected_at = NOW - timedelta(days=1)
|
|
client = SimpleNamespace(inspect_url=AsyncMock(side_effect=SearchConsoleError("HTTP_429")))
|
|
await service.check_site(client, row, SETTINGS, set())
|
|
assert row.inspection == {"verdict": "PASS"}
|
|
assert row.inspected_at < NOW
|
|
assert row.error_code == "HTTP_429"
|
|
assert row.failures == 1
|
|
|
|
|
|
def test_first_indexed_is_observation_time_not_google_crawl_time():
|
|
row = status()
|
|
service.record_inspection(row, {"verdict": "PASS", "lastCrawlTime": "2020-01-01T00:00:00Z"}, NOW)
|
|
service.record_inspection(row, {"verdict": "PASS"}, NOW + timedelta(days=1))
|
|
assert row.first_indexed_at == NOW
|
|
|
|
|
|
def test_alert_cooldown_and_backoff():
|
|
row = status()
|
|
assert alert_reason(row, NOW, 7)
|
|
row.alerted_at = NOW
|
|
assert alert_reason(row, NOW, 7) is None
|
|
for _ in range(30):
|
|
service.record_error(row, "HTTP_503", NOW)
|
|
assert row.next_check_at == NOW + timedelta(days=1)
|
|
|
|
|
|
async def test_failed_alert_does_not_mark_delivered(monkeypatch):
|
|
row = status()
|
|
row.sitemap_submitted_at = NOW
|
|
client = SimpleNamespace(inspect_url=AsyncMock(return_value={"verdict": "NEUTRAL"}))
|
|
send = AsyncMock(return_value=False)
|
|
monkeypatch.setattr(service, "send_alert", send)
|
|
await service.check_site(client, row, SETTINGS, set())
|
|
send.assert_awaited_once()
|
|
assert row.alerted_at is None
|
|
|
|
|
|
async def test_empty_alert_url_never_calls_network():
|
|
assert not await send_alert("", "https://example.com/s/a", "delayed")
|
|
|
|
|
|
async def test_disabled_cron_never_opens_database(monkeypatch):
|
|
monkeypatch.delenv("GSC_ENABLED", raising=False)
|
|
call = AsyncMock()
|
|
monkeypatch.setattr(service.DB_SESSION_MNG, "execute_lambda_write", call)
|
|
await service.run_scheduled_check()
|
|
call.assert_not_awaited()
|
|
|
|
|
|
async def seed_site(session, *, state=3):
|
|
place = places(place_id=uuid.uuid4(), owner_user_id=uuid.uuid4(), name="숙소", category=1)
|
|
site = sites(site_id=uuid.uuid4(), place_id=place.place_id, status=state,
|
|
domain="stay", published_at=NOW)
|
|
version = site_versions(site_version_id=uuid.uuid4(), site_id=site.site_id, version=1, build_status=2)
|
|
site.current_version_id = version.site_version_id
|
|
session.add_all([place, site, version])
|
|
await session.flush()
|
|
return site, place, version
|
|
|
|
|
|
async def test_registration_survives_restart_and_resets_on_publish(db_engine, monkeypatch):
|
|
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
|
|
async with AsyncSession(db_engine, expire_on_commit=False) as session:
|
|
site, _, _ = await seed_site(session)
|
|
await service.register_publications(session, SETTINGS)
|
|
await session.commit()
|
|
row = await session.get(site_search_status, site.site_id)
|
|
row.sitemap_submitted_at = NOW
|
|
row.first_indexed_at = NOW
|
|
await session.commit()
|
|
await service.register_publications(session, SETTINGS)
|
|
assert row.first_indexed_at == NOW
|
|
site.published_at = NOW + timedelta(seconds=1)
|
|
await session.commit()
|
|
await service.register_publications(session, SETTINGS)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
assert row.first_indexed_at is None
|
|
assert row.sitemap_submitted_at is None
|
|
|
|
|
|
async def test_unpublished_and_deleted_places_are_not_checked(db_engine, monkeypatch):
|
|
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
|
|
async with AsyncSession(db_engine) as session:
|
|
site, place, _ = await seed_site(session)
|
|
await service.register_publications(session, SETTINGS)
|
|
assert len(await store.due_sites(session, SETTINGS.property_url)) == 1
|
|
site.status = 5
|
|
await session.flush()
|
|
assert not await store.due_sites(session, SETTINGS.property_url)
|
|
site.status = 3
|
|
place.deleted = True
|
|
await session.flush()
|
|
assert not await store.due_sites(session, SETTINGS.property_url)
|
|
|
|
|
|
async def test_batch_lock_excludes_second_process(db_engine):
|
|
async with AsyncSession(db_engine) as first, AsyncSession(db_engine) as second:
|
|
assert await store.lock_batch(first)
|
|
assert not await store.lock_batch(second)
|
|
await first.rollback()
|
|
assert await store.lock_batch(second)
|
|
|
|
|
|
async def test_full_batch_commits_and_does_not_repeat_same_day(db_engine, monkeypatch):
|
|
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
|
|
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={status().page_url}))
|
|
client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock(return_value={"verdict": "PASS"}))
|
|
|
|
class FakeClient:
|
|
def __init__(self, *_):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return client
|
|
|
|
async def __aexit__(self, *_):
|
|
pass
|
|
|
|
monkeypatch.setattr(service, "SearchConsoleClient", FakeClient)
|
|
async with AsyncSession(db_engine) as session:
|
|
site, _, _ = await seed_site(session)
|
|
sid = site.site_id
|
|
await session.commit()
|
|
await service.run_batch(session, SETTINGS)
|
|
await session.commit()
|
|
async with AsyncSession(db_engine) as session:
|
|
row = await session.get(site_search_status, sid)
|
|
assert row.sitemap_submitted_at
|
|
assert row.first_indexed_at
|
|
await service.run_batch(session, SETTINGS)
|
|
await session.commit()
|
|
client.submit_sitemap.assert_awaited_once()
|
|
client.inspect_url.assert_awaited_once()
|
|
|
|
|
|
async def test_cron_failure_is_isolated(monkeypatch):
|
|
monkeypatch.setattr(service, "load_settings", lambda: SETTINGS)
|
|
monkeypatch.setattr(service.DB_SESSION_MNG, "execute_lambda_write", AsyncMock(side_effect=RuntimeError("db")))
|
|
await service.run_scheduled_check()
|
|
|
|
|
|
async def test_sitemap_http_parser(monkeypatch):
|
|
import httpx
|
|
|
|
real_client = httpx.AsyncClient
|
|
transport = httpx.MockTransport(lambda request: httpx.Response(200, text='''
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
<url><loc>https://example.com/s/stay</loc></url></urlset>'''))
|
|
monkeypatch.setattr(service.httpx, "AsyncClient", lambda **kw: real_client(transport=transport, **kw))
|
|
assert await service.read_sitemap("https://example.com/sitemap.xml") == {status().page_url}
|
|
|
|
|
|
async def test_sitemap_redirect_is_not_followed(monkeypatch):
|
|
import httpx
|
|
|
|
real_client = httpx.AsyncClient
|
|
transport = httpx.MockTransport(lambda request: httpx.Response(302, headers={"location": "http://internal/"}))
|
|
monkeypatch.setattr(service.httpx, "AsyncClient", lambda **kw: real_client(transport=transport, **kw))
|
|
with pytest.raises(SearchConsoleError, match="SITEMAP_UNAVAILABLE"):
|
|
await service.read_sitemap("https://example.com/sitemap.xml")
|
|
|
|
|
|
@pytest.mark.parametrize("enabled,count", [("0", 0), ("1", 1)])
|
|
def test_existing_scheduler_registers_optional_job(monkeypatch, enabled, count):
|
|
import scheduler
|
|
|
|
instance = SimpleNamespace(add_job=lambda *a, **kw: jobs.append((a, kw)),
|
|
start=lambda: None, get_jobs=lambda: jobs)
|
|
jobs = []
|
|
monkeypatch.setattr(scheduler, "_scheduler", None)
|
|
monkeypatch.setattr(scheduler, "AsyncIOScheduler", lambda **kw: instance)
|
|
monkeypatch.setenv("SCHEDULER_ENABLED", "1")
|
|
monkeypatch.setenv("GSC_ENABLED", enabled)
|
|
scheduler.start_scheduler()
|
|
# ★ 알림 스윕 둘(alert-outbox·queue-health)은 GSC_ENABLED 와 무관하게 항상 등록된다
|
|
# (scheduler/__init__.py, docs/ALERTS.md) — search-console 잡만 옵션이다.
|
|
always_on = {kw["id"] for _a, kw in jobs} - {"search-console"}
|
|
assert always_on == {"alert-outbox", "queue-health"}
|
|
assert len(jobs) == count + 2
|
|
gsc_jobs = [kw for _a, kw in jobs if kw["id"] == "search-console"]
|
|
if gsc_jobs:
|
|
assert gsc_jobs[0]["minutes"] == 10
|
|
assert gsc_jobs[0]["max_instances"] == 1
|
|
|
|
|
|
def test_migration_matches_fresh_database_schema():
|
|
from pathlib import Path
|
|
root = Path(__file__).resolve().parents[3]
|
|
migration = (root / "postgres-init/migrations/0014_search_console.sql").read_text()
|
|
ddl = migration[migration.index("CREATE TABLE"):].strip()
|
|
assert ddl in (root / "postgres-init/init-data/init.sql").read_text()
|