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>
This commit is contained in:
244
backend/tests/test_state_machine.py
Normal file
244
backend/tests/test_state_machine.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user