feat(task-021): add admin workflow template api
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user