The demo seed plants exactly one failed delivery (BK-H-0020) to demonstrate retry and audit. Because derive_n8n_status() counted any failure, every fresh reset pinned the n8n integration to "degraded" -- the demo showed a warning about a prop, which tells a viewer something untrue about the automation. The seeded failure now carries its own error code, demoScenarioTimeout, rather than the generic connectionError a real timeout produces. No column and no migration: last_error_code already existed, is already surfaced to the UI and is already localizable. - integration status splits failed into unexpected_failed and demo_scenario_failed; only unexpected failures may move the state. A staged failure alone leaves n8n operational. - latest_failure_at is a health signal and now ignores the staged failure; latest_demo_scenario_at reports it separately. - /api/v1/workflows exposes is_demo_scenario. The Automation page labels the run as a prepared demo scenario, explains that it is a simulated temporary failure that does not affect automation health, and offers a distinct "retry demo scenario" action. Translated in nl-BE, en-GB and fr-BE. - the carve-out stays narrow: a real failure still degrades n8n, and a genuine later failure of the same event overwrites the demo code with the real one. - the retry itself is unchanged and real: the event goes back on the outbox and the dispatcher delivers it to n8n like any other, so 19+1 becomes 20+0 only on an actual round trip. The audit records which kind of failure was retried. Tests that assert on the seeded scenario now reseed first, since earlier test files legitimately mutate the outbox and the suite shares one database. Verified locally against a real PostgreSQL 16: 181 passed, ruff clean, mypy clean (50 files), tsc clean, frontend build clean. Not deployed and not browser-verified.
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
from app.core.db import SessionLocal
|
|
from app.seed_loader import reset_and_seed
|
|
|
|
|
|
def _reseed() -> None:
|
|
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
|
|
|
|
The suite shares one session-scoped database and earlier files legitimately mutate
|
|
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
|
|
rather than depend on file ordering.
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
reset_and_seed(db)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_list_workflows_requires_operations_manager(employee_client):
|
|
response = employee_client.get("/api/v1/workflows")
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_list_workflows_includes_seeded_failed_run(ops_client):
|
|
_reseed()
|
|
response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
|
|
assert response.status_code == 200
|
|
runs = response.json()
|
|
assert len(runs) >= 1
|
|
assert all(r["status"] == "failed" for r in runs)
|
|
|
|
|
|
def test_retry_requires_failed_status(ops_client):
|
|
succeeded = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
|
|
target = succeeded[0]["event_id"]
|
|
response = ops_client.post(f"/api/v1/workflows/{target}/retry")
|
|
assert response.status_code == 409
|
|
assert response.json()["error"]["code"] == "NOT_RETRYABLE"
|
|
|
|
|
|
def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
|
|
_reseed()
|
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
|
target = failed[0]["event_id"]
|
|
|
|
response = ops_client.post(f"/api/v1/workflows/{target}/retry")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "pending"
|
|
|
|
audit_events = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
|
|
assert len(audit_events) >= 1
|
|
|
|
|
|
def test_retry_requires_operations_manager(employee_client):
|
|
response = employee_client.post(
|
|
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client):
|
|
"""The one seeded failure must announce itself as staged. An unexplained red row in
|
|
a demo reads as a broken product; a labelled one reads as the retry story it is."""
|
|
_reseed()
|
|
runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
|
demo_runs = [run for run in runs if run["is_demo_scenario"]]
|
|
assert len(demo_runs) == 1
|
|
assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout"
|
|
assert demo_runs[0]["aggregate_ref"] == "BK-H-0020"
|
|
|
|
|
|
def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client):
|
|
runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
|
|
assert runs
|
|
assert all(run["is_demo_scenario"] is False for run in runs)
|
|
|
|
|
|
def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client):
|
|
"""The retry is a real redelivery either way; the audit records which kind of
|
|
failure it resolved so a staged retry is never mistaken for a production fix."""
|
|
_reseed()
|
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
|
demo_run = next(run for run in failed if run["is_demo_scenario"])
|
|
|
|
response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "pending"
|
|
|
|
audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
|
|
entry = next(
|
|
event
|
|
for event in audit
|
|
if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id")
|
|
== demo_run["event_id"]
|
|
)
|
|
metadata = entry.get("metadata") or entry.get("metadata_json") or {}
|
|
assert metadata["demo_scenario"] is True
|