68e2d7ea04
plan ds-s1-backend-1 (r5 수렴). 코드만 스테이징 — migration 미적용(restart 보류, E-2 Soft Lock 예외창). A (앱 v1 디코딩 비파괴 최소선): - A-1 migrations/287_documents_dedup_fields.sql: original_filename TEXT / duplicate_of BIGINT FK ON DELETE SET NULL / duplicate_count INTEGER NOT NULL DEFAULT 0. 단일 statement·PG16 fast-path·BEGIN/COMMIT 금지. backfill 미포함(B-4). - A-2 app/models/document.py: 1계층 블록에 3 mapped_column (+ ForeignKey import). md_* 는 기존. - A-3 app/api/documents.py: DocumentResponse 3필드(duplicate_count=0 non-opt) + DocumentDetailResponse field_validator(success→completed, mode=before) — read-time DB→API 단방향, write(ORM) 미적용. - A-4 tests/test_s1_dedup_shape.py: success→completed 동작 + 비-success 통과 + 3필드 디폴트/roundtrip + ds-app contract fixture 디코드(skip-if-absent). py_compile OK. ★ backend 절반 — 전체 비파괴는 S3 render 테스트와 AND. C-1 PoC (워커 미연결 — C-2 에서 marker_worker 분기 연결): - app/workers/office_md.py: OOXML=markitdown(신규 dep, lazy) / hwp·hwpx=LibreOffice headless→HTML→markdownify(기존 dep). 실패·빈출력·타임아웃·dep부재 → OfficeMdError raise (success+빈md 금지 = C-5 postcondition 의 변환기 계약). - scripts/poc_office_md.py: 표 fidelity 측정 하니스. E-1 = prod LibreOffice 버전핀 안전컨텍스트 실행(hwpx 필터 버전 의존). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.9 KiB
Python
169 lines
6.9 KiB
Python
"""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"])
|