"""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\w+)\.(?P\w+)\s*\((?P.*?)\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}: 공통 컬럼 누락"