"""납품 산출물(dist/)이 소스와 어긋났는지 검사. dist/ 는 gitignore 대상 빌드 출력이라 git 이 드리프트를 알려주지 않는다. 실제로 기능 커밋 4개가 들어간 뒤 번들이 재생성되지 않아, Report/Export/Actions 컨트롤러와 마이그레이션 1건이 빠진 패키지가 납품 직전까지 남아 있었다. 스테일 번들은 '깨진' 상태가 아니라 '내부적으로 정합적인 구버전'이라 SHA256SUMS 도 통과한다 — 그래서 소스와 대조하는 검사가 따로 필요하다. dist/ 가 없으면(클린 체크아웃) 스킵한다. 있으면 소스와 같아야 한다. """ import hashlib from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] DIST = ROOT / "dist" CAFE24_PRIV = DIST / "1_cafe24_upload" / "move_detect_app" PI_SRC = DIST / "2_pi_install" / "src" / "edge" needs_dist = pytest.mark.skipif(not DIST.is_dir(), reason="dist/ not built in this checkout") def _digest(p: Path) -> str: return hashlib.sha256(p.read_bytes()).hexdigest() def _compare_tree(src: Path, packaged: Path, suffix: str) -> tuple[list[str], list[str]]: """(빠진 파일, 내용이 다른 파일) — 소스 기준.""" missing, differing = [], [] for f in sorted(src.rglob(f"*{suffix}")): if "__pycache__" in f.parts: continue rel = f.relative_to(src) target = packaged / rel if not target.is_file(): missing.append(str(rel)) elif _digest(f) != _digest(target): differing.append(str(rel)) return missing, differing @needs_dist @pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built") def test_cafe24_package_matches_server_source(): missing, differing = _compare_tree(ROOT / "server" / "src", CAFE24_PRIV / "src", ".php") assert not missing, f"번들에 없는 서버 소스(생성기 재실행 필요): {missing}" assert not differing, f"번들과 소스 내용 불일치: {differing}" @needs_dist @pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built") def test_cafe24_package_ships_every_migration_and_current_schema(): # 마이그레이션이 하나라도 빠지면 기존 DB 업그레이드가 전 요청 500 이 된다. src = sorted(p.name for p in (ROOT / "server" / "migrations").glob("*.sql")) packaged = sorted(p.name for p in (CAFE24_PRIV / "migrations").glob("*.sql")) assert src == packaged, "동봉 마이그레이션이 소스와 다름 — 생성기 재실행 필요" assert _digest(ROOT / "server" / "schema.sql") == _digest(CAFE24_PRIV / "schema.sql") @needs_dist @pytest.mark.skipif(not PI_SRC.is_dir(), reason="pi bundle not built") def test_pi_bundle_matches_edge_source(): missing, differing = _compare_tree(ROOT / "edge", PI_SRC, ".py") assert not missing, f"번들에 없는 엣지 소스(생성기 재실행 필요): {missing}" assert not differing, f"번들과 소스 내용 불일치: {differing}" @needs_dist @pytest.mark.skipif(not (DIST / "2_pi_install" / "MANIFEST.txt").is_file(), reason="pi bundle not built") def test_pi_bundle_records_its_provenance(): # 버전·커밋이 없으면 현장에 올라간 빌드를 나중에 식별할 수 없다 — 스테일 번들이 조용히 # 통과한 구조적 원인. text = (DIST / "2_pi_install" / "MANIFEST.txt").read_text(encoding="utf-8") for field in ("version:", "git_commit:", "built_at:"): assert field in text, f"MANIFEST.txt에 {field} 없음" assert "version: unknown" not in text @needs_dist @pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built") def test_build_info_stamps_the_commit_it_actually_ships(): """BUILD-INFO 의 git_commit 이 현재 HEAD 와 같은가. 1.1.0 에서 이 결함(R3-6)을 고쳤는데 1.2.0 에서 그대로 재발했다 — 릴리스 커밋 **전에** 패키지를 만들면 스탬프가 코드를 담지 않은 커밋을 가리킨다. 기존 테스트는 필드의 존재만 확인했으므로 두 번 다 통과했다. 스탬프가 틀리면 나중에 "고객이 받은 것이 어느 코드인지"를 되짚을 방법이 사라진다. 절차로 막으려다 두 번 실패했으므로 테스트로 막는다. """ import subprocess info = (CAFE24_PRIV / "BUILD-INFO.txt").read_text(encoding="utf-8") stamped = next((l.split(":", 1)[1].strip() for l in info.splitlines() if l.startswith("git_commit:")), None) assert stamped, f"BUILD-INFO 에 git_commit 이 없습니다:\n{info}" if stamped in ("unknown", ""): pytest.skip("git 정보 없이 빌드된 패키지") head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip() assert stamped == head, ( f"패키지가 담은 코드와 스탬프가 다릅니다: BUILD-INFO={stamped}, HEAD={head}. " "릴리스 커밋 이후에 생성기를 다시 돌리십시오.") @needs_dist @pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built") def test_upload_readme_lists_every_migration(): """업그레이드 안내의 마이그레이션 표가 실제 파일을 전부 담는가. 빠지면 업그레이드한 고객이 그 마이그레이션을 영영 실행하지 않는다. 1.2.0 직전 상태에서 두 개가 빠져 있었고, 그중 하나가 없으면 신규 이벤트 유형의 INSERT 가 DB CHECK 에 걸려 전부 거부된다 — 감지기는 도는데 대시보드에 아무것도 안 뜨는, 이 프로젝트가 반복해서 잡아온 조용한 실패다. """ readme = (ROOT / "server" / "deploy" / "README-UPLOAD.md").read_text(encoding="utf-8") for sql in sorted((ROOT / "server" / "migrations").glob("*.sql")): assert sql.name in readme, ( f"업그레이드 안내에 {sql.name} 이 없습니다 — 업그레이드한 현장은 이 마이그레이션을 " "영영 실행하지 않습니다")