generated from coulomb/repo-seed
WP-0001 (Foundation & LEVEL1 Core):
- manifest model (FR-100), MD→DOCX builder (FR-200), DOCX→MD importer
(FR-300/400), template family registry (FR-600), drift detector (FR-700),
CLI wiring, pre-commit config, CI skeleton, regression harness
WP-0002 (Service Interfaces & Workflow Orchestration):
- REST service via FastAPI (FR-900): /health, /version, /capabilities,
/templates, /styles, /validate, /build, /import, /compare,
/templates/register, /workflows/{name}, /evidence/{run_id}
- Evidence & report store (FR-1400): JSON-backed, per-run, retrievable
through all interfaces, classification (pass/warnings/failed)
- Composite workflow orchestration (FR-1300): single-file-roundtrip,
multi-file-roundtrip, release-regression, family-switch-build
- MCP server via FastMCP (FR-1000): all tools + resources
- CLI additions: `markidocx serve`, `markidocx workflow`, `markidocx mcp`
- Interface parity tests: CLI / REST / MCP produce equivalent results
135 tests passing, ruff + mypy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
306 lines
9.4 KiB
Python
306 lines
9.4 KiB
Python
"""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}"
|