Files
pesco-ncr/backend/tests/test_state_machine.py
ang3l12 dea316b113 Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint
prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend,
React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS,
group-gated), Microsoft Graph delegated Mail.Send notifications (OBO),
six-stage workflow state machine with server-side enforcement, atomic
NCR-YYYY-NNNN numbering, attachments with camera capture, immutable
field-level audit trail, admin reopen, reports + CSV export, WeasyPrint
PDF traveler, Power BI reporting views + read-only DB user, documented
VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose
deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:41:22 -06:00

245 lines
8.6 KiB
Python

"""Workflow state machine: happy paths, invalid transitions, closure locking,
admin reopen, and rich-text sanitization."""
from .util import (
create_ncr,
do_initial_disposition,
hdr,
to_closed,
to_costing,
to_operations,
to_qc_inspection,
user_id_by_email,
)
async def test_full_lifecycle_direct_to_operations(client, team):
ncr = await create_ncr(client, team)
assert ncr["stage"] == "new_request"
assert ncr["ncr_number"].startswith("NCR-")
ncr = await to_operations(client, team, ncr["id"])
assert ncr["stage"] == "operations"
assert ncr["secondary_review_needed"] is False
assert ncr["qc_authority"] == "AS9100 8.7"
r = await client.post(
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"])
)
body = r.json()["ncr"]
assert body["stage"] == "qc_inspection"
assert body["operations_complete"] is True
assert body["operations_completed_by"]["email"] == team["ops"]
# QC can save repeatedly without closing
r = await client.post(
f"/api/ncrs/{ncr['id']}/inspection",
json={"qc_approval": "no", "inspection_notes": "First pass failed."},
headers=hdr(team["qc"]),
)
assert r.json()["ncr"]["stage"] == "qc_inspection"
r = await client.post(
f"/api/ncrs/{ncr['id']}/inspection",
json={"qc_approval": "yes", "inspection_notes": "Rework verified.", "qc_closed": True},
headers=hdr(team["qc"]),
)
body = r.json()["ncr"]
assert body["stage"] == "costing"
assert body["qc_closed"] is True
r = await client.post(
f"/api/ncrs/{ncr['id']}/costing",
json={
"labor_cost": "100.00",
"material_cost": "50.25",
"service_cost": "0",
"other_cost": "10",
},
headers=hdr(team["cost"]),
)
body = r.json()["ncr"]
assert body["stage"] == "closed"
assert body["total_cost"] == "160.25"
assert body["closed_at"] is not None
# Transition history is complete and ordered
stages = [t["to_stage"] for t in body["transitions"]]
assert stages == ["new_request", "operations", "qc_inspection", "costing", "closed"]
async def test_secondary_disposition_flow(client, team):
ncr = await create_ncr(client, team)
second_id = await user_id_by_email(
client, team["dispo"], team["second"], "secondary_disposition_authority"
)
# secondary review without assignees is rejected
r = await do_initial_disposition(client, team, ncr["id"], secondary=True, secondary_ids=[])
assert r.status_code == 422
r = await do_initial_disposition(
client, team, ncr["id"], secondary=True, secondary_ids=[second_id]
)
body = r.json()["ncr"]
assert body["stage"] == "secondary_disposition"
assert [u["email"] for u in body["secondary_authorities"]] == [team["second"]]
# assignee saves without releasing
r = await client.post(
f"/api/ncrs/{ncr['id']}/secondary-disposition",
json={"disposition_notes": "<p>Updated by secondary.</p>", "release": False},
headers=hdr(team["second"]),
)
assert r.json()["ncr"]["stage"] == "secondary_disposition"
# then releases to operations
r = await client.post(
f"/api/ncrs/{ncr['id']}/secondary-disposition",
json={"work_order": "WO-2002", "release": True},
headers=hdr(team["second"]),
)
body = r.json()["ncr"]
assert body["stage"] == "operations"
assert body["work_order"] == "WO-2002"
# earlier saved notes were not wiped by the release payload
assert "Updated by secondary" in body["disposition_notes"]
async def test_invalid_transitions_rejected(client, team):
ncr = await create_ncr(client, team)
# can't skip ahead from new_request
r = await client.post(f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"]))
assert r.status_code == 409
r = await client.post(
f"/api/ncrs/{ncr['id']}/inspection",
json={"qc_closed": True},
headers=hdr(team["qc"]),
)
assert r.status_code == 409
r = await client.post(
f"/api/ncrs/{ncr['id']}/costing",
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
headers=hdr(team["cost"]),
)
assert r.status_code == 409
# once in operations, initial disposition can't run again
await to_operations(client, team, ncr["id"])
r = await do_initial_disposition(client, team, ncr["id"])
assert r.status_code == 409
async def test_closed_ncr_is_fully_locked(client, team):
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
assert ncr["stage"] == "closed"
for path, payload, user in [
("initial-disposition", {"secondary_review_needed": False}, team["dispo"]),
("secondary-disposition", {"release": True}, team["second"]),
("operations-complete", None, team["ops"]),
("inspection", {"qc_closed": True}, team["qc"]),
("costing", {"labor_cost": "9", "material_cost": "9", "service_cost": "9", "other_cost": "9"}, team["cost"]),
]:
r = await client.post(
f"/api/ncrs/{ncr['id']}/{path}",
json=payload,
headers=hdr(user),
)
assert r.status_code == 409, f"{path}: {r.status_code} {r.text}"
assert "closed" in r.json()["detail"].lower()
async def test_admin_reopen_with_reason(client, team):
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
# non-admin cannot reopen
r = await client.post(
f"/api/ncrs/{ncr['id']}/reopen",
json={"to_stage": "costing", "reason": "Costs were entered incorrectly."},
headers=hdr(team["cost"]),
)
assert r.status_code == 403
# reason is required (min length)
r = await client.post(
f"/api/ncrs/{ncr['id']}/reopen",
json={"to_stage": "costing", "reason": ""},
headers=hdr(team["admin"]),
)
assert r.status_code == 422
# reopening to 'closed' is not a valid target
r = await client.post(
f"/api/ncrs/{ncr['id']}/reopen",
json={"to_stage": "closed", "reason": "does not make sense"},
headers=hdr(team["admin"]),
)
assert r.status_code == 422
r = await client.post(
f"/api/ncrs/{ncr['id']}/reopen",
json={"to_stage": "costing", "reason": "Costs were entered incorrectly."},
headers=hdr(team["admin"]),
)
body = r.json()["ncr"]
assert body["stage"] == "costing"
assert body["closed_at"] is None
# costs preserved for correction
assert body["labor_cost"] == "125.50"
# reopen is recorded with its reason in the transition history + audit trail
reopen_t = [t for t in body["transitions"] if t["action"] == "reopen"]
assert len(reopen_t) == 1
assert "Costs were entered incorrectly." in reopen_t[0]["note"]
audit = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["admin"]))
actions = [a["action"] for a in audit.json()["items"]]
assert "reopen" in actions
# workflow resumes: costing can close it again
r = await client.post(
f"/api/ncrs/{ncr['id']}/costing",
json={"labor_cost": "200", "material_cost": "0", "service_cost": "0", "other_cost": "0"},
headers=hdr(team["cost"]),
)
assert r.json()["ncr"]["stage"] == "closed"
async def test_reopen_only_from_closed(client, team):
ncr = await create_ncr(client, team)
r = await client.post(
f"/api/ncrs/{ncr['id']}/reopen",
json={"to_stage": "new_request", "reason": "not closed yet"},
headers=hdr(team["admin"]),
)
assert r.status_code == 409
async def test_rich_text_is_sanitized(client, team):
ncr = await create_ncr(client, team)
r = await do_initial_disposition(
client,
team,
ncr["id"],
notes='<p onclick="evil()">Keep</p><script>alert("xss")</script><a href="javascript:x()">link</a>',
)
notes = r.json()["ncr"]["disposition_notes"]
assert "<script" not in notes
assert "onclick" not in notes
assert "javascript:" not in notes
assert "Keep" in notes
async def test_audit_trail_field_level(client, team):
ncr = await create_ncr(client, team)
await to_operations(client, team, ncr["id"])
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"]))
items = r.json()["items"]
by_field = {i["field_name"]: i for i in items if i["field_name"]}
assert by_field["stage"]["old_value"] == "new_request"
assert by_field["stage"]["new_value"] == "operations"
assert by_field["work_order"]["new_value"] == "WO-1001"
assert any(i["action"] == "create" for i in items)