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}"
|