o2o-site-AEO/solution/backend/tests/test_snapshot.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +09:00

216 lines
11 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, company_id, category=PlaceCategory.LODGING):
pid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO places (place_id, company_id, name, category, status, road_address, phone, verified_at) "
"VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"),
{"p": pid, "c": uuid.UUID(company_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 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 media (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, company_id):
"""검증: 여러 상태의 fact 를 섞어 넣는다.
기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다."""
pid = await _seed(db_engine, company_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, company_id):
"""검증: 승인/확인대기/반려 사진을 섞어 넣는다.
기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다."""
pid = await _seed(db_engine, company_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, company_id):
"""검증: 승인됐지만 alt 텍스트가 없는 사진.
기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다."""
pid = await _seed(db_engine, company_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, company_id):
"""검증: 스냅샷의 fact 라벨.
기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게."""
pid = await _seed(db_engine, company_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, company_id):
"""검증: 객실 단위 fact.
기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다."""
pid = await _seed(db_engine, company_id)
uid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(text("INSERT INTO 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, company_id):
"""검증: 아무것도 없는 사업장.
기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다."""
pid = await _seed(db_engine, company_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, content_type, status, title="지역행사", **cols):
from common.enums import LocalSource
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO local_contents "
"(local_content_id, region_code, content_type, source, external_id, title, body, status, "
" display_start_at, display_end_at, collected_at) "
"VALUES (:i,:r,:ct,:src,:ext,:t,cast(:b as jsonb),:st,:ds,:de,now())"),
{"i": uuid.uuid4(), "r": region_code, "ct": content_type.value, "src": LocalSource.TOUR_API.value,
"ext": uuid.uuid4().hex, "t": title, "b": '{"title":"%s"}' % title, "st": status.value,
"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, company_id):
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, 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, company_id):
"""검증: 발행됐지만 노출 기간을 벗어난 지역 정보.
기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다."""
from datetime import datetime, timedelta, timezone
from common.enums import LocalContentStatus, LocalContentType
now = datetime.now(timezone.utc)
pid = await _seed(db_engine, company_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제",
display_end_at=now - timedelta(days=1))
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, 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, company_id):
"""검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제")
await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제")
snap = await build_snapshot(_RegionPlace(pid, "4113500"))
assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"]
async def test_place_without_region_code_gets_no_local_content(db_engine, company_id):
"""검증: region_code 가 비어 있는 사업장(수집이 지역을 특정하지 못한 경우).
기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제")
snap = await build_snapshot(_RegionPlace(pid, ""))
assert snap["local"] == {"region_code": None, "contents": []}