"""S1-ADD (plan ds-s1-backend-1) A-4 — call-shape regression + md_status 매핑 동작 검증. 검증 대상 (값이 아니라 *동작*): 1. ★ DB md_status='success' → 응답 'completed' 단방향 매핑 (P0-3 silent-fallback 함정 가드의 backend 절반). - partial/pending/failed/skipped/None 은 그대로 통과 ('success' 만 매핑). 2. [S1-ADD] 3필드(original_filename / duplicate_of / duplicate_count) 디코드 + 기본값(duplicate_count=0). 3. (있으면) ds-app contract fixtures 가 응답 모델로 디코드 — 계약 shape 비파괴. 주의 — 이 테스트는 backend 직렬화 절반만 커버한다. 앱이 'completed' 를 실제 md-first 렌더 분기로 태우는지(¬extracted_text)는 S3 fixture-render 테스트가 책임진다 (A 그룹 close = 본 backend green AND S3 render green, owner 명기 — plan A-4). 실행 환경: app/ 의존성 설치된 컨텍스트(devsbx/GPU). 순수 단위(DB 불요). """ from __future__ import annotations import json import logging import os import sys from datetime import datetime, timezone from pathlib import Path import pytest # logs/ 가 운영 daemon(root) 소유일 때 import-time FileHandler PermissionError 방어 (test 한정). _orig_file_handler = logging.FileHandler def _safe_file_handler(filename, *args, **kwargs): # type: ignore[no-untyped-def] try: return _orig_file_handler(filename, *args, **kwargs) except PermissionError: return logging.NullHandler() logging.FileHandler = _safe_file_handler # type: ignore[assignment] # api.documents import 가 SQLAlchemy engine init 를 트리거 — dummy DATABASE_URL (실제 connect X). os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test") # tests/ → 프로젝트 루트 → app/ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app")) from api.documents import ( # noqa: E402 DocumentDetailResponse, DocumentListResponse, DocumentResponse, ) _NOW = datetime(2026, 6, 4, 8, 0, 0, tzinfo=timezone.utc) def _base_detail(**overrides) -> dict: """DocumentDetailResponse 가 요구하는 전 필드(필수 포함) 완비 dict. overrides 로 일부 교체.""" d = { "id": 4912, "file_path": "Engineering/ASME/x.pdf", "file_format": "pdf", "file_size": 1338920, "file_type": "document", "title": "x", "ai_domain": "Engineering", "ai_sub_group": "압력용기", "ai_tags": ["ASME"], "ai_summary": "요약", "document_type": "standard", "importance": "high", "ai_confidence": 0.9, "user_note": None, "user_tags": None, "pinned": True, "ask_includable": True, "derived_path": None, "original_format": "pdf", "conversion_status": "completed", "is_read": True, "review_status": "approved", "edit_url": None, "preview_status": "ready", "source_channel": "upload", "data_origin": "external", "doc_purpose": "reference", "extracted_at": _NOW, "ai_processed_at": _NOW, "embedded_at": _NOW, "created_at": _NOW, "updated_at": _NOW, # detail 전용 "extracted_text": "원문 폴백 텍스트", "md_content": "# 제목\n본문", "md_frontmatter": {}, "md_status": "success", "md_extraction_engine": "marker", "md_generated_at": _NOW, } d.update(overrides) return d # ── 1. ★ md_status 단방향 매핑 (success → completed) ────────────────────────── def test_db_success_serializes_as_completed(): m = DocumentDetailResponse.model_validate(_base_detail(md_status="success")) assert m.md_status == "completed", "DB 'success' 는 응답에서 'completed' 로 매핑돼야 함(MD-first 렌더 트리거)" # model_dump(직렬화) 까지 확인 — 앱이 받는 실제 값. assert m.model_dump()["md_status"] == "completed" @pytest.mark.parametrize("raw", ["pending", "processing", "partial", "failed", "skipped", None]) def test_non_success_statuses_pass_through(raw): m = DocumentDetailResponse.model_validate(_base_detail(md_status=raw)) assert m.md_status == raw, f"'{raw}' 는 매핑 대상 아님 — 그대로 통과해야 함" def test_mapping_is_read_only_not_a_write_path(): # 이 모델은 응답 직렬화 전용 — write(ORM) 경로가 'completed' 를 DB 로 되쓰지 않는지의 1차 방어선. # 'completed' 입력이 들어와도(예: fixture) 그대로 'completed' (재매핑 없음, 멱등). m = DocumentDetailResponse.model_validate(_base_detail(md_status="completed")) assert m.md_status == "completed" # ── 2. [S1-ADD] 3필드 디코드 + 기본값 ──────────────────────────────────────── def test_s1add_fields_default_on_list_response(): # DocumentResponse(리스트 행)에도 3필드 존재 — 미제공 시 기본값. base = {k: v for k, v in _base_detail().items() if k not in {"extracted_text", "md_content", "md_frontmatter", "md_status", "md_extraction_engine", "md_generated_at"}} m = DocumentResponse.model_validate(base) assert m.duplicate_count == 0 assert m.duplicate_of is None assert m.original_filename is None def test_s1add_fields_roundtrip_values(): m = DocumentDetailResponse.model_validate( _base_detail(original_filename="보고서.docx", duplicate_of=4912, duplicate_count=2) ) assert m.original_filename == "보고서.docx" assert m.duplicate_of == 4912 assert m.duplicate_count == 2 # ── 3. ds-app contract fixtures 디코드 (있으면) ────────────────────────────── _FIXDIR = Path(os.path.expanduser("~/Documents/code/ds-app/contract/fixtures")) @pytest.mark.skipif(not _FIXDIR.exists(), reason="ds-app contract fixtures 미존재(독립 repo) — 디코드 회귀 skip") @pytest.mark.parametrize("fname", ["document_detail.json", "document_detail_pending_md.json"]) def test_contract_detail_fixture_decodes(fname): payload = json.loads((_FIXDIR / fname).read_text()) m = DocumentDetailResponse.model_validate(payload) # fixture 의 md_status 는 이미 API 어휘('completed'/'pending') — 매핑 멱등. assert m.md_status == payload["md_status"] # [S1-ADD] 필드가 fixture 에 있으면 디코드 일치. if "duplicate_count" in payload: assert m.duplicate_count == payload["duplicate_count"] @pytest.mark.skipif(not _FIXDIR.exists(), reason="ds-app contract fixtures 미존재") def test_contract_list_fixture_decodes(): payload = json.loads((_FIXDIR / "documents_list.json").read_text()) m = DocumentListResponse.model_validate(payload) assert m.total == payload["total"] assert len(m.items) == len(payload["items"])