Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""Helpers for the packet API tests: projects and units by SQL, hand drafts, PDFs, transitions, the happy path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import secrets
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from packetstation.domain import checklists
|
||||
from packetstation.settings import Settings
|
||||
|
||||
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
|
||||
USERS = ("admin.ann", "clerk.clara", "wh.wade", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes", "dup.dave")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def phase2_client(settings: Settings, fake_dir: FakeDirectory) -> Iterator[TestClient]:
|
||||
from packetstation.main import create_app
|
||||
|
||||
app = create_app(settings, directory=fake_dir) # main.py registers every phase 2 router
|
||||
with TestClient(app, headers=ORIGIN_HEADERS) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def seed_users(client: TestClient, users: tuple[str, ...] = USERS) -> None:
|
||||
"""Sign each test user in once so their users rows exist."""
|
||||
for sam in users:
|
||||
assert sign_in(client, sam).status_code == 200, sam
|
||||
|
||||
|
||||
def as_user(client: TestClient, sam: str) -> None:
|
||||
r = sign_in(client, sam)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def user_id(conn: Any, sam: str) -> str:
|
||||
row = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()
|
||||
assert row is not None, sam
|
||||
return str(row[0])
|
||||
|
||||
|
||||
def station_id(conn: Any, dept: str = "03", name: str = "Stall 1") -> int:
|
||||
row = conn.execute("SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name)).fetchone()
|
||||
assert row is not None
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def make_unit(conn: Any, *, number: str | None = None, lot: str = "001", status: str = "open") -> tuple[int, int]:
|
||||
"""A project and one unit by SQL. Returns (project_id, unit_id)."""
|
||||
number = number or "L9" + secrets.token_hex(3).upper()
|
||||
pid = conn.execute(
|
||||
"INSERT INTO projects (number, name, customer, po) VALUES (%s, %s, %s, %s) RETURNING id",
|
||||
(number, "Test project", "Acme Refining", "PO-77"),
|
||||
).fetchone()[0]
|
||||
uid = conn.execute(
|
||||
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, %s, %s::project_status) RETURNING id",
|
||||
(pid, lot, "Unit one", status),
|
||||
).fetchone()[0]
|
||||
return int(pid), int(uid)
|
||||
|
||||
|
||||
def add_unit(conn: Any, project_id: int, lot: str, status: str = "open") -> int:
|
||||
return int(
|
||||
conn.execute(
|
||||
"INSERT INTO units (project_id, lot, status) VALUES (%s, %s, %s::project_status) RETURNING id",
|
||||
(project_id, lot, status),
|
||||
).fetchone()[0]
|
||||
)
|
||||
|
||||
|
||||
def draft_body(unit_id: int, **over: Any) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"unit_id": unit_id,
|
||||
"dept_code": "03",
|
||||
"op": "WELD-PART",
|
||||
"title": "Spool 4 inlet",
|
||||
"customer": "Acme Refining",
|
||||
"drawing": "D-1001",
|
||||
"rev": "B",
|
||||
"wps": "WPS-11",
|
||||
"work_orders": [
|
||||
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 1.5},
|
||||
],
|
||||
"bom": [],
|
||||
}
|
||||
body.update(over)
|
||||
return body
|
||||
|
||||
|
||||
def make_draft(client: TestClient, unit_id: int, **over: Any) -> dict[str, Any]:
|
||||
"""A hand draft through the API as whoever is signed in (Clerical). Returns the read model."""
|
||||
r = client.post("/api/packets", json=draft_body(unit_id, **over))
|
||||
assert r.status_code == 201, r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def tiny_pdf(pages: int = 1) -> bytes:
|
||||
from pypdf import PdfWriter
|
||||
|
||||
w = PdfWriter()
|
||||
for _ in range(pages):
|
||||
w.add_blank_page(width=612, height=792)
|
||||
buf = io.BytesIO()
|
||||
w.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def upload(client: TestClient, data: bytes, name: str = "drawing.pdf", mime: str = "application/pdf") -> Any:
|
||||
return client.post("/api/assets", files={"file": (name, data, mime)})
|
||||
|
||||
|
||||
def attach_drawing(client: TestClient, packet_id: int, pages: int = 1) -> dict[str, Any]:
|
||||
a = upload(client, tiny_pdf(pages))
|
||||
assert a.status_code == 201, a.text
|
||||
r = client.post(
|
||||
f"/api/packets/{packet_id}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def set_header(client: TestClient, packet_id: int, **fields: Any) -> Any:
|
||||
return client.patch(f"/api/packets/{packet_id}", json={"set": fields, "base": {}})
|
||||
|
||||
|
||||
def ready_draft(client: TestClient, unit_id: int, **over: Any) -> dict[str, Any]:
|
||||
"""A hand draft with the heat map answered and a drawing attached: it can leave Clerical."""
|
||||
pkt = make_draft(client, unit_id, **over)
|
||||
assert set_header(client, pkt["id"], heat_map=True).status_code == 200
|
||||
return attach_drawing(client, pkt["id"])
|
||||
|
||||
|
||||
def step(client: TestClient, pkt: dict[str, Any], action: str, **fields: Any) -> Any:
|
||||
"""POST a transition with the expect taken from the packet answer."""
|
||||
body = {"action": action, "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}, **fields}
|
||||
return client.post(f"/api/packets/{pkt['id']}/transitions", json=body)
|
||||
|
||||
|
||||
def ok_step(client: TestClient, pkt: dict[str, Any], action: str, **fields: Any) -> dict[str, Any]:
|
||||
r = step(client, pkt, action, **fields)
|
||||
assert r.status_code == 200, r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def get(client: TestClient, packet_id: int) -> dict[str, Any]:
|
||||
r = client.get(f"/api/packets/{packet_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def finish_stage_by_sql(settings: Settings, packet_id: int) -> None:
|
||||
"""The checklist, filler and one weld, which the child endpoints (built later) would write."""
|
||||
keys = checklists.checklist_keys(True)
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute(
|
||||
"UPDATE packet_stages SET checklist = %s, filler = 'ER70S-6' WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(Jsonb({k: {"done": True, "stamp": "A7"} for k in keys}), packet_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO welds (packet_id, weld_no, items, type, size) VALUES (%s, 1, '{1}', 'Fillet', '1/4')",
|
||||
(packet_id,),
|
||||
)
|
||||
|
||||
|
||||
def finish_qc_by_sql(settings: Settings, packet_id: int) -> None:
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute(
|
||||
"UPDATE qc_inspections SET checks = %s, weld_exam = %s, inspector = 'Quinn QC'"
|
||||
" WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(Jsonb({k: {"done": True} for k in checklists.qc_keys()}), Jsonb({"1": {"result": "accept"}}), packet_id),
|
||||
)
|
||||
|
||||
|
||||
def happy_path(client: TestClient, settings: Settings, unit_id: int) -> dict[str, Any]:
|
||||
"""Draft -> warehouse -> ready -> assigned -> in progress -> review -> Q.C. -> complete (data book) -> filed."""
|
||||
with app_conn(settings) as conn:
|
||||
stall = station_id(conn)
|
||||
andy = user_id(conn, "weld.andy")
|
||||
as_user(client, "clerk.clara")
|
||||
pkt = ready_draft(client, unit_id)
|
||||
pkt = ok_step(client, pkt, "send_to_warehouse")
|
||||
assert pkt["status"] == "warehouse" and pkt["wh"]["stage"] == "incoming"
|
||||
|
||||
as_user(client, "wh.wade")
|
||||
pkt = ok_step(client, pkt, "wh_release")
|
||||
assert pkt["status"] == "ready" and pkt["stage"]["dept_code"] == "03" and pkt["wh"] is None
|
||||
|
||||
as_user(client, "lead.lou")
|
||||
r = step(client, pkt, "return_to_clerical", reason="Wrong department for this one")
|
||||
assert r.status_code == 403 and r.json()["code"] == "wrong_status", r.text
|
||||
|
||||
as_user(client, "sup.wes")
|
||||
pkt = ok_step(client, pkt, "assign", station_id=stall, welder_id=andy)
|
||||
assert pkt["status"] == "assigned" and pkt["station"]["name"] == "Stall 1"
|
||||
assert pkt["welder"]["id"] == andy and pkt["stage"]["welder"]["id"] == andy
|
||||
|
||||
as_user(client, "dup.dave")
|
||||
r = step(client, pkt, "start")
|
||||
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
|
||||
|
||||
as_user(client, "weld.andy")
|
||||
pkt = ok_step(client, pkt, "start")
|
||||
assert pkt["status"] == "in_progress" and pkt["stage"]["clock"]["running_since"] is not None
|
||||
r = step(client, pkt, "submit", sign=SIGN)
|
||||
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||||
finish_stage_by_sql(settings, pkt["id"])
|
||||
r = step(client, pkt, "submit")
|
||||
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
||||
pkt = ok_step(client, pkt, "submit", sign=SIGN)
|
||||
assert pkt["status"] == "review" and pkt["stage"]["clock"]["running_since"] is None
|
||||
|
||||
as_user(client, "sup.wes")
|
||||
r = step(client, pkt, "send_to_qc", book=True, spool=True, sign=SIGN)
|
||||
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||||
pkt = ok_step(client, pkt, "send_to_qc", book=True, spool=True, location="Bay 3 rack B", sign=SIGN)
|
||||
assert pkt["status"] == "qc" and pkt["qc"]["location"] == "Bay 3 rack B" and pkt["stage"] is not None
|
||||
|
||||
as_user(client, "qc.quinn")
|
||||
r = step(client, pkt, "qc_accept", routed_to="databook", sign=SIGN)
|
||||
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||||
assert "Q.C. still needs" in r.json()["detail"]
|
||||
finish_qc_by_sql(settings, pkt["id"])
|
||||
pkt = ok_step(client, pkt, "qc_accept", routed_to="databook", sign=SIGN)
|
||||
assert pkt["status"] == "complete" and pkt["routed_to"] == "databook" and pkt["qc"] is None
|
||||
assert pkt["stage"] is None and len(pkt["stages"]) == 1 and pkt["qc_history"][0]["result"] == "accepted"
|
||||
|
||||
as_user(client, "clerk.clara")
|
||||
r = step(client, pkt, "file", sign=SIGN)
|
||||
assert r.status_code == 403 and r.json()["code"] == "precondition"
|
||||
assert r.json()["detail"] == "Close the job in VISUAL first."
|
||||
r = client.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
|
||||
assert r.status_code == 200 and r.json()["vclosed"]["by"]["display_name"] == "Clara Clerk", r.text
|
||||
pkt = ok_step(client, r.json(), "file", sign=SIGN)
|
||||
assert pkt["status"] == "filed" and pkt["seal"]["broken_at"] is None and len(pkt["seal"]["key_id"]) == 8
|
||||
return pkt
|
||||
Reference in New Issue
Block a user