"""Tests for T02 — REST functional endpoints (FR-902–908, FR-913–916).""" from __future__ import annotations import base64 import textwrap import pytest from fastapi.testclient import TestClient from markidocx.rest import create_app SIMPLE_MANIFEST = textwrap.dedent("""\ project: name: "Test Document" feature_level: level1 family: article sources: - path: doc.md output: dir: ./dist metadata: title: "Test Document" """) SIMPLE_MARKDOWN = textwrap.dedent("""\ # Hello World This is a paragraph with **bold** text. ## Section One - Item one - Item two """) @pytest.fixture() def client() -> TestClient: return TestClient(create_app()) def _build_docx_b64(manifest_yaml: str, sources: list[dict]) -> str: """Helper: POST /build and return the docx_base64 from the response.""" resp = TestClient(create_app()).post( "/build", json={"manifest_yaml": manifest_yaml, "sources": sources} ) assert resp.status_code == 200 return resp.json()["outputs"]["docx_base64"] # --------------------------------------------------------------------------- # POST /validate (FR-902) # --------------------------------------------------------------------------- def test_validate_valid_manifest(client: TestClient) -> None: resp = client.post("/validate", json={"manifest_yaml": SIMPLE_MANIFEST}) assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" assert body["outputs"]["project"] == "Test Document" def test_validate_invalid_manifest(client: TestClient) -> None: resp = client.post("/validate", json={"manifest_yaml": "not: valid: yaml: manifest"}) assert resp.status_code == 200 body = resp.json() assert body["status"] == "error" assert body["errors"] def test_validate_response_has_context(client: TestClient) -> None: resp = client.post("/validate", json={"manifest_yaml": SIMPLE_MANIFEST}) body = resp.json() assert "family" in body["context"] assert "feature_level" in body["context"] # --------------------------------------------------------------------------- # POST /build (FR-903) # --------------------------------------------------------------------------- def test_build_returns_docx_base64(client: TestClient) -> None: resp = client.post( "/build", json={ "manifest_yaml": SIMPLE_MANIFEST, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" docx_bytes = base64.b64decode(body["outputs"]["docx_base64"]) # DOCX files are ZIP archives starting with PK assert docx_bytes[:2] == b"PK" def test_build_response_has_family_context(client: TestClient) -> None: resp = client.post( "/build", json={ "manifest_yaml": SIMPLE_MANIFEST, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ) body = resp.json() assert body["context"]["family"] == "article" assert body["context"]["feature_level"] == "level1" def test_build_invalid_manifest_returns_error(client: TestClient) -> None: resp = client.post( "/build", json={"manifest_yaml": "project:\n name: x\n", "sources": []}, ) assert resp.json()["status"] == "error" def test_build_includes_warnings_list(client: TestClient) -> None: resp = client.post( "/build", json={ "manifest_yaml": SIMPLE_MANIFEST, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ) body = resp.json() assert isinstance(body["warnings"], list) # --------------------------------------------------------------------------- # POST /import (FR-904) # --------------------------------------------------------------------------- def test_import_returns_markdown(client: TestClient) -> None: docx_b64 = _build_docx_b64( SIMPLE_MANIFEST, [{"name": "doc.md", "content": SIMPLE_MARKDOWN}] ) resp = client.post( "/import", json={"manifest_yaml": SIMPLE_MANIFEST, "docx_base64": docx_b64}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" assert body["outputs"]["mapping_status"] in ("redistributed", "merged") assert isinstance(body["outputs"]["files"], dict) def test_import_bad_docx_returns_error(client: TestClient) -> None: bad_b64 = base64.b64encode(b"not a docx").decode() resp = client.post( "/import", json={"manifest_yaml": SIMPLE_MANIFEST, "docx_base64": bad_b64}, ) assert resp.json()["status"] == "error" # --------------------------------------------------------------------------- # POST /compare (FR-905) # --------------------------------------------------------------------------- def test_compare_returns_drift_report(client: TestClient) -> None: docx_b64 = _build_docx_b64( SIMPLE_MANIFEST, [{"name": "doc.md", "content": SIMPLE_MARKDOWN}] ) resp = client.post( "/compare", json={ "manifest_yaml": SIMPLE_MANIFEST, "docx_base64": docx_b64, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" assert "has_drift" in body["outputs"] assert "preserved" in body["outputs"] assert "degraded" in body["outputs"] assert "broken" in body["outputs"] def test_compare_bad_docx_returns_error(client: TestClient) -> None: bad_b64 = base64.b64encode(b"not a docx").decode() resp = client.post( "/compare", json={ "manifest_yaml": SIMPLE_MANIFEST, "docx_base64": bad_b64, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ) assert resp.json()["status"] == "error" # --------------------------------------------------------------------------- # POST /templates/register (FR-908) # --------------------------------------------------------------------------- def test_register_template_invalid_bytes(client: TestClient) -> None: """Submitting non-DOCX bytes should produce an error (file has .docx ext but wrong content).""" # The FamilyRegistry checks extension, not magic bytes, so we need to supply a valid-looking docx # Use an actual DOCX from a build to register docx_b64 = _build_docx_b64( SIMPLE_MANIFEST, [{"name": "doc.md", "content": SIMPLE_MARKDOWN}] ) resp = client.post( "/templates/register", json={"name": "custom-family", "docx_base64": docx_b64, "description": "test"}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" assert body["outputs"]["name"] == "custom-family" # --------------------------------------------------------------------------- # POST /workflows/{name} (FR-913) # --------------------------------------------------------------------------- def test_invoke_workflow_single_file_roundtrip(client: TestClient) -> None: resp = client.post( "/workflows/single-file-roundtrip", json={ "manifest_yaml": SIMPLE_MANIFEST, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ) assert resp.status_code == 200 body = resp.json() assert body["status"] in ("ok", "error") assert "run_id" in body["outputs"] assert "classification" in body["outputs"] assert "steps" in body["outputs"] def test_invoke_unknown_workflow_returns_error(client: TestClient) -> None: resp = client.post( "/workflows/no-such-workflow", json={"manifest_yaml": SIMPLE_MANIFEST, "sources": []}, ) assert resp.status_code == 200 assert resp.json()["status"] == "error" def test_invoke_workflow_context_has_run_id(client: TestClient) -> None: resp = client.post( "/workflows/single-file-roundtrip", json={ "manifest_yaml": SIMPLE_MANIFEST, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ) body = resp.json() assert "run_id" in body["context"] assert "workflow" in body["context"] # --------------------------------------------------------------------------- # GET /evidence/{run_id} (FR-914) # --------------------------------------------------------------------------- def test_get_evidence_not_found(client: TestClient) -> None: resp = client.get("/evidence/no-such-run-id-xyz") assert resp.status_code == 200 body = resp.json() assert body["status"] == "not_found" # --------------------------------------------------------------------------- # Response envelope completeness (FR-915, FR-916) # --------------------------------------------------------------------------- @pytest.mark.parametrize( "method,path,body", [ ("POST", "/validate", {"manifest_yaml": SIMPLE_MANIFEST}), ( "POST", "/build", { "manifest_yaml": SIMPLE_MANIFEST, "sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}], }, ), ], ) def test_response_envelope_complete(client: TestClient, method: str, path: str, body: dict) -> None: resp = client.request(method, path, json=body) result = resp.json() for field in ("status", "outputs", "warnings", "errors", "context"): assert field in result, f"Missing field '{field}' in response from {path}"