feat(task-021): add admin workflow template api

This commit is contained in:
2026-05-22 03:45:02 +03:00
parent d1ae5c4437
commit cbd24d4a1c
12 changed files with 2131 additions and 15 deletions
@@ -0,0 +1,391 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AdminWorkflowTemplatesPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-templates.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
self.tmp_dir.cleanup()
def test_admin_creates_workflow_template_with_two_editable_ordered_stages_and_structured_parts(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
create_response = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-v1"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
workflow = create_response.json()["workflow"]
self.assertEqual("article-production-v1", workflow["slug"])
self.assertEqual("DRAFT", workflow["status"])
self.assertEqual(1, workflow["version"])
self.assertEqual([], workflow["stages"])
self.assertTrue(workflow["created_at"])
self.assertTrue(workflow["updated_at"])
intake_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
)
self.assertEqual(201, intake_response.status_code, intake_response.text)
intake_stage = intake_response.json()["stage"]
draft_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
)
self.assertEqual(201, draft_response.status_code, draft_response.text)
draft_stage = draft_response.json()["stage"]
edited_draft_parts = [
{
"key": "draft-outline",
"type": "outline",
"title": "Edited draft outline",
"payload": {
"prompt": "Build a sourced outline before drafting.",
"config": {
"minimum_sections": 5,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
"Claims that need evidence are marked.",
],
},
{
"key": "draft-body",
"type": "generation",
"title": "Draft body",
"payload": {
"prompt": "Generate a complete longform draft.",
"config": {
"tone": "practical",
"include_evidence_markers": True,
},
},
"acceptance_criteria": [
"Draft includes all approved outline sections.",
"Unsupported claims remain marked for review.",
],
},
]
patch_response = self.client.patch(
f"/api/admin/workflows/{workflow['id']}/stages/{draft_stage['id']}",
headers=admin_headers,
json={
"display_name": "Draft and evidence assembly",
"description": "Create an evidence-aware article draft.",
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v2",
"required_inputs": [
"approved_plan",
"evidence_matrix",
],
"expected_outputs": [
"article_draft",
"claim_evidence_map",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
"Evidence markers are preserved.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 120,
},
"parts": edited_draft_parts,
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
detail_response = self.client.get(
f"/api/admin/workflows/{workflow['id']}",
headers=admin_headers,
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
detail = detail_response.json()["workflow"]
stages = detail["stages"]
self.assertEqual([1, 2], [stage["position"] for stage in stages])
self.assertEqual(
["intake-boundary-questions", "draft-assembly"],
[stage["stable_key"] for stage in stages],
)
self.assertEqual(intake_stage["id"], stages[0]["id"])
self.assertEqual(draft_stage["id"], stages[1]["id"])
self.assertEqual(self._intake_stage_payload()["parts"], stages[0]["parts"])
self.assertEqual("Draft and evidence assembly", stages[1]["display_name"])
self.assertEqual("draft-writer-v2", stages[1]["runner_profile_key"])
self.assertTrue(stages[1]["requires_human_approval"])
self.assertEqual(
{
"max_attempts": 2,
"backoff_seconds": 120,
},
stages[1]["retry_policy"],
)
self.assertEqual(edited_draft_parts, stages[1]["parts"])
def test_editor_mutation_is_denied_with_existing_authorization_error_shape(
self,
) -> None:
response = self.client.post(
"/api/admin/workflows",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._workflow_payload("editor-denied-workflow"),
)
self.assertEqual(403, response.status_code, response.text)
self.assertEqual({"detail": "Forbidden"}, response.json())
def test_admin_reorders_activates_archives_and_audits_editor_visible_workflow(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
editor_headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
workflow = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-ops"),
).json()["workflow"]
workflow_id = workflow["id"]
intake_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
).json()["stage"]
draft_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
).json()["stage"]
reorder_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages/reorder",
headers=admin_headers,
json={"stage_ids": [draft_stage["id"], intake_stage["id"]]},
)
self.assertEqual(200, reorder_response.status_code, reorder_response.text)
self.assertEqual(
["draft-assembly", "intake-boundary-questions"],
[
stage["stable_key"]
for stage in reorder_response.json()["workflow"]["stages"]
],
)
activate_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/activate",
headers=admin_headers,
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
activated = activate_response.json()["workflow"]
self.assertEqual("ACTIVE", activated["status"])
self.assertEqual(2, activated["version"])
editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(200, editor_list_response.status_code, editor_list_response.text)
self.assertIn(
workflow_id,
[item["id"] for item in editor_list_response.json()["workflows"]],
)
editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(200, editor_detail_response.status_code, editor_detail_response.text)
immutable_patch_response = self.client.patch(
f"/api/admin/workflows/{workflow_id}/stages/{draft_stage['id']}",
headers=admin_headers,
json={"display_name": "Should not mutate active template"},
)
self.assertEqual(409, immutable_patch_response.status_code, immutable_patch_response.text)
archive_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/archive",
headers=admin_headers,
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
self.assertEqual("ARCHIVED", archive_response.json()["workflow"]["status"])
archived_editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(
200,
archived_editor_list_response.status_code,
archived_editor_list_response.text,
)
self.assertNotIn(
workflow_id,
[item["id"] for item in archived_editor_list_response.json()["workflows"]],
)
archived_editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(
404,
archived_editor_detail_response.status_code,
archived_editor_detail_response.text,
)
audit_response = self.client.get(
f"/api/admin/workflows/{workflow_id}/audit",
headers=admin_headers,
)
self.assertEqual(200, audit_response.status_code, audit_response.text)
event_types = {
event["event_type"] for event in audit_response.json()["events"]
}
self.assertTrue(
{
"WORKFLOW_TEMPLATE_CREATED",
"WORKFLOW_STAGE_CREATED",
"WORKFLOW_STAGES_REORDERED",
"WORKFLOW_TEMPLATE_ACTIVATED",
"WORKFLOW_TEMPLATE_ARCHIVED",
}.issubset(event_types)
)
def _workflow_payload(self, slug: str) -> dict[str, str]:
return {
"name": "Article production workflow",
"slug": slug,
"description": "Reusable editorial workflow for longform article production.",
}
def _intake_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before planning.",
"position": 1,
"owner_role": "EDITOR",
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": [
"article_brief",
"target_site",
],
"expected_outputs": [
"answered_boundary_questions",
],
"acceptance_criteria": [
"All required boundary questions are answered.",
"Answers are tied to the source brief.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 1,
"backoff_seconds": 0,
},
"parts": [
{
"key": "required-context-checklist",
"type": "checklist",
"title": "Required context checklist",
"payload": {
"prompt": "Identify missing audience, keyword, and source constraints.",
"config": {
"required_fields": [
"audience",
"primary_keyword",
"source_rules",
],
"block_on_missing": True,
},
},
"acceptance_criteria": [
"Missing required context is listed explicitly.",
"No free-text parsing is needed to read the checklist.",
],
}
],
}
def _draft_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Generate a first draft from the approved plan.",
"position": 2,
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v1",
"required_inputs": [
"approved_plan",
],
"expected_outputs": [
"article_draft",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
],
"requires_human_approval": False,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 60,
},
"parts": [
{
"key": "draft-outline",
"type": "outline",
"title": "Draft outline",
"payload": {
"prompt": "Build a concise outline before drafting.",
"config": {
"minimum_sections": 4,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
],
}
],
}
if __name__ == "__main__":
unittest.main()