Files
marki-docx/tests/test_rest_endpoints.py
Bernd Worsch 1f3dddf7d6 feat: WP-0001 + WP-0002 complete — LEVEL1 core + service interfaces
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>
2026-03-16 07:46:31 +00:00

306 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for T02 — REST functional endpoints (FR-902908, FR-913916)."""
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}"