o2o-site-AEO/solution/backend/tests/test_faq_api.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

176 lines
9.2 KiB
Python

"""FAQ 조회·승인 e2e.
이 도메인이 지켜야 하는 것:
- LLM 이 만든 FAQ 는 ★ 사람이 승인하기 전엔 사이트에 안 나간다(FAQPage JSON-LD 에도 안 실린다)
- 상태 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)만 따른다
- 고쳐서 승인한 FAQ(CORRECTED)는 잠긴다 — 재생성이 사장님 문구를 덮어쓰지 못한다
- 사장님이 직접 쓴 FAQ 는 바로 노출값이다(사람이 곧 출처)
"""
import uuid
from sqlalchemy import text
from common.enums import ErrorType, FactStatus, SourceType
async def _place(client, headers, name="FAQ펜션"):
r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1})
return r.json()["place"]["place_id"]
async def _seed_generated_faq(db_engine, pid, question="체크인은 몇 시인가요?", answer="15시입니다.", order=0):
"""COPY 잡이 남기고 간 상태를 그대로 만든다 — LLM 출처 · UNVERIFIED."""
fid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO place_faqs (faq_id, place_id, question, answer, source_fact_ids, generated_by, status, sort_order) "
"VALUES (:f, :p, :q, :a, CAST(:k AS jsonb), :g, :st, :o)"),
{"f": fid, "p": uuid.UUID(pid), "q": question, "a": answer, "k": '["check_in_time"]',
"g": SourceType.LLM.value, "st": FactStatus.UNVERIFIED.value, "o": order},
)
return str(fid)
async def _list(client, headers, pid, **params):
return (await client.get(f"/v1/place/{pid}/faq/list", headers=headers, params=params)).json()
async def _transition(client, headers, pid, fid, body):
return (await client.post(f"/v1/place/{pid}/faq/{fid}/transition", headers=headers, json=body)).json()
# ── 조회 ─────────────────────────────────────────────────────────────────
async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client, db_engine):
"""검증: LLM 이 만든 UNVERIFIED FAQ 를 조회한다.
기대결과: 목록엔 보이되 publishable=0 — ★ 승인 전에는 사이트에 나가지 않는다."""
h = await auth_headers("u1")
pid = await _place(client, h)
await _seed_generated_faq(db_engine, pid)
body = await _list(client, h, pid)
assert len(body["faqs"]) == 1
assert body["faqs"][0]["status"] == FactStatus.UNVERIFIED.value
assert body["publishable"] == 0
assert body["pending_review"] == 1
# 사이트 빌드가 보는 것과 같은 집합 — 승인 전에는 비어 있어야 한다.
assert (await _list(client, h, pid, publishable_only=True)).get("faqs", []) == []
async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine):
"""검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다."""
h = await auth_headers("u1")
pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid)
other = await auth_headers("u2")
assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value})
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
# ── 승인 ─────────────────────────────────────────────────────────────────
async def test_approving_faq_makes_it_publishable(auth_headers, client, db_engine):
"""검증: UNVERIFIED FAQ 를 VERIFIED 로 승인한다.
기대결과: 노출 가능해지고 사업장이 재빌드 대상으로 찍힌다(FAQPage 가 바뀌므로)."""
h = await auth_headers("u1")
pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid)
body = await _transition(client, h, pid, fid, {"status": FactStatus.VERIFIED.value})
assert body["result"]["success"] is True
assert body["faq"]["status"] == FactStatus.VERIFIED.value
assert (await _list(client, h, pid))["publishable"] == 1
place = (await client.get(f"/v1/place/{pid}", headers=h)).json()["place"]
assert place["content_updated_at"] is not None
async def test_illegal_transition_is_rejected(auth_headers, client, db_engine):
"""검증: UNVERIFIED → CORRECTED 처럼 전이표에 없는 이동.
기대결과: FACT_INVALID_TRANSITION — 확인을 건너뛴 '정정본'은 만들 수 없다."""
h = await auth_headers("u1")
pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid)
body = await _transition(client, h, pid, fid, {"status": FactStatus.CORRECTED.value, "answer": "15:00 입니다."})
assert body["result"]["code"] == ErrorType.FACT_INVALID_TRANSITION.value
async def test_correction_requires_new_text(auth_headers, client, db_engine):
"""검증: 고친 문구 없이 CORRECTED 로 보낸다.
기대결과: INVALID_REQUEST_DATA — 고친 게 없으면 그건 정정이 아니라 승인(VERIFIED)이다."""
h = await auth_headers("u1")
pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid)
await _transition(client, h, pid, fid, {"status": FactStatus.VERIFIED.value})
body = await _transition(client, h, pid, fid, {"status": FactStatus.CORRECTED.value})
assert body["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value
async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client, db_engine):
"""검증: 사장님이 문구를 고쳐 승인(CORRECTED)한 뒤 COPY 잡이 재생성을 돌린다.
기대결과: 고친 문구가 그대로 남는다 — ★ 자동 생성이 사람의 판단을 덮어쓰지 않는다."""
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_faqs
from common.utils.gtime import GTime
from crud.faq_crud import FaqCRUD
h = await auth_headers("u1")
pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid)
await _transition(client, h, pid, fid, {"status": FactStatus.VERIFIED.value})
body = await _transition(client, h, pid, fid, {"status": FactStatus.CORRECTED.value, "answer": "15시 이후 입실입니다."})
assert body["faq"]["status"] == FactStatus.CORRECTED.value
assert body["faq"]["answer"] == "15시 이후 입실입니다."
# 문장의 책임 주체가 사람으로 넘어왔다는 기록.
assert body["faq"]["generated_by"] == SourceType.OWNER.value
# 재생성이 미확인 FAQ 를 내리는 단계(COPY 잡이 실제로 부르는 그 함수).
await DB_SESSION_MNG.execute_lambda_claim(
place_faqs.DBType(),
lambda s: FaqCRUD().expire_generated(s, uuid.UUID(pid), GTime.UTC()),
)
after = await _list(client, h, pid, publishable_only=True)
assert [f["answer"] for f in after["faqs"]] == ["15시 이후 입실입니다."]
# ── 직접 추가 ────────────────────────────────────────────────────────────
async def test_owner_written_faq_is_published_immediately(auth_headers, client):
"""검증: 사장님이 FAQ 를 직접 쓴다.
기대결과: 바로 노출값(VERIFIED) + 출처는 서버가 OWNER 로 고정 — 사람이 곧 출처다."""
h = await auth_headers("u1")
pid = await _place(client, h)
body = (await client.post(f"/v1/place/{pid}/faq", headers=h,
json={"question": "주차 되나요?", "answer": "무료 주차 가능합니다."})).json()
assert body["faq"]["status"] == FactStatus.VERIFIED.value
assert body["faq"]["generated_by"] == SourceType.OWNER.value
assert body["faq"]["sort_order"] == 0
assert (await _list(client, h, pid))["publishable"] == 1
async def test_owner_faq_appends_after_existing_ones(auth_headers, client, db_engine):
"""검증: 이미 FAQ 가 있는 사업장에 순서 없이 추가한다.
기대결과: 맨 뒤에 붙는다 — 기존 FAQ 순서를 흔들지 않는다."""
h = await auth_headers("u1")
pid = await _place(client, h)
await _seed_generated_faq(db_engine, pid, order=0)
await _seed_generated_faq(db_engine, pid, question="와이파이 되나요?", answer="됩니다.", order=1)
body = (await client.post(f"/v1/place/{pid}/faq", headers=h,
json={"question": "주차 되나요?", "answer": "무료 주차 가능합니다."})).json()
assert body["faq"]["sort_order"] == 2
async def test_blank_faq_is_rejected(auth_headers, client):
"""검증: 공백만 있는 질문으로 FAQ 를 추가한다.
기대결과: INVALID_REQUEST_DATA — 빈 문답은 렌더도 안 되고 고유 콘텐츠로도 안 세진다."""
h = await auth_headers("u1")
pid = await _place(client, h)
body = (await client.post(f"/v1/place/{pid}/faq", headers=h,
json={"question": " ", "answer": "무료 주차 가능합니다."})).json()
assert body["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value