o2o-site-AEO/solution/backend/tests/test_schema_ddl.py
Mina Choi 328d9e18ee [feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

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

111 lines
5.2 KiB
Python

"""init.sql ↔ ORM 모델 정합성.
스키마 정의가 두 곳(마이그레이션 SQL / SQLAlchemy 모델)에 있으므로 둘이 어긋나면
"테스트는 통과하는데 실서버에서 컬럼이 없는" 상황이 난다. DB 없이 파일만 비교해 그걸 잡는다.
"""
import re
from pathlib import Path
from common.database.model.models import MAIN_BASE
# parents[3] = 레포 루트 (tests → backend → solution → 루트).
# ★ 백엔드가 solution/ 안으로 들어가면서 한 칸 깊어졌다 — 폴더를 옮기면 여기부터 깨진다.
_INIT_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "init-data" / "init.sql"
_CREATE_TABLE_RE = re.compile(
r"CREATE TABLE IF NOT EXISTS\s+(?P<schema>\w+)\.(?P<table>\w+)\s*\((?P<body>.*?)\n\);",
re.S | re.I,
)
def _parse_init_sql() -> dict:
"""init.sql → {"schema.table": {컬럼명, ...}}"""
sql = _INIT_SQL.read_text(encoding="utf-8")
out = {}
for m in _CREATE_TABLE_RE.finditer(sql):
columns = set()
for line in m.group("body").splitlines():
line = line.strip()
if not line or line.startswith("--"):
continue
name = line.split()[0]
if name.upper() in ("PRIMARY", "UNIQUE", "CONSTRAINT", "FOREIGN", "CHECK"):
continue
columns.add(name)
out[f"{m.group('schema')}.{m.group('table')}"] = columns
return out
# init.sql 에는 있고 ORM 모델은 없는 표. 마이그레이션 대장은 scripts/migrate.py 가 소유하고
# 애플리케이션 코드가 읽지 않는다 — 모델을 만들면 도메인 표처럼 보인다.
_NOT_ORM = {"public.schema_migrations"}
def _model_tables() -> set:
"""ORM 모델 → {"public.table", ...}
★ 모델은 스키마를 적지 않는다(`Table.schema is None`) — 2026-09-09 에 도메인 스키마를
걷어내고 public 한 벌로 폈기 때문이다(migrations/0005). init.sql 은 `public.` 을
명시하므로 여기서 같은 모양으로 맞춰 준다. `t.schema` 를 그대로 쓰면 "None.users" 가
되어 **모든 표가 누락으로 잡힌다** — 실제로 그렇게 이 테스트가 통째로 빨개졌다.
"""
return {f"{t.schema or 'public'}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables}
def test_init_sql_is_readable():
"""검증: init.sql 을 찾고 파싱할 수 있는지.
기대결과: 파일이 존재하고 CREATE TABLE 이 1개 이상 파싱된다."""
assert _INIT_SQL.exists(), f"init.sql 이 없다: {_INIT_SQL}"
assert _parse_init_sql(), "init.sql 에서 CREATE TABLE 을 하나도 파싱하지 못했다"
def test_every_model_table_exists_in_init_sql():
"""검증: ORM 모델의 모든 테이블이 init.sql 에도 있는지.
기대결과: 누락 없음 — 모델만 추가하고 마이그레이션을 안 쓴 경우를 잡는다."""
sql_tables = set(_parse_init_sql())
model_tables = _model_tables()
missing = sorted(model_tables - sql_tables)
assert not missing, f"init.sql 에 없는 모델 테이블: {missing}"
def test_every_init_sql_table_has_a_model():
"""검증: init.sql 의 모든 테이블에 ORM 모델이 있는지.
기대결과: 누락 없음 — 마이그레이션만 쓰고 모델을 안 만든 경우를 잡는다."""
sql_tables = set(_parse_init_sql()) - _NOT_ORM
model_tables = _model_tables()
missing = sorted(sql_tables - model_tables)
assert not missing, f"ORM 모델이 없는 init.sql 테이블: {missing}"
def test_columns_match_between_model_and_init_sql():
"""검증: 테이블마다 컬럼 집합이 양쪽에서 같은지.
기대결과: 완전 일치 — 한쪽에만 추가된 컬럼을 잡는다."""
sql_tables = _parse_init_sql()
problems = []
compared = 0
for table in MAIN_BASE.metadata.sorted_tables:
name = f"{table.schema or 'public'}.{table.name}"
if name not in sql_tables:
continue
compared += 1
model_cols = {c.name for c in table.columns}
sql_cols = sql_tables[name]
if model_cols != sql_cols:
problems.append(
f"{name}: 모델에만 {sorted(model_cols - sql_cols)} / init.sql 에만 {sorted(sql_cols - model_cols)}"
)
assert not problems, "컬럼 불일치:\n" + "\n".join(problems)
# ★ 한 표도 못 찾으면 이 테스트는 아무것도 검사하지 않고 통과한다. 실제로 그랬다 —
# 이름을 "None.users" 로 만들어 전부 건너뛰었고, 그동안 init.sql 이 조용히 어긋났다.
assert compared == len(MAIN_BASE.metadata.sorted_tables), (
f"init.sql 에서 {compared}/{len(MAIN_BASE.metadata.sorted_tables)} 개만 찾았다 — 이름 규칙이 어긋났다"
)
def test_every_table_has_soft_delete_columns():
"""검증: 모든 테이블이 공통 컬럼(created_at·updated_at·deleted)을 갖는지.
기대결과: MainTableMixin 을 빠뜨린 모델이 없다 — 소프트 삭제 전제가 깨지면 유니크 부분 인덱스도 깨진다."""
for table in MAIN_BASE.metadata.sorted_tables:
cols = {c.name for c in table.columns}
assert {"created_at", "updated_at", "deleted"} <= cols, f"{table.schema}.{table.name}: 공통 컬럼 누락"