o2o-site-AEO/solution/backend/tests/test_snapshot.py
Mina Choi e0d45eda97 [fix] postgres-init,solution/backend,docs: 스키마 재편이 안 닿은 자리를 전부 잡는다 — init.sql · ORM 인덱스 · 테스트
0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데, 문자열로 표 이름을 들고 있던 자리들이
따라오지 않았다. import 도 타입검사도 pyflakes 도 못 잡는 종류라 전부 **실행되는 순간에만**
터졌고, 그동안 pytest 는 569건이 통째로 죽어 있어 아무것도 못 잡고 있었다.

**init.sql 이 새 DB 를 옛 구조로 세우고 있었다**
64ce467 이 이 파일에 94줄을 더하기만 하고 삭제를 0줄 했다. 그래서 이 파일 한 벌로 세운 DB 는
`place.place_links`·`job.jobs` 를 갖고 ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 —
기동은 정상이고 첫 쿼리에서 죽는다. "init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을
유지한다"(migrations/README.md)는 계약이 깨져 있었다.
- public 한 벌 · 표 14개로 다시 썼다. 옛 스키마가 있는 DB 에서 다시 돌면 RAISE EXCEPTION 으로
  멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation already exists" 로 실패해
  데이터가 옛 스키마에 갇힌다
- 말미에 **마이그레이션 기준선**을 심는다. 없으면 새 DB 에서 migrate.py 가 0001 부터 다시 돌다가
  `schema "local" does not exist` 로 죽는다

**운영 버그 둘** — 두 DB(새로 세운 것 · 마이그레이션으로 따라온 것)를 pg_dump 로 찍어 비교해 찾았다
- `upsert_weather` 의 ON CONFLICT 술어에 `kind IS NULL` 이 빠져 **날씨 캐시 저장이 계속 실패**하고
  있었다(0007 이 인덱스에 그 조건을 더했다). 캐시라 화면이 안 죽고 로그에만 남았다.
  포스트그레스는 술어가 인덱스 술어를 함의하는지 보고 아니면 "no unique or exclusion constraint
  matching" 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보인다
- ORM 의 `area_contents` 인덱스 정의가 0004·0007·0008 을 하나도 안 따라왔다. 테스트 DB 는 이
  모델로 세워지므로 **테스트가 운영과 다른 제약 아래에서 돌고 있었다**

**0009** — 두 DB 비교에서 나온 어긋남 셋(데이터는 안 건드린다)
- `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔
- `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 틀린다
- RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …)

**테스트를 살린다**
- conftest 의 TRUNCATE 가 표 이름을 **손으로 나열**하고 있었다. 0005 가 이름을 옮기자 전 테스트가
  `relation "place_aliases" does not exist` 로 죽었다 — 이제 ORM 메타데이터에서 뽑아 다시 어긋날 수 없다
- `test_schema_ddl` 이 모델 표를 `"None.users"` 로 조회해 **한 표도 비교하지 않고 통과**하고 있었다.
  init.sql 이 조용히 어긋난 동안 이 테스트는 초록이었다. 비교한 표 수를 세는 단언을 더한다
- 테스트 SQL 15곳의 옛 표 이름, `_run_worker` 1틱 문제(수집 뒤 따라오는 LOCAL_SYNC 를 집어 가
  정작 기다리던 잡이 PENDING 으로 남았다), 지역 캐시 픽스처(읽는 코드가 옳게 거르는데 테스트가 빨개졌다)

**문서**
- `docs/DATA_MODEL.md` 신설 — 표 14개가 무엇을 담고 누가 쓰는지, 값 하나가 DB 에서 페이지까지
  가는 길, 두 번 도는 게이트, **DB 에 없는 것**
- `SERVERS.md` DB 절을 마이그레이션 체계로. 배포에 `migrate.py` 를 넣는다 — 코드만 갈면 컨테이너는
  정상으로 뜨고 가게 등록·수집·발행만 죽는다
- ARCHITECTURE 2절의 프리렌더 컨테이너가 `solution-frontend` 로 적혀 있었다. 굽는 건
  `solution-prerender` 고 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 그 혼동을
  문서가 만들고 있었다
- 옛 표 이름 잔재(`place_links`·`local_contents`·`job.jobs`·`company.users`·`fact.facts`·`ai_check_results`)

검증: 빈 컨테이너에 init.sql 로 세운 DB ↔ 마이그레이션으로 따라온 DB 를 `pg_dump --schema-only`
로 비교 — 표·인덱스·제약·컬럼 전부 동일. pytest 583건 중 581 통과(남은 2건은 `.env` 누수·
레이트리밋 카운터로 환경 문제다). 구글 로그인 21건 포함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:39:43 +09:00

245 lines
12 KiB
Python

"""빌드 스냅샷 — ★ 사이트에 나가면 안 되는 것이 스냅샷에 들어오지 않는가.
스냅샷이 정적 빌드의 경계다. 여기 들어온 것은 그대로 발행되므로, 필터링이 여기서 새면 끝이다.
"""
import uuid
from sqlalchemy import text
from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType
from services.snapshot import build_snapshot
async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING):
pid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, phone, verified_at) "
"VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"),
{"p": pid, "c": uuid.UUID(owner_id), "n": "스냅샷펜션", "cat": category.value,
"addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"},
)
return pid
async def _fact(db_engine, pid, key, value, status, unit_id=None):
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO place_facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) "
"VALUES (:f,:p,:u,:k,:v,:s,:st,now())"),
{"f": uuid.uuid4(), "p": pid, "u": unit_id, "k": key, "v": value,
"s": SourceType.OWNER.value, "st": status.value},
)
async def _media(db_engine, pid, url, status, alt="설명"):
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, sort_order) "
"VALUES (:m,:p,:u,:u,:s,:st,:a,0)"),
{"m": uuid.uuid4(), "p": pid, "u": url, "s": SourceType.CRAWL.value, "st": status.value, "a": alt},
)
class _Place:
def __init__(self, pid, category=PlaceCategory.LODGING):
self.place_id = pid
self.category = category.value
self.name = "스냅샷펜션"
self.road_address = "강원특별자치도 양양군 현북면 하조대3길 11"
self.address = None
self.phone = "033-000-0000"
self.latitude = None
self.longitude = None
async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id):
"""검증: 여러 상태의 fact 를 섞어 넣는다.
기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다."""
pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED)
await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED)
await _fact(db_engine, pid, "breakfast", "true", FactStatus.PENDING_OWNER)
await _fact(db_engine, pid, "bbq_available", "true", FactStatus.REJECTED)
await _fact(db_engine, pid, "smoking", "false", FactStatus.EXPIRED)
snap = await build_snapshot(_Place(pid))
keys = {f["key"] for f in snap["facts"]}
assert keys == {"check_in_time", "wifi"}
async def test_only_approved_media_enters_snapshot(db_engine, owner_id):
"""검증: 승인/확인대기/반려 사진을 섞어 넣는다.
기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다."""
pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED)
await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW)
await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED)
snap = await build_snapshot(_Place(pid))
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"]
async def test_media_without_alt_is_excluded(db_engine, owner_id):
"""검증: 승인됐지만 alt 텍스트가 없는 사진.
기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다."""
pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="")
await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진")
snap = await build_snapshot(_Place(pid))
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"]
async def test_fact_labels_come_from_category_schema(db_engine, owner_id):
"""검증: 스냅샷의 fact 라벨.
기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게."""
pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
snap = await build_snapshot(_Place(pid))
f = snap["facts"][0]
assert f["label"] == "체크인 시간"
assert f["scope"] == "place"
async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id):
"""검증: 객실 단위 fact.
기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다."""
pid = await _seed(db_engine, owner_id)
uid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(text("INSERT INTO place_units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"),
{"u": uid, "p": pid, "n": "A동"})
await _fact(db_engine, pid, "max_capacity", "4", FactStatus.VERIFIED, unit_id=uid)
snap = await build_snapshot(_Place(pid))
assert snap["units"][0]["name"] == "A동"
assert snap["facts"][0]["unit_id"] == str(uid)
assert snap["facts"][0]["scope"] == "unit"
async def test_empty_place_gives_empty_snapshot(db_engine, owner_id):
"""검증: 아무것도 없는 사업장.
기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다."""
pid = await _seed(db_engine, owner_id)
snap = await build_snapshot(_Place(pid))
assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == []
assert snap["place"]["name"] == "스냅샷펜션"
# ── 지역 정보 ─────────────────────────────────────────────────────────────
# ★ 지역 정보가 스냅샷에 담기는 이유: site_payload 는 DB 를 다시 읽지 않는다.
# 거기서 지역 캐시를 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 '스냅샷과 다른 페이지'가 나온다.
async def _local(db_engine, region_code, kind, status, title="지역이야기", **cols):
"""지역 캐시(`area_contents`)에 **지역 단위** 항목 한 행.
★ `external_id` 를 넣지 않는다. 그 값이 있는 행(축제·관광지·맛집)은 업장마다 거리가 달라
개인화(`site_sections`)를 거쳐 들어오고, 스냅샷의 지역 캐시 경로는 그것들을 일부러
건너뛴다(`services/snapshot._local_contents`). 예전 이 픽스처는 uuid 를 external_id 로
넣고 있어서, **읽는 코드가 옳게 걸러내는데도 테스트가 빨개졌다**.
★ 한 지역에 같은 kind 는 한 행이다(`uq_local_contents_kind`). 여러 행이 필요한 테스트는
kind 를 달리 준다 — 지역 이야기 다섯 종이 그 자리다.
"""
from common.enums import LocalContentType, LocalSource
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO area_contents "
"(local_content_id, region_code, content_type, source, title, body, status, kind, "
" display_start_at, display_end_at, collected_at) "
"VALUES (:i,:r,:ct,:src,:t,cast(:b as jsonb),:st,:k,:ds,:de,now())"),
{"i": uuid.uuid4(), "r": region_code, "ct": LocalContentType.STORY.value,
"src": LocalSource.LLM.value, "t": title,
"b": '{"title":"%s"}' % title, "st": status.value, "k": kind,
"ds": cols.get("display_start_at"), "de": cols.get("display_end_at")},
)
class _RegionPlace(_Place):
"""region_code 를 가진 사업장. 지역 캐시의 키는 place_id 가 아니라 region_code 다."""
def __init__(self, pid, region_code):
super().__init__(pid)
self.region_code = region_code
async def test_only_published_local_content_enters_snapshot(db_engine, owner_id):
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id)
# kind 를 달리 준다 — 한 지역에 같은 kind 는 한 행이다(uq_local_contents_kind).
await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "발행이야기")
await _local(db_engine, "4113500", "people", LocalContentStatus.REVIEW, "검수대기이야기")
await _local(db_engine, "4113500", "chronicle", LocalContentStatus.ENDED, "종료이야기")
snap = await build_snapshot(_RegionPlace(pid, "4113500"))
assert [c["title"] for c in snap["local"]["contents"]] == ["발행이야기"]
async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id):
"""검증: 발행됐지만 노출 기간을 벗어난 지역 정보.
기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다."""
from datetime import datetime, timedelta, timezone
from common.enums import LocalContentStatus
now = datetime.now(timezone.utc)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "지금이야기")
await _local(db_engine, "4113500", "people", LocalContentStatus.PUBLISHED, "끝난이야기",
display_end_at=now - timedelta(days=1))
await _local(db_engine, "4113500", "chronicle", LocalContentStatus.PUBLISHED, "아직이야기",
display_start_at=now + timedelta(days=1))
snap = await build_snapshot(_RegionPlace(pid, "4113500"))
assert [c["title"] for c in snap["local"]["contents"]] == ["지금이야기"]
async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id):
"""검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "우리지역이야기")
await _local(db_engine, "5011025", "songs", LocalContentStatus.PUBLISHED, "남의지역이야기")
snap = await build_snapshot(_RegionPlace(pid, "4113500"))
assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역이야기"]
async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id):
"""검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.
기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는
코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다."""
from common.enums import LocalContentStatus
from services.external.naver import region_key
derived = region_key(_Place("x").road_address)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, derived, "songs", LocalContentStatus.PUBLISHED, "양양이야기")
snap = await build_snapshot(_RegionPlace(pid, ""))
assert snap["local"]["region_code"] == derived
assert [c["title"] for c in snap["local"]["contents"]] == ["양양이야기"]
async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id):
"""검증: 지역 코드도 읽을 만한 주소도 없는 사업장.
기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다."""
from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "어딘가이야기")
place = _RegionPlace(pid, "")
place.road_address = None
snap = await build_snapshot(place)
assert snap["local"] == {"region_code": None, "contents": []}