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>
245 lines
12 KiB
Python
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": []}
|