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:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+246
View File
@@ -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
+7
View File
@@ -12,6 +12,7 @@ import pytest
from packetstation.settings import Settings
TEST_SESSION_KEY = "test-session-key-0123456789abcdef0123456789"
TEST_SEAL_KEY = "test-seal-key-fedcba9876543210fedcba9876543210"
ORIGIN_HEADERS = {"Origin": "http://testserver", "X-Requested-With": "PacketStation"}
@@ -23,6 +24,12 @@ def make_settings(tmp: Path, **over: Any) -> Settings:
key_file.parent.mkdir(parents=True, exist_ok=True)
key_file.write_text(TEST_SESSION_KEY + "\n")
over["session_key_file"] = key_file
if "seal_key_file" not in over:
seal_file = tmp / "seal_key"
if not seal_file.exists():
seal_file.parent.mkdir(parents=True, exist_ok=True)
seal_file.write_text(TEST_SEAL_KEY + "\n")
over["seal_key_file"] = seal_file
values: dict[str, Any] = {
"_env_file": None,
"stack": "ci",
+2 -1
View File
@@ -76,7 +76,8 @@ def test_placeholder_when_static_missing(client: TestClient) -> None:
def test_api_and_blob_never_fall_back(client: TestClient) -> None:
assert client.get("/api/nope").status_code == 404
assert client.get("/_blob/abc").status_code == 404
# /_blob needs a session first (phase 2), so an anonymous call is refused, never the SPA page
assert client.get("/_blob/abc").status_code in (401, 404)
def test_static_and_spa_fallback(tmp_path: Path, dev_visual: None) -> None:
+658
View File
@@ -0,0 +1,658 @@
"""Packet child rows: the BOM and shorts, welds, heat tags, the heat table, the stage, the warehouse board, Q.C.
and change orders. Every write goes through the pipeline; these tests check the policy, the row and state checks,
and what each write leaves behind."""
from __future__ import annotations
import threading
from collections.abc import Iterator
from typing import Any
import psycopg
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import (
SIGN,
as_user,
finish_stage_by_sql,
get,
make_draft,
make_unit,
ok_step,
phase2_client,
ready_draft,
seed_users,
station_id,
step,
user_id,
)
from pshelpers import app_conn, needs_pg
from packetstation.settings import Settings
pytestmark = needs_pg
BOM = [
{"item": 1, "qty": 4, "part_id": "PIPE-4", "description": "4in pipe SCH40"},
{"item": 2, "qty": 2, "part_id": "FLG-4", "description": "4in flange"},
]
@pytest.fixture
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
with phase2_client(pg_settings, fake_dir) as client:
seed_users(client)
with app_conn(pg_settings) as conn:
conn.execute("UPDATE users SET welder_stamp = 'A7' WHERE sam_account = 'weld.andy'")
yield client
@pytest.fixture
def unit(pg_settings: Settings) -> tuple[int, int]:
with app_conn(pg_settings) as conn:
return make_unit(conn)
def _one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def _line(pkt: dict[str, Any], item: int) -> dict[str, Any]:
return next(b for b in pkt["bom"] if b["item"] == item)
def _im(v: int) -> dict[str, str]:
return {"If-Match": str(v)}
def warehouse_packet(client: TestClient, unit_id: int) -> dict[str, Any]:
as_user(client, "clerk.clara")
pkt = ready_draft(client, unit_id, bom=BOM)
return ok_step(client, pkt, "send_to_warehouse")
def pull_all(client: TestClient, pkt: dict[str, Any]) -> dict[str, Any]:
for b in pkt["bom"]:
if not b["pulled"] and b["short"] is None:
r = client.patch(
f"/api/packets/{pkt['id']}/bom/{b['item']}", json={"pulled": True}, headers=_im(b["row_version"])
)
assert r.status_code == 200, r.text
pkt = r.json()
return pkt
def shop_packet(client: TestClient, settings: Settings, unit_id: int) -> dict[str, Any]:
"""A packet in progress at Stall 1 with weld.andy, every BOM line pulled."""
pkt = warehouse_packet(client, unit_id)
as_user(client, "wh.wade")
pkt = pull_all(client, pkt)
pkt = ok_step(client, pkt, "wh_release")
with app_conn(settings) as conn:
stall = station_id(conn)
andy = user_id(conn, "weld.andy")
as_user(client, "sup.wes")
pkt = ok_step(client, pkt, "assign", station_id=stall, welder_id=andy)
as_user(client, "weld.andy")
pkt = ok_step(client, pkt, "start")
assert pkt["status"] == "in_progress"
return pkt
# ---------------------------------------------------------------- BOM
def test_bom_full_replace_on_a_draft(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
as_user(c, "clerk.clara")
pkt = ready_draft(c, unit[1])
url = f"/api/packets/{pkt['id']}/bom"
body = {"bom": [{**BOM[0], "cl_item": "A", "material": " A106-B​ ", "length": "20ft"}, BOM[1]]}
assert c.put(url, json=body).status_code == 428
r = c.put(url, json=body, headers=_im(pkt["version"]))
assert r.status_code == 200, r.text
out = r.json()
assert [b["item"] for b in out["bom"]] == [1, 2] and out["version"] == pkt["version"] + 1
assert _line(out, 1)["material"] == "A106-B" and _line(out, 1)["src"] == "hand"
stale = c.put(url, json={"bom": [BOM[0]]}, headers=_im(pkt["version"]))
assert stale.status_code == 409 and stale.json()["code"] == "version_conflict", stale.text
dup = c.put(url, json={"bom": [BOM[0], BOM[0]]}, headers=_im(out["version"]))
assert dup.status_code == 422 and dup.json()["code"] == "duplicate_item"
zero = c.put(url, json={"bom": [{"item": 0, "qty": 1}]}, headers=_im(out["version"]))
assert zero.status_code == 422
r = c.put(url, json={"bom": [BOM[1]]}, headers=_im(out["version"]))
assert r.status_code == 200 and [b["item"] for b in r.json()["bom"]] == [2]
log = c.get(f"/api/packets/{pkt['id']}/log").json()
assert log[0]["action"] == "BOM replaced" and log[0]["note"] == "1 line"
wh = ok_step(c, r.json(), "send_to_warehouse")
r = c.put(url, json={"bom": BOM}, headers=_im(wh["version"]))
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
as_user(c, "wh.wade")
r = c.put(url, json={"bom": BOM}, headers=_im(wh["version"]))
assert r.status_code == 403 and r.json()["code"] == "wrong_status", r.text
def test_bom_line_patch_in_the_warehouse(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
assert pkt["wh"]["stage"] == "incoming"
url = f"/api/packets/{pkt['id']}/bom/1"
r = c.patch(url, json={"heat": "H123"}, headers=_im(1))
assert r.status_code == 403 and r.json()["code"] == "forbidden"
assert "It's with Warehouse right now." in r.json()["detail"]
as_user(c, "wh.wade")
r = c.patch(url, json={"heat": "H123", "loc": "Rack 4"}, headers=_im(1))
assert r.status_code == 200, r.text
line = _line(r.json(), 1)
assert line["heat"] == "H123" and line["loc"] == "Rack 4" and line["row_version"] == 2
assert r.json()["wh"]["stage"] == "incoming"
stale = c.patch(url, json={"heat": "H999"}, headers=_im(1))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
assert stale.json()["current"]["row_version"] == 2 and stale.json()["current"]["heat"] == "H123"
bad = c.patch(url, json={"heat": "@@ bad"}, headers=_im(2))
assert bad.status_code == 422 and bad.json()["code"] == "invalid_heat"
nomtr = c.patch(url, json={"mtr_asset_id": "6f1c1d1e-0000-4000-8000-000000000000"}, headers=_im(2))
assert nomtr.status_code == 422 and nomtr.json()["code"] == "invalid_asset"
r = c.patch(url, json={"pulled": True}, headers=_im(2))
assert r.status_code == 200, r.text
out = r.json()
line = _line(out, 1)
assert line["pulled"] is True and float(line["issued"]) == 4 and line["row_version"] == 3
assert out["wh"]["stage"] == "pulling" and out["wh"]["auto"] is True and out["wh"]["note"] == "started pulling"
row = _one(
pg_settings, "SELECT pulled_at, pulled_by FROM bom_lines WHERE packet_id = %s AND item = 1", (pkt["id"],)
)
assert row[0] is not None and row[1] is not None
r = c.patch(f"/api/packets/{pkt['id']}/bom/2", json={"pulled": True}, headers=_im(1))
assert r.json()["wh"]["stage"] == "pulling"
n = _one(pg_settings, "SELECT count(*) FROM packet_wh WHERE packet_id = %s", (pkt["id"],))[0]
assert n == 2
r = c.patch(url, json={"na": True}, headers=_im(3))
assert _line(r.json(), 1)["na"] is True and _line(r.json(), 1)["heat"] is None
r = c.patch(url, json={"pulled": False}, headers=_im(4))
assert _line(r.json(), 1)["pulled"] is False
row = _one(pg_settings, "SELECT pulled_at FROM bom_lines WHERE packet_id = %s AND item = 1", (pkt["id"],))
assert row[0] is None
same = c.patch(url, json={"pulled": False}, headers=_im(5))
assert same.status_code == 200 and same.json()["version"] == r.json()["version"]
def test_wh_hold_and_first_pull_resumes(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
as_user(c, "wh.wade")
r = c.post(
f"/api/packets/{pkt['id']}/wh",
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
)
assert r.status_code == 200 and r.json()["wh"]["stage"] == "hold", r.text
r = c.patch(f"/api/packets/{pkt['id']}/bom/1", json={"pulled": True}, headers=_im(1))
assert r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["note"] == "pulling resumed"
def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
pid = pkt["id"]
as_user(c, "wh.wade")
url = f"/api/packets/{pid}/bom/1/short"
body = {"qty": 3, "reason": "Not in stock", "ref": "PO-991", "eta": "2026-11-02", "expect_open": False}
r = c.post(url, json=body)
assert r.status_code == 200, r.text
out = r.json()
line = _line(out, 1)
assert float(line["short"]["qty"]) == 3 and float(line["short"]["qty_open"]) == 3
assert line["short"]["ref"] == "PO-991"
assert line["pulled"] is True and float(line["issued"]) == 1 and out["open_shorts"] == 1
assert out["wh"]["stage"] == "pulling" and out["wh"]["auto"] is True
again = c.post(url, json=body)
assert again.status_code == 409 and again.json()["code"] == "short_conflict"
assert c.post(url, json={**body, "qty": 9, "expect_open": True}).status_code == 422
r = c.post(url, json={**body, "qty": 2, "expect_open": True})
assert r.status_code == 200 and float(_line(r.json(), 1)["short"]["qty_open"]) == 2
assert float(_line(r.json(), 1)["issued"]) == 2
rv = _line(r.json(), 1)["row_version"]
blocked = c.patch(f"/api/packets/{pid}/bom/1", json={"heat": "H1"}, headers=_im(rv))
assert blocked.status_code == 409 and blocked.json()["code"] == "short_open"
# A short on item 2, then cleared: the whole quantity counts as issued.
r = c.post(f"/api/packets/{pid}/bom/2/short", json={"qty": 1, "reason": "Damaged", "expect_open": False})
assert _line(r.json(), 2)["short"] is not None
r = c.request("DELETE", f"/api/packets/{pid}/bom/2/short", json={"expect_open": True})
assert r.status_code == 200, r.text
assert _line(r.json(), 2)["short"] is None and float(_line(r.json(), 2)["issued"]) == 2
assert c.request("DELETE", f"/api/packets/{pid}/bom/2/short", json={"expect_open": True}).status_code == 409
pkt = ok_step(c, r.json(), "wh_release")
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
rec = f"/api/packets/{pid}/bom/1/short/receipts"
r = c.post(rec, json={"qty": 1, "heat": "H100", "note": "First piece"})
assert r.status_code == 200, r.text
line = _line(r.json(), 1)
assert float(line["short"]["qty_open"]) == 1 and len(line["short"]["receipts"]) == 1
assert line["short"]["receipts"][0]["by"]["display_name"] == "Wade Warehouse"
assert line["heat"] == "H100" and float(line["issued"]) == 3
assert c.post(f"/api/packets/{pid}/bom/2/short/receipts", json={"qty": 1, "heat": "H1"}).status_code == 404
with app_conn(pg_settings) as conn:
stall = station_id(conn)
andy = user_id(conn, "weld.andy")
as_user(c, "sup.wes")
pkt = ok_step(c, r.json(), "assign", station_id=stall, welder_id=andy)
as_user(c, "weld.andy")
pkt = ok_step(c, pkt, "start")
no = c.post(rec, json={"qty": 1, "heat": "H200"})
assert no.status_code == 403 and no.json()["code"] == "forbidden"
as_user(c, "wh.wade")
assert c.post(rec, json={"qty": 2, "heat": "H200"}).status_code == 422
nh = c.post(rec, json={"qty": 1})
assert nh.status_code == 422 and nh.json()["code"] == "heat_required"
r = c.post(rec, json={"qty": 1, "heat": "H200"})
assert r.status_code == 200, r.text
line = _line(r.json(), 1)
assert line["short"] is None and r.json()["open_shorts"] == 0 and r.json()["status"] == "in_progress"
assert line["heat"] == "H100 / H200" and float(line["issued"]) == 4
closed = _one(
pg_settings,
"SELECT s.closed_at, s.qty_open FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id"
" WHERE s.packet_id = %s AND b.item = 1",
(pid,),
)
assert closed[0] is not None and closed[1] == 0
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
conn.execute("UPDATE short_receipts SET qty = 9 WHERE packet_id = %s", (pid,))
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
conn.execute("DELETE FROM short_receipts WHERE packet_id = %s", (pid,))
# ---------------------------------------------------------------- welds, heat tags, heat table
def test_welds(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
weld = {"page": 1, "fx": 0.25, "fy": 0.5, "wo_base": "L670001", "piece": 1, "items": [2, 1], "type": "Fillet"}
r1 = c.post(f"/api/packets/{pid}/welds", json={**weld, "size": "1/4"})
assert r1.status_code == 200, r1.text
r2 = c.post(f"/api/packets/{pid}/welds", json={**weld, "type": "Butt"})
assert r2.status_code == 200, r2.text
welds = r2.json()["welds"]
assert [w["weld_no"] for w in welds] == [1, 2]
assert welds[0]["items"] == [1, 2] and welds[0]["stamp"] == "A7"
assert welds[0]["welder"]["display_name"] == "Andy Welder"
row = _one(pg_settings, "SELECT dept_code, stage_id FROM welds WHERE packet_id = %s AND weld_no = 1", (pid,))
assert row[0] == "03" and row[1] == pkt["stage"]["id"]
bad = c.post(f"/api/packets/{pid}/welds", json={**weld, "items": [9]})
assert bad.status_code == 422 and bad.json()["code"] == "invalid_items"
assert c.post(f"/api/packets/{pid}/welds", json={**weld, "items": []}).status_code == 422
assert c.post(f"/api/packets/{pid}/welds", json={**weld, "stamp": "no stamp!"}).status_code == 422
r = c.patch(f"/api/packets/{pid}/welds/1", json={"size": "3/8"}, headers=_im(1))
assert r.status_code == 200 and r.json()["welds"][0]["size"] == "3/8" and r.json()["welds"][0]["row_version"] == 2
stale = c.patch(f"/api/packets/{pid}/welds/1", json={"size": "1/2"}, headers=_im(1))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
r = c.request("DELETE", f"/api/packets/{pid}/welds/2", json={"reason": "Marked twice"})
assert r.status_code == 200 and [w["weld_no"] for w in r.json()["welds"]] == [1]
row = _one(pg_settings, "SELECT removed_reason FROM welds WHERE packet_id = %s AND weld_no = 2", (pid,))
assert row[0] == "Marked twice"
assert c.patch(f"/api/packets/{pid}/welds/2", json={"size": "1"}, headers=_im(1)).status_code == 404
r = c.post(f"/api/packets/{pid}/welds", json=weld)
assert r.json()["welds"][-1]["weld_no"] == 3
# Another worker of the department, limited to another stall, does not hold this packet.
with app_conn(pg_settings) as conn:
conn.execute(
"INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)",
(user_id(conn, "dup.dave"), station_id(conn, "03", "Stall 2")),
)
as_user(c, "dup.dave")
r = c.post(f"/api/packets/{pid}/welds", json=weld)
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
as_user(c, "clerk.clara")
assert c.post(f"/api/packets/{pid}/welds", json=weld).status_code == 403
def test_heat_tags_and_heat_table(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
pid = pkt["id"]
assert c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 1}).status_code == 403
as_user(c, "wh.wade")
for item in (1, 2):
r = c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": item, "fx": 0.1, "fy": 0.2})
assert r.status_code == 200, r.text
tags = r.json()["heat_tags"]
assert [(t["tag_no"], t["item"]) for t in tags] == [(1, 1), (2, 2)]
assert c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 7}).status_code == 422
r = c.patch(f"/api/packets/{pid}/heat-tags/2", json={"item": 1}, headers=_im(1))
assert r.status_code == 200 and r.json()["heat_tags"][1]["item"] == 1
stale = c.patch(f"/api/packets/{pid}/heat-tags/2", json={"item": 2}, headers=_im(1))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
r = c.request("DELETE", f"/api/packets/{pid}/heat-tags/1", json={})
assert r.status_code == 200 and [t["tag_no"] for t in r.json()["heat_tags"]] == [2]
url = f"/api/packets/{pid}/heat-table/1"
r1 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0})
assert r1.status_code == 200 and r1.json()["heat_table"] == [{"page": 1, "x": 0.7, "y": 0.1, "s": 1.0}]
r2 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0})
assert r2.status_code == 200 and r2.json()["version"] == r1.json()["version"]
r3 = c.put(url, json={"x": 0.6, "y": 0.1, "s": 1.2})
assert r3.json()["heat_table"][0]["x"] == 0.6 and r3.json()["version"] == r1.json()["version"] + 1
n = _one(pg_settings, "SELECT count(*) FROM heat_table_placements WHERE packet_id = %s", (pid,))[0]
assert n == 1
assert c.put(url, json={"x": 0.6, "y": 0.1, "s": 0}).status_code == 422
r = c.request("DELETE", url, json={})
assert r.status_code == 200 and r.json()["heat_table"] == []
assert c.request("DELETE", url, json={}).status_code == 404
# ---------------------------------------------------------------- the stage
def test_stage_fields_and_checklist(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
rv = pkt["stage"]["row_version"]
r = c.patch(f"/api/packets/{pid}/stage", json={"filler": "ER70S-6", "preheat": "50F"}, headers=_im(rv))
assert r.status_code == 200, r.text
st = r.json()["stage"]
assert st["filler"] == "ER70S-6" and st["preheat"] == "50F" and st["row_version"] == rv + 1
stale = c.patch(f"/api/packets/{pid}/stage", json={"filler": "E7018"}, headers=_im(rv))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
andy = user_id_of(pg_settings, "weld.andy")
r = c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": True})
assert r.status_code == 200, r.text
entry = r.json()["stage"]["checklist"]["dwg"]
assert entry["done"] is True and entry["by"] == andy and entry["stamp"] == "A7" and entry["at"]
assert r.json()["stage"]["row_version"] == st["row_version"]
bad = c.put(f"/api/packets/{pid}/stage/checklist/nope", json={"done": True})
assert bad.status_code == 422 and bad.json()["code"] == "invalid_key"
as_user(c, "lead.lou")
r = c.put(f"/api/packets/{pid}/stage/checklist/heat", json={"done": True})
assert r.status_code == 200 and r.json()["stage"]["checklist"]["heat"]["by"] == user_id_of(pg_settings, "lead.lou")
r = c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": False})
assert r.json()["stage"]["checklist"]["dwg"] == {"done": False}
as_user(c, "wh.wade")
assert c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": True}).status_code == 403
def user_id_of(settings: Settings, sam: str) -> str:
with app_conn(settings) as conn:
return user_id(conn, sam)
def test_stage_clock(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
url = f"/api/packets/{pkt['id']}/stage/clock"
assert pkt["stage"]["clock"]["running_since"] is not None
r = c.post(url, json={"op": "start"})
assert r.status_code == 409 and r.json()["code"] == "clock_state"
r = c.post(url, json={"op": "pause"})
assert r.status_code == 200 and r.json()["stage"]["clock"]["running_since"] is None, r.text
assert r.json()["stage"]["clock"]["elapsed_ms"] >= 0
assert c.post(url, json={"op": "pause"}).json()["code"] == "clock_state"
r = c.post(url, json={"op": "resume"})
assert r.status_code == 200 and r.json()["stage"]["clock"]["running_since"] is not None
assert c.post(url, json={"op": "resume"}).status_code == 409
assert c.post(url, json={"op": "stop"}).status_code == 422
as_user(c, "clerk.clara")
assert c.post(url, json={"op": "pause"}).status_code == 403
def test_no_stage(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
as_user(c, "clerk.clara")
pkt = make_draft(c, unit[1])
r = c.post(f"/api/packets/{pkt['id']}/stage/clock", json={"op": "start"})
assert r.status_code == 403 and r.json()["code"] == "wrong_status"
# ---------------------------------------------------------------- the warehouse board
def test_wh_stage_board(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
url = f"/api/packets/{pkt['id']}/wh"
r = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(c, "wh.wade")
r = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is False
assert r.json()["wh"]["by"]["display_name"] == "Wade Warehouse"
stale = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
assert stale.status_code == 409 and stale.json()["code"] == "stale_step"
assert stale.json()["current"]["stage"] == "pulling"
r = c.post(url, json={"stage": "hold", "expect_stage": "pulling"})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = c.post(url, json={"stage": "hold", "reason": "Other", "expect_stage": "pulling"})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
hold = {"stage": "hold", "reason": "Drawing / BOM problem", "note": "Asked Leo", "expect_stage": "pulling"}
r = c.post(url, json=hold)
assert r.status_code == 200 and r.json()["wh"]["reason"] == "Drawing / BOM problem"
assert r.json()["wh"]["note"] == "Asked Leo"
bad = c.post(url, json={"stage": "pulled", "expect_stage": "hold"})
assert bad.status_code == 409 and bad.json()["code"] == "wh_transition"
r = c.post(url, json={"stage": "pulling", "expect_stage": "hold"})
assert r.status_code == 200
r = c.post(url, json={"stage": "pulled", "expect_stage": "pulling"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulled"
log = c.get(f"/api/packets/{pkt['id']}/log").json()
assert log[0]["action"] == "Warehouse: Marked pulled, ready for production"
assert log[1]["action"] == "Warehouse: Pulling resumed"
# ---------------------------------------------------------------- Q.C.
def test_qc_patch(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
r = c.patch(f"/api/packets/{pid}/qc", json={"inspector": "Quinn"}, headers=_im(1))
assert r.status_code == 403
finish_stage_by_sql(pg_settings, pid)
pkt = ok_step(c, get(c, pid), "submit", sign=SIGN)
as_user(c, "sup.wes")
pkt = ok_step(c, pkt, "send_to_qc", book=True, spool=True, location="Bay 3", sign=SIGN)
rv = pkt["qc"]["row_version"]
as_user(c, "qc.quinn")
url = f"/api/packets/{pid}/qc"
body = {"checks": {"db_heats": True}, "weld_exam": {"1": {"result": "accept"}}, "inspector": "Quinn QC"}
r = c.patch(url, json={**body, "stamp": "Q5"}, headers=_im(rv))
assert r.status_code == 200, r.text
qc = r.json()["qc"]
assert qc["checks"]["db_heats"]["done"] is True and qc["checks"]["db_heats"]["stamp"] == "Q5"
assert qc["checks"]["db_heats"]["by"] == user_id_of(pg_settings, "qc.quinn")
assert qc["weld_exam"]["1"]["result"] == "accept" and qc["inspector"] == "Quinn QC"
assert qc["row_version"] == rv + 1
r = c.patch(
url, json={"checks": {"db_mtr": True}, "weld_exam": {"1": {"note": "Good tie-in"}}}, headers=_im(rv + 1)
)
assert r.status_code == 200, r.text
qc = r.json()["qc"]
assert qc["checks"]["db_heats"]["done"] and qc["checks"]["db_mtr"]["done"]
assert qc["weld_exam"]["1"]["result"] == "accept"
assert qc["weld_exam"]["1"]["note"] == "Good tie-in"
stale = c.patch(url, json={"notes": "x"}, headers=_im(rv))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
assert c.patch(url, json={"checks": {"nope": True}}, headers=_im(rv + 2)).status_code == 422
assert c.patch(url, json={"weld_exam": {"1": {"result": "maybe"}}}, headers=_im(rv + 2)).status_code == 422
assert c.patch(url, json={"weld_exam": {"8": {"result": "accept"}}}, headers=_im(rv + 2)).status_code == 422
r = c.patch(url, json={"checks": {"db_mtr": False}}, headers=_im(rv + 2))
assert r.json()["qc"]["checks"]["db_mtr"] == {"done": False}
as_user(c, "wh.wade")
assert c.patch(url, json={"notes": "x"}, headers=_im(rv + 3)).status_code == 403
# ---------------------------------------------------------------- change orders
def test_change_orders(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
finish_stage_by_sql(pg_settings, pid)
as_user(c, "clerk.clara")
body = {"reason": "Rev C moves nozzle N2", "ecn": "ECN-55", "items": [{"old_page": 1, "new_page": 1}]}
r = c.post(f"/api/packets/{pid}/change-orders", json=body)
assert r.status_code == 400 and r.json()["code"] == "signature_required"
r = c.post(f"/api/packets/{pid}/change-orders", json={**body, "sign": SIGN})
assert r.status_code == 200, r.text
co = r.json()["change_orders"][0]
assert co["co_no"] == 1 and co["origin"] == "manual" and co["review"] == "approved"
assert co["required"] == ["03", "WH"] and co["issued_by"]["display_name"] == "Clara Clerk"
assert co["items"][0]["old_page"] == 1 and r.json()["open_cos"] == [1]
bad = c.post(f"/api/packets/{pid}/change-orders", json={**body, "required_depts": ["CL"], "sign": SIGN})
assert bad.status_code == 422 and bad.json()["code"] == "invalid_dept"
listed = c.get(f"/api/packets/{pid}/change-orders")
assert listed.status_code == 200 and [x["co_no"] for x in listed.json()] == [1]
as_user(c, "weld.andy")
r = step(c, get(c, pid), "submit", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold", r.text
assert "Change order 1" in r.json()["detail"]
vurl = f"/api/packets/{pid}/change-orders/1/verify"
assert c.post(vurl, json={"dept": "03", "sign": SIGN}).status_code == 403
as_user(c, "sup.wes")
r = c.post(vurl, json={"dept": "03", "note": "Checked N2", "sign": SIGN})
assert r.status_code == 200, r.text
assert r.json()["change_orders"][0]["verified"]["03"]["note"] == "Checked N2"
assert r.json()["open_cos"] == [1]
again = c.post(vurl, json={"dept": "03", "sign": SIGN})
assert again.status_code == 409 and again.json()["code"] == "already_verified"
assert c.post(vurl, json={"dept": "WH", "sign": SIGN}).status_code == 403
as_user(c, "wh.wade")
r = c.post(vurl, json={"dept": "WH", "sign": SIGN})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(c, "admin.ann")
r = c.post(vurl, json={"dept": "WH", "sign": SIGN})
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
r = c.post(vurl, json={"dept": "QC", "sign": SIGN, "it_reason": "Warehouse lead is out today"})
assert r.status_code == 422 and r.json()["code"] == "invalid_dept"
r = c.post(vurl, json={"dept": "WH", "sign": SIGN, "it_reason": "Warehouse lead is out today"})
assert r.status_code == 200, r.text
assert r.json()["open_cos"] == []
row = _one(pg_settings, "SELECT it_reason FROM co_verifications WHERE dept_code = 'WH'")
assert row[0] == "Warehouse lead is out today"
as_user(c, "weld.andy")
r = step(c, get(c, pid), "submit", sign=SIGN)
assert r.status_code == 200 and r.json()["status"] == "review", r.text
# A pending change from VISUAL holds nothing until Clerical holds it.
with app_conn(pg_settings) as conn:
conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, reason) VALUES (%s, 2, 'visual', 'pending',"
" 'Drawing rev changed in VISUAL')",
(pid,),
)
conn.execute("UPDATE packets SET next_co_no = 3 WHERE id = %s", (pid,))
as_user(c, "clerk.clara")
assert get(c, pid)["open_cos"] == []
hurl = f"/api/packets/{pid}/change-orders/2/hold"
r = c.post(hurl, json={"hold": True})
assert r.status_code == 200 and r.json()["open_cos"] == [2], r.text
assert r.json()["change_orders"][1]["hold"] is True
as_user(c, "sup.wes")
r = step(c, get(c, pid), "send_to_qc", book=True, spool=True, location="Bay 3", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold"
as_user(c, "clerk.clara")
r = c.post(hurl, json={"hold": False})
assert r.status_code == 200 and r.json()["open_cos"] == []
assert c.post(f"/api/packets/{pid}/change-orders/1/hold", json={"hold": True}).json()["code"] == "co_state"
rurl = f"/api/packets/{pid}/change-orders/2/reject"
r = c.post(rurl, json={"sign": SIGN})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = c.post(rurl, json={"reason": "Not a real change", "sign": SIGN})
assert r.status_code == 200, r.text
co2 = r.json()["change_orders"][1]
assert co2["review"] == "rejected" and co2["rejected_reason"] == "Not a real change"
r = c.post(f"/api/packets/{pid}/change-orders/2/approve", json={"sign": SIGN})
assert r.status_code == 409 and r.json()["code"] == "co_state"
assert c.post(f"/api/packets/{pid}/change-orders/9/approve", json={"sign": SIGN}).status_code == 404
def test_co_approve_pending(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
pid = pkt["id"]
with app_conn(pg_settings) as conn:
conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review) VALUES (%s, 1, 'visual', 'pending')", (pid,)
)
conn.execute("UPDATE packets SET next_co_no = 2 WHERE id = %s", (pid,))
r = c.post(f"/api/packets/{pid}/change-orders/1/approve", json={"sign": SIGN})
assert r.status_code == 200, r.text
co = r.json()["change_orders"][0]
assert co["review"] == "approved" and co["approved_by"]["display_name"] == "Clara Clerk"
assert co["required"] == ["WH"] and r.json()["open_cos"] == [1]
# ---------------------------------------------------------------- concurrency
def test_concurrent_welds_get_distinct_numbers(
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, unit: tuple[int, int]
) -> None:
pkt = shop_packet(pg_client2, pg_settings, unit[1])
with phase2_client(pg_settings, fake_dir) as other:
as_user(other, "weld.andy")
results: list[Any] = [None, None]
barrier = threading.Barrier(2)
def run(i: int, client: TestClient) -> None:
barrier.wait()
results[i] = client.post(f"/api/packets/{pkt['id']}/welds", json={"items": [1], "type": "Fillet"})
threads = [threading.Thread(target=run, args=(i, cl)) for i, cl in enumerate((pg_client2, other))]
for t in threads:
t.start()
for t in threads:
t.join(30)
assert [r.status_code for r in results] == [200, 200], [r.text for r in results]
nos = [r[0] for r in _all(pg_settings, "SELECT weld_no FROM welds WHERE packet_id = %s", (pkt["id"],))]
assert sorted(nos) == [1, 2]
def test_concurrent_bom_patch_one_wins(
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, unit: tuple[int, int]
) -> None:
pkt = warehouse_packet(pg_client2, unit[1])
as_user(pg_client2, "wh.wade")
with phase2_client(pg_settings, fake_dir) as other:
as_user(other, "wh.wade")
results: list[Any] = [None, None]
barrier = threading.Barrier(2)
def run(i: int, client: TestClient) -> None:
barrier.wait()
results[i] = client.patch(f"/api/packets/{pkt['id']}/bom/1", json={"heat": f"H{i}"}, headers=_im(1))
threads = [threading.Thread(target=run, args=(i, cl)) for i, cl in enumerate((pg_client2, other))]
for t in threads:
t.start()
for t in threads:
t.join(30)
codes = sorted(r.status_code for r in results)
assert codes == [200, 409], [r.text for r in results]
loser = next(r for r in results if r.status_code == 409)
assert loser.json()["code"] == "row_conflict" and loser.json()["current"]["row_version"] == 2
def _all(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchall()
+486
View File
@@ -0,0 +1,486 @@
"""The packet API: hand drafts, header CAS, drawing pages, transitions, filing and the seal, the boards, assets."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import (
SIGN,
as_user,
attach_drawing,
get,
happy_path,
make_draft,
make_unit,
ok_step,
phase2_client,
ready_draft,
seed_users,
set_header,
step,
tiny_pdf,
upload,
)
from pshelpers import app_conn, needs_pg
from psycopg.types.json import Jsonb
from packetstation import events
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
with phase2_client(pg_settings, fake_dir) as client:
seed_users(client)
yield client
@pytest.fixture
def unit(pg_settings: Settings) -> tuple[int, int]:
with app_conn(pg_settings) as conn:
return make_unit(conn)
def _one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def _all(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchall()
# ---------------------------------------------------------------- hand drafts
def test_hand_draft_by_clerical(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
assert pkt["status"] == "draft" and pkt["source_kind"] == "hand" and pkt["source"] == {"kind": "hand"}
assert pkt["flags"] == ["hand"] and pkt["drawing_state"] == "none" and pkt["version"] == 1
assert pkt["flag"] == "Made by hand - check it and send it on"
assert pkt["holder"] == "Clerical" and pkt["dept"] == {"code": "03", "name": "Weld Parts", "weld": True}
wo = pkt["work_orders"][0]
assert (wo["wo_base"], wo["lot"], wo["split"], wo["sub"]) == ("L670001", "001", "0", "0")
assert pkt["unit"]["number"].endswith("001") and pkt["unit"]["project"]["customer"] == "Acme Refining"
assert [i["key"] for i in pkt["checklist_items"]][:2] == ["dwg", "heat"]
assert "D-1001 Rev B" in pkt["checklist_items"][0]["text"] and len(pkt["qc_items"]) == 10
assert "edit_header" in pkt["can"] and "send_to_warehouse" not in pkt["can"]
assert pkt["blocked"]["send_to_warehouse"] == [
"Say whether this packet needs a heat map.",
"Attach the drawing first.",
]
log = pg_client2.get(f"/api/packets/{pkt['id']}/log").json()
assert [r["action"] for r in log] == ["Packet created by hand"] and log[0]["by"]["display_name"] == "Clara Clerk"
row = _one(pg_settings, "SELECT action, detail FROM audit_events WHERE action = 'packet.created'")
assert row[1]["source"] == "hand" and row[1]["work_orders"] == 1
def test_hand_draft_rules(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
from pkthelpers import draft_body
as_user(pg_client2, "wh.wade")
r = pg_client2.post("/api/packets", json=draft_body(unit[1]))
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(pg_client2, "clerk.clara")
r = pg_client2.post("/api/packets", json=draft_body(unit[1], dept_code="QC"))
assert r.status_code == 422 and r.json()["code"] == "invalid_dept"
r = pg_client2.post("/api/packets", json=draft_body(unit[1], work_orders=[{"wo_base": 670001}]))
assert r.status_code == 422 # work order keys are text, never numbers
with app_conn(pg_settings) as conn:
conn.execute("UPDATE units SET status = 'closed' WHERE id = %s", (unit[1],))
r = pg_client2.post("/api/packets", json=draft_body(unit[1]))
assert r.status_code == 409 and r.json()["code"] == "unit_closed"
r = pg_client2.post("/api/packets", json=draft_body(unit[1]) | {"pad": "x" * 210_000})
assert r.status_code == 413 and r.json()["code"] == "too_large"
assert pg_client2.get("/api/packets/999999").status_code == 404
# ---------------------------------------------------------------- header CAS
def test_patch_header_cas(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
pid = pkt["id"]
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "New title"}, "base": {"title": pkt["title"]}})
assert r.status_code == 200 and r.json()["title"] == "New title" and r.json()["version"] == 2
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "Third"}, "base": {"title": "Spool 4 inlet"}})
assert r.status_code == 409 and r.json()["code"] == "field_conflict"
assert r.json()["current"] == {"title": "New title"}
# heat map chips
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"heat_map": True}, "base": {"heat_map": None}})
assert r.status_code == 200 and r.json()["heat_map"] is True
assert pg_client2.get(f"/api/packets/{pid}/log").json()[0]["action"] == "Heat map required"
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"heat_map": False}, "base": {"heat_map": True}})
assert r.status_code == 200 and r.json()["heat_map"] is False
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"status": "filed"}})
assert r.status_code == 422
as_user(pg_client2, "wh.wade")
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "Mine"}})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
assert r.json()["detail"] == (
"Your access doesn't allow changing the packet details on this packet. It's with Clerical right now."
)
# ---------------------------------------------------------------- drawing pages and assets
def test_attach_drawing_needs_signature(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
a = upload(pg_client2, tiny_pdf(2), name="D-1001 B.pdf")
assert a.status_code == 201, a.text
asset = a.json()
assert asset["mime"] == "application/pdf" and asset["pages"] == 2 and asset["url"] == f"/_blob/{asset['id']}"
url = f"/api/packets/{pkt['id']}/pages"
r = pg_client2.post(url, json={"asset_id": asset["id"], "type": "drawing"})
assert r.status_code == 400 and r.json()["code"] == "signature_required"
assert r.json()["meaning"] == "Attach a drawing by hand"
r = pg_client2.post(url, json={"asset_id": asset["id"], "sign": {"factor": "password", "secret": "nope"}})
assert r.status_code == 403 and r.json()["code"] == "wrong_secret" and r.json()["attempts_left"] == 2
fails = _one(pg_settings, "SELECT stepup_failures FROM sessions WHERE revoked_at IS NULL ORDER BY created_at DESC")
assert fails[0] == 1 # the wrong attempt was kept although the write was refused
r = pg_client2.post(url, json={"asset_id": asset["id"], "sign": SIGN})
assert r.status_code == 200, r.text
out = r.json()
assert [(p["n"], p["pn"], p["dwg"]) for p in out["pages"]] == [(1, 1, "D-1001"), (2, 2, "D-1001")]
assert out["drawing_state"] == "hand" and out["pages"][0]["url"] == f"/_blob/{asset['id']}"
log = pg_client2.get(f"/api/packets/{pkt['id']}/log").json()[0]
assert log["action"] == "Drawing attached by hand" and log["signed"] is True
r = pg_client2.post(url, json={"asset_id": asset["id"], "sign": SIGN})
assert r.status_code == 409 and r.json()["code"] == "already_attached"
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/pages/2", json={"reason": "wrong sheet"})
assert r.status_code == 200 and r.json()["pages"] == [] and r.json()["drawing_state"] == "none"
def test_assets_blob_and_limits(
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None
) -> None:
as_user(pg_client2, "clerk.clara")
data = tiny_pdf(1)
a = upload(pg_client2, data, name="..\\evil/name.pdf").json()
assert a["original_name"] == "name.pdf" and a["size"] == len(data)
again = upload(pg_client2, data, name="other.pdf").json()
assert again["id"] == a["id"]
assert pg_client2.get(f"/api/assets/{a['id']}").json()["sha256"] == a["sha256"]
b = pg_client2.get(a["url"])
assert b.status_code == 200 and b.content == data
assert b.headers["x-content-type-options"] == "nosniff"
assert b.headers["content-disposition"] == 'inline; filename="name.pdf"'
assert b.headers["cache-control"] == "private, max-age=3600" and a["sha256"] in b.headers["etag"]
assert pg_client2.get(a["url"] + "?download=1").headers["content-disposition"].startswith("attachment;")
assert pg_client2.get("/_blob/00000000-0000-0000-0000-000000000000").status_code == 404
r = upload(pg_client2, b"just some text, not a document", name="x.txt", mime="text/plain")
assert r.status_code == 415 and r.json()["code"] == "unsupported_type"
r = upload(pg_client2, b"%PDF-1.7\nthis is not really a pdf at all\n%%EOF\n", name="bad.pdf")
assert r.status_code == 422 and r.json()["code"] == "bad_pdf"
small = pg_settings.model_copy(update={"upload_max_bytes": 2048})
with phase2_client(small, fake_dir) as c2:
as_user(c2, "clerk.clara")
r = upload(c2, b"%PDF-1.4\n" + b"0" * 5000)
assert r.status_code == 413 and r.json()["code"] == "too_large"
with TestClient(c2.app) as anon:
assert anon.get(a["url"]).status_code == 401
# ---------------------------------------------------------------- transitions
def test_send_to_warehouse(
pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int], monkeypatch: pytest.MonkeyPatch
) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
r = step(pg_client2, pkt, "send_to_warehouse")
assert r.status_code == 403 and r.json() == {
"detail": "Say whether this packet needs a heat map.",
"code": "precondition",
}
pkt = set_header(pg_client2, pkt["id"], heat_map=False).json()
r = step(pg_client2, pkt, "send_to_warehouse")
assert r.status_code == 403 and r.json()["detail"] == "Attach the drawing first."
pkt = attach_drawing(pg_client2, pkt["id"])
assert "send_to_warehouse" in pkt["can"] and "send_to_warehouse" not in pkt["blocked"]
r = pg_client2.post(
f"/api/packets/{pkt['id']}/transitions",
json={"action": "send_to_warehouse", "expect": {"status": "warehouse", "dept": "03"}},
)
assert r.status_code == 409 and r.json()["code"] == "stale_step"
assert r.json()["current"] == {"status": "draft", "dept": "03", "version": pkt["version"]}
published: list[dict[str, Any]] = []
real = events.publish
monkeypatch.setattr(events, "publish", lambda conn, payload: (published.append(payload), real(conn, payload)))
out = ok_step(pg_client2, pkt, "send_to_warehouse")
assert out["status"] == "warehouse" and out["version"] == pkt["version"] + 1 and out["holder"] == "Warehouse"
assert out["wh"]["stage"] == "incoming" and out["wh"]["by"]["display_name"] == "Clara Clerk"
assert out["wh_sent_at"] and out["wh_skipped"] is False
assert published == [
{"event": "changed", "packet_id": pkt["id"], "version": out["version"], "status": "warehouse", "dept": "03"}
]
tr = _one(
pg_settings,
"SELECT action, from_status, to_status, by FROM packet_transitions WHERE packet_id = %s",
(pkt["id"],),
)
assert tr[:3] == ("send_to_warehouse", "draft", "warehouse")
lg = _one(
pg_settings, "SELECT action, transition_id FROM packet_log WHERE packet_id = %s ORDER BY id DESC", (pkt["id"],)
)
assert lg[0] == "Sent to warehouse" and lg[1] is not None
au = _one(pg_settings, "SELECT detail FROM audit_events WHERE action = 'packet.send_to_warehouse'")
assert au[0]["from"] == "draft" and au[0]["to"] == "warehouse"
as_user(pg_client2, "clerk.clara")
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}})
assert r.status_code == 403 and r.json()["code"] == "wrong_status"
def test_happy_path_file_and_reopen(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
pkt = happy_path(pg_client2, pg_settings, unit[1])
pid = pkt["id"]
seal = _one(pg_settings, "SELECT key_id, signature_id, broken_at FROM packet_seals WHERE packet_id = %s", (pid,))
assert seal[1] is not None and seal[2] is None
view = pg_client2.get(f"/api/packets/{pid}/seal").json()
assert view["valid"] is True and view["seal"]["key_id"] == seal[0]
actions = [
r[0]
for r in _all(pg_settings, "SELECT action FROM packet_transitions WHERE packet_id = %s ORDER BY id", (pid,))
]
assert actions == [
"send_to_warehouse",
"wh_release",
"assign",
"start",
"submit",
"send_to_qc",
"qc_accept",
"file",
]
assert pg_client2.get(f"/api/packets/{pid}/log").json()[0]["action"] == "Filed in the data book"
# filed: every write is refused, Admin included
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}})
assert r.status_code == 403 and r.json()["code"] == "filed"
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"title": "x"}})
assert r.status_code == 403 and r.json()["code"] == "filed"
as_user(pg_client2, "admin.ann")
r = pg_client2.patch(
f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}, "it_reason": "Fix a typo in the drawing"}
)
assert r.status_code == 403 and r.json()["code"] == "filed"
# the data book date: Clerical and Q.C., not the warehouse
for sam, code in (("clerk.clara", 200), ("qc.quinn", 200), ("wh.wade", 403)):
as_user(pg_client2, sam)
r = pg_client2.post(f"/api/packets/{pid}/databook", json={})
assert r.status_code == code, (sam, r.text)
assert pg_client2.get(f"/api/packets/{pid}/seal").json()["valid"] is True
# reopen by Admin with a reason and a signature breaks the seal
as_user(pg_client2, "clerk.clara")
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed", "sign": SIGN})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(pg_client2, "admin.ann")
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "short", "sign": SIGN})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed"})
assert r.status_code == 400 and r.json()["code"] == "signature_required"
r = pg_client2.post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed", "sign": SIGN})
assert r.status_code == 200, r.text
assert r.json()["status"] == "complete" and r.json()["seal"]["broken_at"] is not None
view = pg_client2.get(f"/api/packets/{pid}/seal").json()
assert view["seal"] is None and view["seals"][0]["broken_reason"] == "Wrong heat number filed"
def test_co_hold_blocks_filing(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
pid = pkt["id"]
with app_conn(pg_settings) as conn:
conn.execute("UPDATE packets SET status = 'complete', routed_to = 'shipping' WHERE id = %s", (pid,))
co = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review) VALUES (%s, 1, 'manual', 'approved')"
" RETURNING id",
(pid,),
).fetchone()[0]
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (co,))
pkt = get(pg_client2, pid)
assert pkt["open_cos"] == [1] and "file" in pkt["blocked"]
r = step(pg_client2, pkt, "file", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold"
assert r.json()["detail"] == "Change order 1 is waiting for verification by Weld Parts."
as_user(pg_client2, "admin.ann")
r = step(pg_client2, pkt, "file", sign=SIGN, it_reason="Close out the old job now")
assert r.status_code == 403 and r.json()["code"] == "co_hold"
card = pg_client2.get("/api/packets", params={"status": "complete"}).json()["packets"][0]
assert card["open_cos"] == [1] and "file" in card["blocked"]
def test_admin_bypass_needs_it_reason(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
as_user(pg_client2, "admin.ann")
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "IT title"}})
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
r = pg_client2.patch(
f"/api/packets/{pkt['id']}", json={"set": {"title": "IT title"}, "it_reason": "Ticket 4411 fix"}
)
assert r.status_code == 200 and r.json()["title"] == "IT title"
log = pg_client2.get(f"/api/packets/{pkt['id']}/log").json()[0]
assert log["it_reason"] == "Ticket 4411 fix" and log["by"]["display_name"] == "Ann Admin"
au = _one(pg_settings, "SELECT it_reason, detail FROM audit_events WHERE action = 'packet.edit_header'")
assert au[0] == "Ticket 4411 fix" and au[1]["fields"] == ["title"]
def test_return_to_clerical_only_when_wh_skipped(
pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]
) -> None:
as_user(pg_client2, "clerk.clara")
pkt = ready_draft(pg_client2, unit[1])
r = step(pg_client2, pkt, "release_to_shop", dept="QC")
assert r.status_code == 422 and r.json()["code"] == "invalid_dept"
pkt = ok_step(pg_client2, pkt, "release_to_shop", dept="06", note="Rush job")
assert pkt["status"] == "ready" and pkt["dept"]["code"] == "06" and pkt["wh_skipped"] is True
assert pkt["stage"]["dept_code"] == "06" and pkt["return_note"] == "Rush job"
as_user(pg_client2, "lead.lou")
r = step(pg_client2, pkt, "return_to_clerical", reason="Not ours")
assert r.status_code == 403 and r.json()["code"] == "forbidden" # lead.lou leads 03, the packet is in 06
with app_conn(pg_settings) as conn:
conn.execute("UPDATE packets SET dept_code = '03' WHERE id = %s", (pkt["id"],))
pkt = get(pg_client2, pkt["id"])
r = step(pg_client2, pkt, "return_to_clerical", reason="short")
assert r.status_code == 400 and r.json()["code"] == "reason_required"
out = ok_step(pg_client2, pkt, "return_to_clerical", reason="Missing the second sheet")
assert out["status"] == "draft" and out["return_note"] == "Missing the second sheet" and out["stage"] is None
def test_assign_validation_and_draft_to_qc(
pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]
) -> None:
as_user(pg_client2, "clerk.clara")
pkt = ok_step(pg_client2, ready_draft(pg_client2, unit[1]), "release_to_shop", dept="03")
with app_conn(pg_settings) as conn:
other = conn.execute("SELECT id FROM stations WHERE dept_code = '06' LIMIT 1").fetchone()[0]
andy = conn.execute("SELECT id FROM users WHERE sam_account = 'weld.andy'").fetchone()[0]
as_user(pg_client2, "sup.wes")
r = step(pg_client2, pkt, "assign", station_id=other, welder_id=str(andy))
assert r.status_code == 422 and r.json()["code"] == "invalid_station"
r = step(pg_client2, pkt, "assign")
assert r.status_code == 403 and r.json()["detail"] == "Pick a station and a welder."
as_user(pg_client2, "clerk.clara")
q = ok_step(pg_client2, ready_draft(pg_client2, unit[1]), "send_draft_to_qc", note="Inspect as is")
assert q["status"] == "qc" and q["wh_skipped"] is True and q["qc"]["sent_note"] == "Inspect as is"
assert q["holder"] == "Q.C."
def test_move_and_remove(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
from pkthelpers import add_unit
with app_conn(pg_settings) as conn:
u2 = add_unit(conn, unit[0], "002")
u3 = add_unit(conn, unit[0], "7A", status="closed")
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
r = pg_client2.post(f"/api/packets/{pkt['id']}/move", json={"unit_id": u3})
assert r.status_code == 409 and r.json()["code"] == "unit_closed"
r = pg_client2.post(f"/api/packets/{pkt['id']}/move", json={"unit_id": u2, "reason": "Belongs to unit two"})
assert r.status_code == 200 and r.json()["unit"]["id"] == u2 and r.json()["unit"]["lot"] == "002"
assert pg_client2.get(f"/api/packets/{pkt['id']}/log").json()[0]["action"] == "Moved to another unit"
assert _one(pg_settings, "SELECT 1 FROM audit_events WHERE action = 'packet.moved'") is not None
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}", json={"reason": "dup"})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}", json={"reason": "Made twice by mistake"})
assert r.status_code == 200 and r.json()["removed"]["reason"] == "Made twice by mistake"
assert get(pg_client2, pkt["id"])["removed"] is not None and get(pg_client2, pkt["id"])["can"] == []
assert pg_client2.get("/api/packets").json()["packets"] == []
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}})
assert r.status_code == 403 and r.json()["code"] == "removed"
assert pg_client2.get("/api/packets", params={"removed": 1}).json()["packets"] == []
as_user(pg_client2, "admin.ann")
cards = pg_client2.get("/api/packets", params={"removed": 1}).json()["packets"]
assert [c["id"] for c in cards] == [pkt["id"]]
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}, "it_reason": "Restore the title"})
assert r.status_code == 403 and r.json()["code"] == "removed"
def test_list_filters_and_cards(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
with app_conn(pg_settings) as conn:
_, other_unit = make_unit(conn, number="L777")
as_user(pg_client2, "clerk.clara")
a = make_draft(pg_client2, unit[1], drawing="D-55", work_orders=[{"wo_base": "007123", "lot": "7A"}])
b = ready_draft(pg_client2, other_unit, title="Other one")
ok_step(pg_client2, b, "send_to_warehouse")
cards = pg_client2.get("/api/packets").json()["packets"]
assert {c["id"] for c in cards} == {a["id"], b["id"]}
card = next(c for c in cards if c["id"] == a["id"])
assert card["holder"] == "Clerical" and card["flag"] == "Made by hand - check it and send it on"
assert card["can"] == [] and set(card["blocked"]) == {"send_to_warehouse", "release_to_shop", "send_draft_to_qc"}
assert card["unit"]["id"] == unit[1] and card["dept"] == {"code": "03", "name": "Weld Parts"}
assert card["wo_count"] == 1 and card["open_shorts"] == 0 and card["open_cos"] == []
bcard = next(c for c in cards if c["id"] == b["id"])
assert bcard["wh_stage"] == "incoming" and bcard["flag"] is None and bcard["holder"] == "Warehouse"
def ids(**params: Any) -> list[int]:
return [c["id"] for c in pg_client2.get("/api/packets", params=params).json()["packets"]]
assert ids(status="warehouse") == [b["id"]]
assert ids(status="draft,warehouse", unit_id=unit[1]) == [a["id"]]
assert ids(q="d-55") == [a["id"]] and ids(q="007123") == [a["id"]]
assert ids(q="L777 other") == [b["id"]] and ids(q="L777 D-55") == []
assert ids(flag="1") == [a["id"]] and ids(flag="0") == [b["id"]]
as_user(pg_client2, "wh.wade")
wcard = next(c for c in pg_client2.get("/api/packets", params={"status": "warehouse"}).json()["packets"])
assert set(wcard["can"]) == {"wh_send_back", "wh_release"} # transitions only, no edits
def test_pm_reads_but_cannot_write(pg_client2: TestClient, unit: tuple[int, int]) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
as_user(pg_client2, "pm.paula")
view = get(pg_client2, pkt["id"])
assert view["can"] == [] and view["holder"] == "Clerical"
assert pg_client2.get("/api/packets").json()["packets"][0]["id"] == pkt["id"]
r = pg_client2.patch(f"/api/packets/{pkt['id']}", json={"set": {"title": "x"}})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
r = step(pg_client2, view, "send_to_warehouse")
assert r.status_code == 403 and r.json()["code"] == "forbidden"
def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int]) -> None:
as_user(pg_client2, "clerk.clara")
pkt = make_draft(pg_client2, unit[1])
url = f"/api/packets/{pkt['id']}/work-orders"
wos = {"work_orders": [{"wo_base": "0070001", "lot": "7A", "split": "", "sub": "01"}]}
assert pg_client2.put(url, json=wos).status_code == 428
r = pg_client2.put(url, json=wos, headers={"If-Match": str(pkt["version"] + 5)})
assert r.status_code == 409 and r.json()["code"] == "version_conflict"
r = pg_client2.put(url, json=wos, headers={"If-Match": str(pkt["version"])})
assert r.status_code == 200
wo = r.json()["work_orders"]
assert [(w["wo_base"], w["lot"], w["sub"]) for w in wo] == [("0070001", "7A", "01")]
a = upload(pg_client2, tiny_pdf(1)).json()
r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "mtr", "title": "MTR 1"})
assert r.status_code == 200 and r.json()["docs"][0]["title"] == "MTR 1"
doc_id = r.json()["docs"][0]["id"]
as_user(pg_client2, "wh.wade")
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={})
assert r.status_code == 403
as_user(pg_client2, "qc.quinn")
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={"reason": "wrong file"})
assert r.status_code == 200 and r.json()["docs"] == []
def test_seal_detects_tampering(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
pkt = happy_path(pg_client2, pg_settings, unit[1])
with app_conn(pg_settings) as conn:
conn.execute("UPDATE packets SET title = 'changed behind the app' WHERE id = %s", (pkt["id"],))
conn.execute("UPDATE bom_lines SET heat = 'X' WHERE packet_id = %s", (pkt["id"],))
conn.execute(
"UPDATE packet_stages SET checklist = %s WHERE packet_id = %s", (Jsonb({"dwg": {"done": False}}), pkt["id"])
)
assert pg_client2.get(f"/api/packets/{pkt['id']}/seal").json()["valid"] is False
+117
View File
@@ -0,0 +1,117 @@
"""The pipeline under concurrency: the FOR UPDATE lock serialises writers; counters never collide; the audit chain
holds after a full packet life; packet_log is append-only for the app role."""
from __future__ import annotations
import threading
from collections.abc import Iterator
from typing import Any
import psycopg
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import as_user, happy_path, make_unit, phase2_client, ready_draft, seed_users, step
from pshelpers import app_conn, needs_pg
from packetstation import audit
from packetstation.domain.pipeline import next_counter
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
with phase2_client(pg_settings, fake_dir) as client:
seed_users(client)
yield client
def test_same_step_twice_one_wins(pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
with app_conn(pg_settings) as conn:
_, unit_id = make_unit(conn)
as_user(pg_client2, "clerk.clara")
pkt = ready_draft(pg_client2, unit_id)
with phase2_client(pg_settings, fake_dir) as other:
as_user(other, "clerk.clara")
results: list[Any] = [None, None]
barrier = threading.Barrier(2)
def run(i: int, client: TestClient) -> None:
barrier.wait()
results[i] = step(client, pkt, "send_to_warehouse")
threads = [threading.Thread(target=run, args=(i, c)) for i, c in enumerate((pg_client2, other))]
for t in threads:
t.start()
for t in threads:
t.join(30)
codes = sorted(r.status_code for r in results)
assert codes == [200, 409], [r.text for r in results]
loser = next(r for r in results if r.status_code == 409)
assert loser.json()["code"] == "stale_step"
assert loser.json()["current"]["status"] == "warehouse" and loser.json()["current"]["version"] == pkt["version"] + 1
with app_conn(pg_settings) as conn:
n = conn.execute("SELECT count(*) FROM packet_transitions WHERE packet_id = %s", (pkt["id"],)).fetchone()[0]
wh = conn.execute("SELECT count(*) FROM packet_wh WHERE packet_id = %s", (pkt["id"],)).fetchone()[0]
assert n == 1 and wh == 1
def test_next_counter_concurrent(pg_client2: TestClient, pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
_, unit_id = make_unit(conn)
pid = conn.execute(
"INSERT INTO packets (unit_id, dept_code) VALUES (%s, '03') RETURNING id", (unit_id,)
).fetchone()[0]
got: list[int] = []
lock = threading.Lock()
barrier = threading.Barrier(8)
def take() -> None:
with app_conn(pg_settings) as conn:
barrier.wait()
with conn.transaction():
n = next_counter(conn, pid, "weld")
with lock:
got.append(n)
threads = [threading.Thread(target=take) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(30)
assert sorted(got) == list(range(1, 9))
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT next_weld_no FROM packets WHERE id = %s", (pid,)).fetchone()[0] == 9
with conn.transaction():
assert next_counter(conn, pid, "page", 3) == 1
assert next_counter(conn, pid, "page") == 4
def test_audit_chain_after_happy_path(pg_client2: TestClient, pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
_, unit_id = make_unit(conn)
happy_path(pg_client2, pg_settings, unit_id)
with app_conn(pg_settings) as conn:
ok, n, detail = audit.verify_chain(conn)
actions = {r[0] for r in conn.execute("SELECT action FROM audit_events")}
assert ok, detail
assert n > 20
assert {"packet.created", "packet.file", "packet.qc_accept", "sig.signed", "asset.uploaded"} <= actions
def test_packet_log_append_only(pg_client2: TestClient, pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
_, unit_id = make_unit(conn)
as_user(pg_client2, "clerk.clara")
pkt = ready_draft(pg_client2, unit_id)
for sql in (
"UPDATE packet_log SET note = 'x' WHERE packet_id = %s",
"DELETE FROM packet_log WHERE packet_id = %s",
"UPDATE packet_transitions SET reason = 'x' WHERE packet_id = %s",
):
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
conn.execute(sql, (pkt["id"],))
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
conn.execute("TRUNCATE packet_log")
+630
View File
@@ -0,0 +1,630 @@
"""The policy matrix: every role against every status for every transition, then the rules one by one.
The expected table below is written from the rule table in the phase 2 design contract, not from the code.
"""
from __future__ import annotations
import dataclasses
from types import MappingProxyType
from typing import Any
import pytest
from packetstation.policy import (
RULES,
STATUSES,
Action,
PacketState,
Principal,
can,
check,
holder_name,
holds,
principal_from_roles,
works_on,
)
from packetstation.policy.preconditions import INPUT_ONLY, MESSAGES
A = Action
WELDER = "00000000-0000-0000-0000-00000000a11d"
OTHER = "00000000-0000-0000-0000-0000000000b0"
IT = "Ticket 4411: fix a typo"
# Every input a step can need. The default state satisfies every state precondition.
FULL_EXTRA: dict[str, Any] = {
"reason": "Needs another look at the fit-up.",
"station_id": 1,
"welder_id": WELDER,
"dept": "06",
"routed_to": "databook",
"book": True,
"spool": True,
"location": "Rack 4",
}
def P(roles: list[str], user_id: str = OTHER, limits: tuple[int, ...] = ()) -> Principal:
return principal_from_roles(user_id, roles, station_limits=limits)
ROLES: dict[str, Principal] = {
"admin": P(["admin"]),
"clerical": P(["clerical"]),
"clerical_sup": P(["clerical_supervisor"]),
"warehouse": P(["warehouse"]),
"warehouse_sup": P(["warehouse_supervisor"]),
"qc": P(["qc"]),
"qc_sup": P(["qc_supervisor"]),
"pm": P(["pm"]),
"worker03": P(["shop:03:worker"]),
"lead03": P(["shop:03:lead"]),
"sup03": P(["shop:03:supervisor"]),
"worker06": P(["shop:06:worker"], user_id=WELDER),
# the assigned welder, but limited to a station that is not the packet's
"worker03_limited": P(["shop:03:worker"], user_id=WELDER, limits=(99,)),
"welder": P(["shop:03:worker"], user_id=WELDER),
}
def state(status: str, **kw: Any) -> PacketState:
base = PacketState(
id=1,
status=status,
dept="03",
station_id=1,
welder_id=WELDER,
wh_skipped=True,
routed_to="databook",
vclosed=True,
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
drawing_attached=True,
heat_map=True,
bom_total=3,
bom_ready=3,
welds=2,
checklist_complete=True,
filler_set=True,
open_shorts=1,
)
return dataclasses.replace(base, **kw)
CL = {"clerical", "clerical_sup"}
WH = {"warehouse", "warehouse_sup"}
QC = {"qc", "qc_sup"}
LEADS = {"lead03", "sup03"}
SUP = {"sup03"}
# action -> (from statuses, roles allowed). From the contract rule table.
EXPECTED: dict[Action, tuple[set[str], set[str]]] = {
A.SEND_TO_WAREHOUSE: ({"draft"}, CL),
A.RELEASE_TO_SHOP: ({"draft"}, CL),
A.SEND_DRAFT_TO_QC: ({"draft"}, CL),
A.WH_SEND_BACK: ({"warehouse"}, WH),
A.RETURN_TO_CLERICAL: ({"ready"}, LEADS),
A.WH_RELEASE: ({"warehouse"}, WH),
A.SEND_BACK_TO_WAREHOUSE: ({"ready"}, LEADS),
A.ASSIGN: ({"ready"}, LEADS),
A.UNASSIGN: ({"assigned"}, LEADS),
A.PULL_BACK: ({"in_progress"}, LEADS),
A.START: ({"assigned"}, {*LEADS, "welder"}),
A.SUBMIT: ({"assigned", "in_progress"}, {*LEADS, "welder"}),
A.RETURN_TO_STATION: ({"review"}, SUP),
A.SEND_TO_QC: ({"review"}, SUP),
A.ROUTE_FROM_REVIEW: ({"review"}, SUP),
A.QC_REJECT: ({"qc"}, QC),
A.QC_ACCEPT: ({"qc"}, QC),
A.QC_ACCEPT_AND_ROUTE: ({"qc"}, QC),
A.FILE: ({"complete"}, CL),
A.ADMIN_REOPEN: ({"filed"}, {"admin"}),
}
def expected(role: str, status: str, action: Action) -> str:
"""'' when the step passes, else the expected code."""
froms, allowed = EXPECTED[action]
if status not in froms:
return "wrong_status"
if role in allowed:
return ""
return "it_reason_required" if role == "admin" else "forbidden"
MATRIX = [(r, s, a) for r in ROLES for s in STATUSES for a in EXPECTED]
def test_expected_table_covers_every_user_transition() -> None:
transitions = {a for a, r in RULES.items() if r.to_status is not None and not r.system_only}
assert transitions == set(EXPECTED)
@pytest.mark.parametrize(("role", "status", "action"), MATRIX, ids=[f"{r}-{s}-{a}" for r, s, a in MATRIX])
def test_matrix(role: str, status: str, action: Action) -> None:
d = check(ROLES[role], state(status), action, extra=FULL_EXTRA)
want = expected(role, status, action)
assert d.ok is (want == ""), (d.code, d.why)
assert d.code == want
if d.ok:
assert d.signature == RULES[action].signature
assert d.admin_bypass is False
# ---------- custody ----------
HOLDERS: dict[str, set[str]] = {
"draft": {"clerical", "clerical_sup"},
"warehouse": {"warehouse", "warehouse_sup"},
"ready": {"lead03", "sup03"},
"assigned": {"lead03", "sup03", "worker03", "welder"},
"in_progress": {"lead03", "sup03", "worker03", "welder"},
"review": {"lead03", "sup03"},
"qc": {"qc", "qc_sup"},
"complete": {"clerical", "clerical_sup"},
"filed": set(),
}
@pytest.mark.parametrize("status", STATUSES)
def test_custody_per_status(status: str) -> None:
got = {name for name, p in ROLES.items() if holds(p, state(status))}
assert got == HOLDERS[status]
def test_holder_name() -> None:
assert holder_name(state("draft")) == "Clerical"
assert holder_name(state("warehouse")) == "Warehouse"
assert holder_name(state("qc")) == "Q.C."
assert holder_name(state("complete")) == "Clerical (data book)"
assert holder_name(state("filed")) == "the data book (filed)"
assert holder_name(state("ready"), "Weld Parts") == "Weld Parts"
assert holder_name(state("assigned"), "Weld Parts", "Stall 1") == "Weld Parts · Stall 1"
assert holder_name(state("review"), "Weld Parts", "Stall 1") == "Weld Parts"
assert holder_name(state("ready")) == "03"
def test_works_on_station_limits() -> None:
s = state("assigned", station_id=4)
assert works_on(P(["shop:03:worker"]), s)
assert works_on(P(["shop:03:worker"], limits=(4, 5)), s)
assert not works_on(P(["shop:03:worker"], limits=(5,)), s)
assert not works_on(P(["shop:06:worker"]), s)
# leads and supervisors ignore station limits
assert works_on(P(["shop:03:lead"], limits=(5,)), s)
assert works_on(P(["shop:03:supervisor"], limits=(5,)), s)
# a limited worker with no station on the packet does not work on it
assert not works_on(P(["shop:03:worker"], limits=(5,)), state("assigned", station_id=None))
@pytest.mark.parametrize("action", [A.START, A.SUBMIT])
def test_start_and_submit_need_the_assigned_welder_for_a_worker(action: Action) -> None:
s = state("assigned")
assert check(P(["shop:03:worker"], user_id=WELDER), s, action).ok
other = check(P(["shop:03:worker"], user_id=OTHER), s, action)
assert (other.ok, other.code) == (False, "forbidden")
assert other.why == "Your access doesn't allow this step."
unassigned = check(P(["shop:03:worker"], user_id=WELDER), state("assigned", welder_id=None), action)
assert unassigned.code == "forbidden"
limited = check(P(["shop:03:worker"], user_id=WELDER, limits=(2,)), s, action)
assert limited.code == "forbidden"
# a lead does not need to be the welder
assert check(P(["shop:03:lead"], user_id=OTHER), s, action).ok
assert check(P(["shop:03:lead"], user_id=OTHER), state("assigned", welder_id=None), action).ok
def test_worker_who_is_not_the_welder_still_holds_for_edits() -> None:
s = state("in_progress")
worker = P(["shop:03:worker"], user_id=OTHER)
assert check(worker, s, A.EDIT_WELDS).ok
assert check(worker, s, A.CLOCK).ok
assert check(P(["shop:03:worker"], limits=(9,)), s, A.EDIT_WELDS).code == "forbidden"
def test_leads_write_shop_fields_only_while_they_hold() -> None:
lead = ROLES["lead03"]
for status in ("ready", "assigned", "in_progress", "review"):
assert check(lead, state(status), A.EDIT_WELDS).ok
d = check(ROLES["lead03"], state("qc"), A.EDIT_WELDS)
assert d.code == "wrong_status"
other_dept = check(P(["shop:06:lead"]), state("review"), A.EDIT_BOM)
assert other_dept.code == "forbidden"
assert other_dept.why == "Your access doesn't allow changing the materials on this packet. It's with 03 right now."
named = check(P(["shop:06:lead"]), state("review"), A.EDIT_BOM, extra={"holder_name": "Weld Parts"})
assert named.why.endswith("It's with Weld Parts right now.")
# ---------- change-order hold ----------
CO_HOLD_ACTIONS = {a for a, r in RULES.items() if r.co_hold}
def test_co_hold_set_is_the_contract_set() -> None:
assert {
A.WH_RELEASE,
A.SUBMIT,
A.SEND_TO_QC,
A.ROUTE_FROM_REVIEW,
A.QC_ACCEPT,
A.QC_ACCEPT_AND_ROUTE,
A.FILE,
} == CO_HOLD_ACTIONS
@pytest.mark.parametrize("action", list(EXPECTED))
def test_co_hold_blocks_exactly_the_co_hold_actions(action: Action) -> None:
froms, allowed = EXPECTED[action]
status = sorted(froms)[0]
role = sorted(allowed)[0]
why = "Change order 2 must be verified first by: WH."
d = check(ROLES[role], state(status, co_hold=True, co_hold_why=why), action, extra=FULL_EXTRA)
if action in CO_HOLD_ACTIONS:
assert (d.ok, d.code, d.why) == (False, "co_hold", why)
else:
assert d.ok, (d.code, d.why)
def test_co_hold_applies_to_admin() -> None:
d = check(ROLES["admin"], state("warehouse", co_hold=True), A.WH_RELEASE, it_reason=IT)
assert (d.code, d.why) == ("co_hold", "A change order holds this step.")
# ---------- admin bypass ----------
def test_admin_bypass_needs_an_it_reason() -> None:
admin = ROLES["admin"]
d = check(admin, state("draft"), A.SEND_TO_WAREHOUSE)
assert (d.ok, d.code) == (False, "it_reason_required")
short = check(admin, state("draft"), A.SEND_TO_WAREHOUSE, it_reason="too sh")
assert short.code == "it_reason_required"
ok = check(admin, state("draft"), A.SEND_TO_WAREHOUSE, it_reason=IT)
assert ok.ok and ok.admin_bypass
def test_admin_bypass_passes_custody_and_is_marked() -> None:
admin = ROLES["admin"]
for status in ("ready", "assigned", "in_progress", "review"):
d = check(admin, state(status), A.EDIT_WELDS, it_reason=IT)
assert d.ok and d.admin_bypass
d = check(admin, state("in_progress"), A.SUBMIT, it_reason=IT)
assert d.ok and d.admin_bypass and d.signature == "Submit work to the supervisor"
def test_admin_who_also_holds_is_not_a_bypass() -> None:
both = P(["admin", "clerical"])
d = check(both, state("draft"), A.EDIT_HEADER)
assert d.ok and not d.admin_bypass
def test_admin_reopen_is_admin_only_and_not_a_bypass() -> None:
d = check(ROLES["admin"], state("filed"), A.ADMIN_REOPEN, extra=FULL_EXTRA)
assert d.ok and not d.admin_bypass and d.signature == "Reopen a filed packet"
for name, p in ROLES.items():
if name != "admin":
assert check(p, state("filed"), A.ADMIN_REOPEN, extra=FULL_EXTRA).code == "forbidden"
def test_admin_set_databook_at_needs_no_reason() -> None:
d = check(ROLES["admin"], state("filed"), A.SET_DATABOOK_AT)
assert d.ok and d.admin_bypass
def test_itp_reopen_qc_supervisor_or_admin_with_it_reason() -> None:
extra = {"reason": "Wrong line signed by mistake."}
assert check(ROLES["qc_sup"], state("complete"), A.ITP_REOPEN, extra=extra).ok
assert check(ROLES["qc"], state("complete"), A.ITP_REOPEN, extra=extra).code == "forbidden"
assert check(ROLES["admin"], state("complete"), A.ITP_REOPEN, extra=extra).code == "it_reason_required"
assert check(ROLES["admin"], state("complete"), A.ITP_REOPEN, extra=extra, it_reason=IT).ok
# ---------- filed lock and removed ----------
@pytest.mark.parametrize("action", list(RULES))
def test_filed_lock(action: Action) -> None:
s = state("filed")
for name, p in ROLES.items():
d = check(p, s, action, extra={**FULL_EXTRA, "dept": "03"}, it_reason=IT)
if action is A.SET_DATABOOK_AT and name in {"admin", "clerical", "clerical_sup", "qc", "qc_sup"}:
assert d.ok, (name, d.code)
elif action is A.ADMIN_REOPEN and name == "admin":
assert d.ok
else:
assert not d.ok, (name, action)
if not RULES[action].from_status and not RULES[action].system_only and name == "admin":
assert d.code == "filed"
assert d.why == "This packet is filed and locked. IT can reopen it if a correction is needed."
@pytest.mark.parametrize("action", list(RULES))
def test_removed_refuses_everything(action: Action) -> None:
for status in STATUSES:
s = state(status, removed=True)
for p in [*ROLES.values(), Principal(user_id=None, system="visual-feed")]:
d = check(p, s, action, extra=FULL_EXTRA, it_reason=IT)
assert (d.ok, d.code) == (False, "removed")
def test_unknown_status_is_wrong_status() -> None:
for action in RULES:
d = check(ROLES["admin"], state("shipped"), action, extra=FULL_EXTRA, it_reason=IT)
assert d.code == "wrong_status"
allowed, blocked = can(ROLES["clerical"], state("shipped"))
assert allowed == [] and blocked == {}
# ---------- preconditions ----------
# (action, status, role, state change or extra change, precondition name)
PRECONDITION_CASES: list[tuple[Action, str, str, dict[str, Any], dict[str, Any], str]] = [
(A.SEND_TO_WAREHOUSE, "draft", "clerical", {"heat_map": None}, {}, "heat_map_answered"),
(A.SEND_TO_WAREHOUSE, "draft", "clerical", {"drawing_attached": False}, {}, "drawing_attached"),
(A.SEND_DRAFT_TO_QC, "draft", "clerical", {"drawing_attached": False}, {}, "drawing_attached"),
(A.RELEASE_TO_SHOP, "draft", "clerical", {}, {"dept": ""}, "target_dept_given"),
(A.WH_RELEASE, "warehouse", "warehouse", {"bom_ready": 2}, {}, "bom_all_pulled_or_short"),
(A.ASSIGN, "ready", "lead03", {}, {"welder_id": None}, "welder_set"),
(A.SUBMIT, "in_progress", "welder", {"checklist_complete": False}, {}, "checklist_complete"),
(A.SUBMIT, "in_progress", "welder", {"welds": 0}, {}, "welds_recorded"),
(A.SUBMIT, "in_progress", "welder", {"filler_set": False}, {}, "filler_set"),
(A.SEND_TO_QC, "review", "sup03", {}, {"spool": False}, "qc_book_spool_location"),
(A.SEND_TO_QC, "review", "sup03", {}, {"location": " "}, "qc_book_spool_location"),
(A.ROUTE_FROM_REVIEW, "review", "sup03", {}, {"dept": "03"}, "target_dept_given"),
(A.QC_ACCEPT, "qc", "qc", {"qc_missing": ("weld exam",)}, {}, "qc_missing_empty"),
(A.QC_ACCEPT, "qc", "qc", {}, {"routed_to": "scrap"}, "route_given"),
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {}, {"dept": "WH"}, "target_dept_given"),
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "vclosed_if_databook"),
(A.WH_SEND_BACK, "warehouse", "warehouse", {}, {"reason": "short"}, "reason_given"),
(A.QC_REJECT, "qc", "qc", {}, {"reason": None}, "reason_given"),
(A.REMOVE, "draft", "clerical", {}, {"reason": " x "}, "reason_given"),
(A.WH_RECEIVE_SHORT, "ready", "warehouse", {"open_shorts": 0}, {}, "short_open"),
]
@pytest.mark.parametrize(
("action", "status", "role", "change", "extra", "name"),
PRECONDITION_CASES,
ids=[f"{c[0]}-{c[5]}" for c in PRECONDITION_CASES],
)
def test_each_precondition_fails_alone(
action: Action, status: str, role: str, change: dict[str, Any], extra: dict[str, Any], name: str
) -> None:
s = state(status, **change)
x = {**FULL_EXTRA, **extra}
assert check(ROLES[role], state(status), action, extra=FULL_EXTRA).ok
d = check(ROLES[role], s, action, extra=x)
want = "reason_required" if name == "reason_given" else "precondition"
assert (d.ok, d.code) == (False, want)
if name == "qc_missing_empty":
assert d.why == "Q.C. still needs: weld exam."
elif name == "target_dept_given" and x["dept"] in ("", "WH"):
assert d.why == "Choose a department."
else:
assert d.why == MESSAGES[name]
# never bypassed by admin
a = check(ROLES["admin"], s, action, extra=x, it_reason=IT)
assert (a.ok, a.code, a.why) == (False, want, d.why)
def test_every_named_precondition_is_tested() -> None:
used = {n for r in RULES.values() for n in r.preconditions}
assert used <= {c[5] for c in PRECONDITION_CASES}
def test_filler_and_welds_only_in_weld_departments() -> None:
s = state("in_progress", weld_dept=False, welds=0, filler_set=False)
assert check(ROLES["welder"], s, A.SUBMIT).ok
def test_vclosed_only_needed_for_the_data_book_route() -> None:
assert check(ROLES["clerical"], state("complete", routed_to="shipping", vclosed=False), A.FILE).ok
def test_co_staged_if_dwg_change() -> None:
from packetstation.policy.preconditions import co_staged_if_dwg_change
s = state("draft")
assert co_staged_if_dwg_change(s, {}) is None
assert co_staged_if_dwg_change(s, {"dwg_change": True, "staged": True}) is None
assert co_staged_if_dwg_change(s, {"dwg_change": True}) == MESSAGES["co_staged_if_dwg_change"]
# ---------- specific rules ----------
@pytest.mark.parametrize("status", [s for s in STATUSES if s != "filed"])
def test_wh_receive_short_passes_in_every_non_filed_status(status: str) -> None:
assert check(ROLES["warehouse"], state(status), A.WH_RECEIVE_SHORT).ok
assert check(ROLES["warehouse_sup"], state(status), A.WH_RECEIVE_SHORT).ok
assert check(ROLES["lead03"], state(status), A.WH_RECEIVE_SHORT).code == "forbidden"
def test_wh_receive_short_refused_when_filed() -> None:
assert check(ROLES["warehouse"], state("filed"), A.WH_RECEIVE_SHORT).code == "filed"
def test_wh_receive_short_needs_an_open_short() -> None:
d = check(ROLES["warehouse"], state("ready", open_shorts=0), A.WH_RECEIVE_SHORT)
assert d.code == "precondition" and d.why == MESSAGES["short_open"]
allowed, blocked = can(ROLES["warehouse"], state("draft", open_shorts=0))
assert allowed == [] and blocked == {A.WH_RECEIVE_SHORT: [MESSAGES["short_open"]]}
def test_return_to_clerical_needs_wh_skipped() -> None:
assert check(ROLES["lead03"], state("ready"), A.RETURN_TO_CLERICAL, extra=FULL_EXTRA).ok
d = check(ROLES["lead03"], state("ready", wh_skipped=False), A.RETURN_TO_CLERICAL, extra=FULL_EXTRA)
assert d.code == "wrong_status"
assert "warehouse" in d.why
def test_set_vclosed_needs_the_data_book_route() -> None:
assert check(ROLES["clerical"], state("complete"), A.SET_VCLOSED).ok
for route in ("shipping", None):
d = check(ROLES["clerical"], state("complete", routed_to=route), A.SET_VCLOSED)
assert d.code == "wrong_status"
assert check(ROLES["qc"], state("complete"), A.SET_VCLOSED).code == "forbidden"
@pytest.mark.parametrize("status", STATUSES)
def test_set_databook_at_is_clerical_or_qc(status: str) -> None:
s = state(status)
for role in ("clerical", "clerical_sup", "qc", "qc_sup"):
assert check(ROLES[role], s, A.SET_DATABOOK_AT).ok
for role in ("warehouse", "warehouse_sup", "sup03", "lead03", "pm", "welder"):
assert check(ROLES[role], s, A.SET_DATABOOK_AT).code == "forbidden"
def test_rev_approve_needs_clerical_supervisor() -> None:
extra = {"reason": "Drafting confirmed rev C."}
assert check(ROLES["clerical_sup"], state("draft"), A.REV_APPROVE, extra=extra).ok
assert check(ROLES["clerical"], state("draft"), A.REV_APPROVE, extra=extra).code == "forbidden"
def test_co_verify_needs_a_supervisor_of_the_named_department() -> None:
s = state("in_progress")
assert check(ROLES["sup03"], s, A.CO_VERIFY, extra={"dept": "03"}).ok
assert check(ROLES["sup03"], s, A.CO_VERIFY, extra={"dept": "06"}).code == "forbidden"
assert check(ROLES["lead03"], s, A.CO_VERIFY, extra={"dept": "03"}).code == "forbidden"
assert check(ROLES["warehouse_sup"], s, A.CO_VERIFY, extra={"dept": "WH"}).ok
assert check(ROLES["warehouse"], s, A.CO_VERIFY, extra={"dept": "WH"}).code == "forbidden"
assert check(ROLES["qc_sup"], s, A.CO_VERIFY, extra={"dept": "QC"}).ok
assert check(ROLES["clerical_sup"], s, A.CO_VERIFY, extra={"dept": "CL"}).code == "forbidden"
assert check(ROLES["sup03"], s, A.CO_VERIFY).code == "forbidden"
@pytest.mark.parametrize("action", list(RULES))
def test_pm_can_do_nothing(action: Action) -> None:
pm = ROLES["pm"]
for status in STATUSES:
assert not check(pm, state(status), action, extra=FULL_EXTRA).ok
for status in STATUSES:
assert can(pm, state(status)) == ([], {})
SYSTEM = Principal(user_id=None, system="visual-feed")
VIS = {A.VIS_UPDATE_DRAFT, A.VIS_OPEN_CO, A.VIS_AUTO_RELEASE, A.VIS_RETIRE_DRAFT}
VIS_FROM = {
A.VIS_UPDATE_DRAFT: {"draft"},
A.VIS_RETIRE_DRAFT: {"draft"},
A.VIS_AUTO_RELEASE: {"draft"},
A.VIS_OPEN_CO: set(STATUSES) - {"draft", "filed"},
}
@pytest.mark.parametrize("action", list(RULES))
def test_system_principal(action: Action) -> None:
assert {a for a, r in RULES.items() if r.system_only} == VIS
for status in STATUSES:
d = check(SYSTEM, state(status), action)
if action in VIS:
assert d.ok is (status in VIS_FROM[action]), (status, d.code)
if not d.ok:
assert d.code == "wrong_status"
elif not d.ok and d.code != "wrong_status":
assert d.code == "forbidden"
else:
assert not d.ok
assert can(SYSTEM, state("draft")) == ([], {})
@pytest.mark.parametrize("action", sorted(VIS))
def test_people_cannot_do_vis_actions(action: Action) -> None:
s = state(sorted(VIS_FROM[action])[0])
for p in ROLES.values():
d = check(p, s, action, it_reason=IT)
assert (d.ok, d.code) == (False, "system_only")
def test_vis_auto_release_preconditions() -> None:
d = check(SYSTEM, state("draft", heat_map=None), A.VIS_AUTO_RELEASE)
assert (d.code, d.why) == ("precondition", MESSAGES["heat_map_answered"])
# ---------- can and blocked ----------
def test_can_and_blocked_on_a_bare_draft() -> None:
s = state("draft", heat_map=None, drawing_attached=False)
allowed, blocked = can(ROLES["clerical"], s)
reasons = [MESSAGES["heat_map_answered"], MESSAGES["drawing_attached"]]
assert blocked[A.SEND_TO_WAREHOUSE] == reasons
assert blocked[A.SEND_DRAFT_TO_QC] == reasons
assert blocked[A.RELEASE_TO_SHOP] == reasons
assert A.SEND_TO_WAREHOUSE not in allowed
assert {A.EDIT_HEADER, A.EDIT_BOM, A.EDIT_WOS, A.REMOVE, A.CO_ISSUE, A.DOC_ADD} <= set(allowed)
assert A.REV_APPROVE not in allowed # clerical supervisor only
assert all(not RULES[a].system_only for a in allowed)
wh_allowed, wh_blocked = can(ROLES["warehouse"], s)
assert A.SEND_TO_WAREHOUSE not in wh_allowed
assert A.SEND_TO_WAREHOUSE not in wh_blocked
assert wh_allowed == [A.WH_RECEIVE_SHORT]
def test_can_skips_input_only_preconditions() -> None:
assert {"welder_set", "target_dept_given", "route_given", "reason_given"} <= INPUT_ONLY
allowed, _ = can(ROLES["lead03"], state("ready"))
assert {A.ASSIGN, A.SEND_BACK_TO_WAREHOUSE, A.RETURN_TO_CLERICAL} <= set(allowed)
allowed, _ = can(ROLES["sup03"], state("review"))
assert {A.SEND_TO_QC, A.ROUTE_FROM_REVIEW, A.RETURN_TO_STATION} <= set(allowed)
allowed, _ = can(ROLES["qc"], state("qc"))
assert {A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE, A.QC_REJECT} <= set(allowed)
def test_blocked_by_co_hold_only_after_who_passes() -> None:
s = state("warehouse", co_hold=True, co_hold_why="Held for CO 1.", bom_ready=1)
_, blocked = can(ROLES["warehouse"], s)
assert blocked[A.WH_RELEASE] == ["Held for CO 1.", MESSAGES["bom_all_pulled_or_short"]]
_, other = can(ROLES["clerical"], s)
assert A.WH_RELEASE not in other
def test_can_for_admin_assumes_an_it_reason() -> None:
from packetstation.policy import evaluate
allowed, _ = can(ROLES["admin"], state("draft"))
assert A.SEND_TO_WAREHOUSE in allowed and A.ADMIN_REOPEN not in allowed
assert evaluate(ROLES["admin"], state("draft"), A.SEND_TO_WAREHOUSE).admin_bypass
no_assume, _ = can(ROLES["admin"], state("draft"), assume_it_reason=False)
assert A.SEND_TO_WAREHOUSE not in no_assume
filed, _ = can(ROLES["admin"], state("filed"))
assert set(filed) == {A.ADMIN_REOPEN, A.SET_DATABOOK_AT}
assert not evaluate(ROLES["admin"], state("filed"), A.ADMIN_REOPEN).admin_bypass
def test_can_for_the_welder() -> None:
allowed, blocked = can(ROLES["welder"], state("in_progress", checklist_complete=False))
assert A.SUBMIT in blocked and blocked[A.SUBMIT] == [MESSAGES["checklist_complete"]]
assert {A.EDIT_WELDS, A.CLOCK, A.EDIT_CHECKLIST} <= set(allowed)
assert A.PULL_BACK not in allowed
# ---------- principal_from_roles ----------
def test_principal_from_roles() -> None:
cs = principal_from_roles("u1", ["clerical_supervisor"])
assert cs.support == {"CL"} and cs.support_sup == {"CL"}
c = principal_from_roles("u1", ["clerical"])
assert c.support == {"CL"} and c.support_sup == frozenset()
w = principal_from_roles("u1", ["warehouse_supervisor", "qc"])
assert w.support == {"WH", "QC"} and w.support_sup == {"WH"}
shop = principal_from_roles("u1", ["shop:03:worker", "shop:06:supervisor", "shop:03:lead", "shop:10:worker"])
assert dict(shop.shop) == {"03": 2, "06": 3, "10": 1}
assert isinstance(shop.shop, MappingProxyType)
admin = principal_from_roles("u1", ["admin"])
assert admin.admin and not admin.support and not admin.shop and not admin.pm
pm = principal_from_roles("u1", ["pm"], session_kind="pin")
assert pm.pm and pm.session_kind == "pin" and not pm.support
limited = principal_from_roles("u1", ["shop:03:worker"], station_limits=[3, 4])
assert limited.station_limits == {3, 4}
assert principal_from_roles("u1", ["nonsense", "shop:03:boss"]) == Principal(user_id="u1")
assert principal_from_roles("u1", []).system is None
def test_multi_role_user() -> None:
mia = principal_from_roles("u9", ["clerical", "shop:06:supervisor"])
assert check(mia, state("draft"), A.SEND_TO_WAREHOUSE).ok
assert check(mia, state("review", dept="06"), A.SEND_TO_QC, extra=FULL_EXTRA).ok
assert check(mia, state("review"), A.SEND_TO_QC, extra=FULL_EXTRA).code == "forbidden"
+393
View File
@@ -0,0 +1,393 @@
"""Projects and units API: numbers, lots, aliases, renumbering, field CAS, soft delete, counts and access."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
from packetstation.api import projects, units
from packetstation.domain.projects import normalize_lot
from packetstation.settings import Settings
pytestmark = needs_pg
IT = "Ticket 4411: fixing a typo for Clerical"
@pytest.fixture
def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
from packetstation.main import create_app
app = create_app(pg_settings, directory=fake_dir)
app.include_router(projects.router)
app.include_router(units.router)
# the static catch-all must stay last, or it answers the GET routes added here
app.router.routes.sort(key=lambda r: getattr(r, "path", "") == "/{path:path}")
with TestClient(app, headers=ORIGIN_HEADERS) as c:
yield c
def as_user(client: TestClient, sam: str) -> None:
client.post("/api/auth/signout")
r = sign_in(client, sam)
assert r.status_code == 200, r.text
def audit_rows(settings: Settings, prefix: str) -> list[dict[str, Any]]:
from psycopg.rows import dict_row
with app_conn(settings) as conn, conn.cursor(row_factory=dict_row) as cur:
return cur.execute(
"SELECT action, subject_type, subject_id, it_reason, detail FROM audit_events WHERE action LIKE %s"
" ORDER BY id",
(prefix + "%",),
).fetchall()
def add_packet(settings: Settings, unit_id: int, status: str = "draft") -> int:
with app_conn(settings) as conn:
return int(
conn.execute(
"INSERT INTO packets (unit_id, dept_code, status) VALUES (%s, '03', %s) RETURNING id", (unit_id, status)
).fetchone()[0]
)
def make_project(client: TestClient, number: str = "L670", **extra: Any) -> dict[str, Any]:
r = client.post("/api/projects", json={"number": number, "name": "Separator skids", **extra})
assert r.status_code == 201, r.text
out: dict[str, Any] = r.json()
return out
def add_units(client: TestClient, project_id: int, **body: Any) -> list[dict[str, Any]]:
r = client.post(f"/api/projects/{project_id}/units", json=body)
assert r.status_code == 201, r.text
out: list[dict[str, Any]] = r.json()
return out
def test_normalize_lot() -> None:
assert normalize_lot("7") == "007"
assert normalize_lot("7a") == "7A"
assert normalize_lot(" 12 ") == "012"
assert normalize_lot("A") == "A"
def test_create_project_rules(client: TestClient, pg_settings: Settings) -> None:
as_user(client, "clerk.clara")
r = client.post(
"/api/projects",
json={"number": " l670 ", "name": "Separator skids", "customer": "Acme", "po": "PO-1", "due": "2026-12-01"},
)
assert r.status_code == 201, r.text
p = r.json()
assert p["number"] == "L670" and p["status"] == "open" and p["due"] == "2026-12-01" and p["version"] == 1
assert p["units"] == 0 and p["packets"]["total"] == 0 and p["source"] == "hand"
rows = audit_rows(pg_settings, "project.")
assert rows[-1]["action"] == "project.created" and rows[-1]["subject_type"] == "project"
assert rows[-1]["subject_id"] == str(p["id"]) and rows[-1]["it_reason"] is None
r = client.post("/api/projects", json={"number": "L670"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
r = client.post("/api/projects", json={"number": "L6 70!"})
assert r.status_code == 422 and r.json()["code"] == "invalid_number"
r = client.post("/api/projects", json={"number": "L680", "due": "12/01/2026"})
assert r.status_code == 422 and r.json()["code"] == "invalid_date"
as_user(client, "wh.wade")
r = client.post("/api/projects", json={"number": "L680"})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(client, "admin.ann")
r = client.post("/api/projects", json={"number": "L680"})
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
r = client.post("/api/projects", json={"number": "L680", "it_reason": "short"})
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
r = client.post("/api/projects", json={"number": "L680", "it_reason": IT})
assert r.status_code == 201, r.text
last = audit_rows(pg_settings, "project.")[-1]
assert last["action"] == "project.created" and last["it_reason"] == IT
def test_add_units(client: TestClient, pg_settings: Settings) -> None:
as_user(client, "clerk.clara")
p = make_project(client)
one = add_units(client, p["id"], lot="1", name="Skid A")
assert [(u["lot"], u["number"], u["name"]) for u in one] == [("001", "L670001", "Skid A")]
several = add_units(client, p["id"], count=3, start="005", name="Skid B")
assert [u["number"] for u in several] == ["L670005", "L670006", "L670007"]
assert add_units(client, p["id"], lot="7a")[0]["lot"] == "7A"
# a full unit number is cut to its lot; with nothing given the next free lot is used
assert add_units(client, p["id"], lot="L670012")[0]["lot"] == "012"
assert add_units(client, p["id"])[0]["lot"] == "013"
assert [u["lot"] for u in add_units(client, p["id"], lots=["20", "21"])] == ["020", "021"]
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "0001"})
assert r.status_code == 422 and r.json()["code"] == "invalid_lot"
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "001"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
r = client.post(f"/api/projects/{p['id']}/units", json={"count": 3, "start": "004"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
r = client.post(f"/api/projects/{p['id']}/units", json={"count": 3, "start": "998"})
assert r.status_code == 422
r = client.post(f"/api/projects/{p['id']}/units", json={"count": 0, "start": "100"})
assert r.status_code == 422 and r.json()["code"] == "invalid_count"
created = [r for r in audit_rows(pg_settings, "unit.") if r["action"] == "unit.created"]
assert len(created) == 9 and created[0]["subject_type"] == "unit" and created[0]["detail"]["number"] == "L670001"
detail = client.get(f"/api/projects/{p['id']}").json()
assert detail["next_lot"] == "022" and len(detail["units"]) == 9
def test_list_counts_and_search(client: TestClient, pg_settings: Settings) -> None:
as_user(client, "clerk.clara")
p = make_project(client, customer="Acme Gas", po="PO-77")
make_project(client, "L700", name="Heater treaters")
u1, u2 = add_units(client, p["id"], count=2, start="001")
for status in ("draft", "ready", "in_progress", "qc", "filed"):
add_packet(pg_settings, u1["id"], status)
add_packet(pg_settings, u2["id"], "warehouse")
gone = add_packet(pg_settings, u2["id"], "complete")
with app_conn(pg_settings) as conn:
conn.execute("UPDATE packets SET removed_at = now() WHERE id = %s", (gone,))
listing = {x["number"]: x for x in client.get("/api/projects").json()}
assert set(listing) == {"L670", "L700"}
assert listing["L670"]["units"] == 2
assert listing["L670"]["packets"] == {
"draft": 1,
"warehouse": 1,
"shop": 2,
"qc": 1,
"complete": 0,
"filed": 1,
"total": 6,
}
detail = client.get(f"/api/projects/{p['id']}").json()
by_lot = {u["lot"]: u for u in detail["units"]}
assert by_lot["001"]["packets"]["total"] == 5 and by_lot["002"]["packets"]["total"] == 1
assert detail["aliases"] == []
def numbers(**params: str) -> list[str]:
return [x["number"] for x in client.get("/api/projects", params=params).json()]
assert numbers(q="separator") == ["L670"]
assert numbers(q="acme po-77") == ["L670"]
assert numbers(q="heater") == ["L700"]
assert numbers(q="acme heater") == []
assert client.get("/api/projects", params={"status": "bogus"}).status_code == 422
picker = client.get("/api/units", params={"project_id": p["id"]}).json()
assert [u["number"] for u in picker] == ["L670001", "L670002"]
assert [u["number"] for u in client.get("/api/units", params={"q": "L670002"}).json()] == ["L670002"]
one = client.get(f"/api/units/{u1['id']}").json()
assert one["project"]["number"] == "L670" and one["packets"]["shop"] == 2 and one["due_effective"] is None
def test_patch_field_cas(client: TestClient, pg_settings: Settings) -> None:
as_user(client, "clerk.clara")
p = make_project(client, customer="Acme")
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Gas"}, "base": {"customer": "Acme"}})
assert r.status_code == 200, r.text
assert r.json()["customer"] == "Acme Gas" and r.json()["version"] == 2
# a stale base conflicts and returns the current value
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Oil"}, "base": {"customer": "Acme"}})
assert r.status_code == 409 and r.json()["code"] == "field_conflict"
assert r.json()["current"] == {"customer": "Acme Gas"}
# no base: mine wins; a value equal to the current one is not a conflict
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"due": "2027-01-15", "status": "hold"}})
assert r.status_code == 200 and r.json()["due"] == "2027-01-15" and r.json()["status"] == "hold"
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Gas"}, "base": {"customer": "x"}})
assert r.status_code == 200 and r.json()["version"] == 3
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"number": "L999"}})
assert r.status_code == 422 and r.json()["code"] == "invalid_field"
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"status": "done"}})
assert r.status_code == 422 and r.json()["code"] == "invalid_status"
updated = [x for x in audit_rows(pg_settings, "project.") if x["action"] == "project.updated"]
assert len(updated) == 2 and updated[0]["detail"]["fields"]["customer"] == {"from": "Acme", "to": "Acme Gas"}
(u,) = add_units(client, p["id"], lot="1")
r = client.patch(
f"/api/units/{u['id']}", json={"set": {"name": "Skid A", "due": "2027-02-01"}, "base": {"name": None}}
)
assert r.status_code == 200 and r.json()["name"] == "Skid A" and r.json()["due_effective"] == "2027-02-01"
r = client.patch(f"/api/units/{u['id']}", json={"set": {"name": "Skid Z"}, "base": {"name": "Skid Q"}})
assert r.status_code == 409 and r.json()["current"] == {"name": "Skid A"}
assert audit_rows(pg_settings, "unit.updated")[-1]["subject_id"] == str(u["id"])
def test_renumber_project(client: TestClient, pg_settings: Settings) -> None:
as_user(client, "clerk.clara")
p = make_project(client)
u1, u2 = add_units(client, p["id"], count=2, start="001")
pk = add_packet(pg_settings, u1["id"])
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671", "reason": "Wrong number"})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(client, "clerk.leo")
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671"})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "l671", "reason": "Wrong number"})
assert r.status_code == 200, r.text
assert r.json()["number"] == "L671" and r.json()["version"] == 2
def look(n: str) -> dict[str, Any]:
res = client.get("/api/lookup/number", params={"n": n})
assert res.status_code == 200, (n, res.text)
out: dict[str, Any] = res.json()
return out
assert look("L670") == {"kind": "project", "id": p["id"], "project_id": p["id"], "number": "L671", "alias": True}
assert look("L671")["alias"] is False and look("L671")["id"] == p["id"]
assert look("l670002") == {
"kind": "unit",
"id": u2["id"],
"project_id": p["id"],
"number": "L671002",
"alias": True,
}
assert look("L671001")["id"] == u1["id"] and look("L671001")["alias"] is False
assert client.get("/api/lookup/number", params={"n": "L999"}).status_code == 404
with app_conn(pg_settings) as conn:
rows = conn.execute(
"SELECT unit_id, old_number, new_number, reason FROM project_renumbers ORDER BY id"
).fetchall()
assert rows == [
(None, "L670", "L671", "Wrong number"),
(u1["id"], "L670001", "L671001", "Wrong number"),
(u2["id"], "L670002", "L671002", "Wrong number"),
]
# the packet still points at its unit
assert conn.execute("SELECT unit_id FROM packets WHERE id = %s", (pk,)).fetchone()[0] == u1["id"]
detail = client.get(f"/api/projects/{p['id']}").json()
assert [u["number"] for u in detail["units"]] == ["L671001", "L671002"]
assert {a["alias"] for a in detail["aliases"]} == {"L670", "L670001", "L670002"}
assert [x["number"] for x in client.get("/api/projects", params={"q": "L670"}).json()] == ["L671"]
last = audit_rows(pg_settings, "project.renumbered")[-1]
assert last["detail"] == {"from": "L670", "to": "L671", "reason": "Wrong number", "units": 2}
# old numbers stay taken; renumbering back to its own old number is allowed
r = client.post("/api/projects", json={"number": "L670"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
r = client.post("/api/projects", json={"number": "L670001"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
make_project(client, "L800")
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L800", "reason": "Wrong number"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671", "reason": "Same"})
assert r.status_code == 400 and r.json()["code"] == "same_number"
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L670", "reason": "Back again"})
assert r.status_code == 200 and r.json()["number"] == "L670"
assert look("L671")["alias"] is True and look("L670")["alias"] is False
as_user(client, "admin.ann")
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L672", "reason": "Fix"})
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L672", "reason": "Fix", "it_reason": IT})
assert r.status_code == 200 and audit_rows(pg_settings, "project.renumbered")[-1]["it_reason"] == IT
def test_renumber_unit(client: TestClient, pg_settings: Settings) -> None:
as_user(client, "clerk.clara")
p = make_project(client)
u1, _u2 = add_units(client, p["id"], count=2, start="001")
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "10", "reason": "Lot was wrong"})
assert r.status_code == 403
as_user(client, "clerk.leo")
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "2", "reason": "Lot was wrong"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "10", "reason": "Lot was wrong"})
assert r.status_code == 200, r.text
assert r.json()["lot"] == "010" and r.json()["number"] == "L670010" and r.json()["version"] == 2
found = client.get("/api/lookup/number", params={"n": "L670001"}).json()
assert found == {"kind": "unit", "id": u1["id"], "project_id": p["id"], "number": "L670010", "alias": True}
aliases = client.get(f"/api/units/{u1['id']}").json()["aliases"]
assert [(a["alias"], a["kind"]) for a in aliases] == [("L670001", "unit_renumber")]
# the old lot is taken for a new unit, but the unit itself may go back to it
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "001"})
assert r.status_code == 409 and r.json()["code"] == "number_taken"
r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "001", "reason": "Back again"})
assert r.status_code == 200 and r.json()["number"] == "L670001"
assert audit_rows(pg_settings, "unit.renumbered")[0]["detail"]["to"] == "L670010"
def test_delete_units_and_projects(client: TestClient, pg_settings: Settings) -> None:
as_user(client, "clerk.clara")
p = make_project(client)
u1, u2 = add_units(client, p["id"], count=2, start="001")
add_packet(pg_settings, u1["id"], "filed")
r = client.request("DELETE", f"/api/units/{u2['id']}", json={"reason": "Made by mistake"})
assert r.status_code == 403
as_user(client, "clerk.leo")
r = client.request("DELETE", f"/api/units/{u2['id']}", json={})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = client.request("DELETE", f"/api/units/{u2['id']}", json={"reason": "Made by mistake"})
assert r.status_code == 200, r.text
assert r.json()["removed"]["reason"] == "Made by mistake"
assert client.get(f"/api/units/{u2['id']}").status_code == 404
r = client.request("DELETE", f"/api/units/{u1['id']}", json={"reason": "Made by mistake"})
assert r.status_code == 409 and r.json()["code"] == "has_packets" and r.json()["packets"] == 1
r = client.request("DELETE", f"/api/projects/{p['id']}", json={"reason": "Made by mistake"})
assert r.status_code == 409 and r.json()["code"] == "has_packets"
# the removed unit's lot is free again
assert add_units(client, p["id"], lot="2")[0]["number"] == "L670002"
q = make_project(client, "L700")
v1, v2 = add_units(client, q["id"], count=2, start="001")
r = client.request("DELETE", f"/api/projects/{q['id']}", json={"reason": "Duplicate project"})
assert r.status_code == 200 and r.json()["removed"]["reason"] == "Duplicate project"
assert client.get(f"/api/projects/{q['id']}").status_code == 404
assert client.get(f"/api/units/{v1['id']}").status_code == 404
assert "L700" not in [x["number"] for x in client.get("/api/projects", params={"status": "all"}).json()]
with app_conn(pg_settings) as conn:
removed = conn.execute(
"SELECT count(*) FROM units WHERE project_id = %s AND removed_at IS NOT NULL", (q["id"],)
).fetchone()[0]
assert removed == 2
assert audit_rows(pg_settings, "project.removed")[-1]["detail"]["units"] == [v1["id"], v2["id"]]
assert [x["action"] for x in audit_rows(pg_settings, "unit.removed")] == ["unit.removed"]
# the number can be used again
make_project(client, "L700")
def test_closed_project(client: TestClient) -> None:
as_user(client, "clerk.clara")
p = make_project(client, due="2026-12-01")
(u,) = add_units(client, p["id"], lot="1")
r = client.patch(f"/api/projects/{p['id']}", json={"set": {"status": "closed"}, "base": {"status": "open"}})
assert r.status_code == 200
r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "2"})
assert r.status_code == 409 and r.json()["code"] == "project_closed"
one = client.get(f"/api/units/{u['id']}").json()
assert one["status"] == "open" and one["status_effective"] == "closed" and one["due_effective"] == "2026-12-01"
assert client.get("/api/units", params={"project_id": p["id"]}).json() == []
closed = client.get("/api/units", params={"project_id": p["id"], "status": "closed"}).json()
assert [x["number"] for x in closed] == ["L670001"]
assert client.get("/api/projects").json() == []
assert [x["number"] for x in client.get("/api/projects", params={"status": "closed"}).json()] == ["L670"]
def test_pm_reads_only(client: TestClient) -> None:
as_user(client, "clerk.clara")
p = make_project(client)
(u,) = add_units(client, p["id"], lot="1")
as_user(client, "pm.paula")
assert client.get("/api/projects").status_code == 200
assert client.get(f"/api/projects/{p['id']}").status_code == 200
assert client.get(f"/api/units/{u['id']}").status_code == 200
assert client.get("/api/units", params={"project_id": p["id"]}).status_code == 200
assert client.get("/api/lookup/number", params={"n": "L670001"}).status_code == 200
assert client.post("/api/projects", json={"number": "L900"}).status_code == 403
assert client.patch(f"/api/projects/{p['id']}", json={"set": {"name": "x"}}).status_code == 403
assert client.post(f"/api/projects/{p['id']}/units", json={"lot": "2"}).status_code == 403
assert client.patch(f"/api/units/{u['id']}", json={"set": {"name": "x"}}).status_code == 403
+369
View File
@@ -0,0 +1,369 @@
"""Migration 0003_domain: enums, constraints, append-only tables, views and app-role grants."""
from __future__ import annotations
import uuid
from typing import Any
import psycopg
import pytest
from pshelpers import app_conn, needs_pg
from psycopg import sql
from packetstation import db, migrate
from packetstation.settings import Settings
pytestmark = needs_pg
ENUMS = {
"packet_status": [
"draft",
"warehouse",
"ready",
"assigned",
"in_progress",
"review",
"qc",
"complete",
"filed",
],
"wh_stage": ["incoming", "pulling", "hold", "pulled"],
"co_review": ["pending", "approved", "rejected"],
"co_origin": ["visual", "drawing", "manual"],
"source_kind": ["visual", "hand"],
"project_status": ["open", "hold", "closed"],
"route_kind": ["databook", "shipping"],
"drawing_state": [
"none",
"needed",
"attached",
"rev_mismatch",
"not_released",
"not_found",
"lookup_failed",
"hand",
],
"itp_status": ["open", "signed", "reopened"],
}
# One minimal INSERT per append-only table, with ids from seed().
APPEND_INSERTS = {
"project_renumbers": (
"INSERT INTO project_renumbers (project_id, old_number, new_number, by) "
"VALUES (%(project)s, 'L998', 'L999', %(user)s)"
),
"short_receipts": (
"INSERT INTO short_receipts (short_id, packet_id, qty, by) VALUES (%(short)s, %(packet)s, 1, %(user)s)"
),
"packet_transitions": (
"INSERT INTO packet_transitions (packet_id, action, by) VALUES (%(packet)s, 'send_to_warehouse', %(user)s)"
),
"qc_history": "INSERT INTO qc_history (packet_id, result) VALUES (%(packet)s, 'accepted')",
"packet_log": (
"INSERT INTO packet_log (packet_id, action, by) VALUES (%(packet)s, 'Packet created by hand', %(user)s)"
),
"itp_rejects": "INSERT INTO itp_rejects (itp_id, line_key, ncr) VALUES (%(itp)s, 'k1', 'NCR-1')",
"co_verifications": (
"INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)"
),
}
APPEND_ONLY = list(APPEND_INSERTS)
def q(template: str, name: str) -> sql.Composed:
return sql.SQL(template).format(sql.Identifier(name))
def one(conn: Any, query: Any, params: Any = None) -> Any:
row = conn.execute(query, params).fetchone()
assert row is not None
return row[0]
def seed(conn: Any) -> dict[str, Any]:
"""A user, a signature, project L999 with unit 001, one packet with a BOM line, a short, a CO and an ITP."""
ids: dict[str, Any] = {}
ids["user"] = uid = uuid.uuid4()
conn.execute(
"INSERT INTO users (id, sam_account, display_name, welder_stamp) VALUES (%s, %s, %s, %s)",
(uid, f"weld.{uid.hex[:6]}", "Andy Welder", "A7"),
)
ids["sig"] = one(conn, "INSERT INTO signatures (user_id, action) VALUES (%s, 'test') RETURNING id", (uid,))
ids["project"] = pid = one(
conn,
"INSERT INTO projects (number, name, due, created_by) VALUES ('L999', 'Test', '2026-12-01', %s) RETURNING id",
(uid,),
)
ids["unit"] = unit = one(conn, "INSERT INTO units (project_id, lot) VALUES (%s, '001') RETURNING id", (pid,))
ids["packet"] = pk = one(
conn,
"INSERT INTO packets (unit_id, dept_code, op, created_by) VALUES (%s, '03', 'WELD-PART', %s) RETURNING id",
(unit, uid),
)
ids["bom"] = bom = one(
conn, "INSERT INTO bom_lines (packet_id, item, qty, part_id) VALUES (%s, 1, 2, '00123') RETURNING id", (pk,)
)
ids["short"] = one(
conn,
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open) VALUES (%s, %s, 2, 2) RETURNING id",
(pk, bom),
)
ids["co"] = new_co(conn, pk, 1, "manual", "approved")
ids["itp"] = one(conn, "INSERT INTO itps (unit_id, template) VALUES (%s, '{}') RETURNING id", (unit,))
return ids
def new_co(conn: Any, packet_id: int, co_no: int, origin: str, review: str, hold: bool = False) -> int:
co_id: int = one(
conn,
"INSERT INTO change_orders (packet_id, co_no, origin, review, hold) VALUES (%s, %s, %s, %s, %s) RETURNING id",
(packet_id, co_no, origin, review, hold),
)
return co_id
def test_migrations_apply_from_zero(pg_settings: Settings) -> None:
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
assert migrate.run(conn, db.MIGRATIONS_DIR) == []
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
assert names[:3] == ["0001_base.sql", "0002_identity.sql", "0003_domain.sql"]
def test_enum_values_in_order(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
for name, values in ENUMS.items():
rows = conn.execute(
"SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid "
"WHERE t.typname = %s ORDER BY e.enumsortorder",
(name,),
).fetchall()
assert [r[0] for r in rows] == values, name
# Declaration order is workflow order, so comparisons work.
assert one(conn, "SELECT 'qc'::packet_status > 'review'::packet_status") is True
def test_departments_weld(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
rows = conn.execute("SELECT code FROM departments WHERE weld ORDER BY code").fetchall()
assert [r[0] for r in rows] == ["03", "04", "05", "06", "07", "10"]
def test_append_only_tables_refuse_update_and_delete(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
for table in APPEND_ONLY:
conn.execute(APPEND_INSERTS[table], ids)
assert one(conn, q("SELECT count(*) FROM {}", table)) == 1, table
# The app role lacks the privilege.
for stmt in ("UPDATE {} SET at = at", "DELETE FROM {}", "TRUNCATE {}"):
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute(q(stmt, table))
# The owner has the privilege, so the trigger is what refuses it.
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
for table in APPEND_ONLY:
for stmt in ("UPDATE {} SET at = at", "DELETE FROM {}", "TRUNCATE {} CASCADE"):
with pytest.raises(psycopg.errors.InsufficientPrivilege, match="append-only"):
conn.execute(q(stmt, table))
def test_append_only_set_matches_triggers(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
rows = conn.execute(
"SELECT DISTINCT c.relname FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid "
"JOIN pg_proc p ON p.oid = t.tgfoid WHERE p.proname = 'ps_append_only'"
).fetchall()
assert {r[0] for r in rows} == {*APPEND_ONLY, "audit_events", "signin_attempts", "signatures"}
def test_packets_visual_key_live_only(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
ins = (
"INSERT INTO packets (unit_id, dept_code, source_kind, visual_key, removed_at) "
"VALUES (%s, '03', 'visual', 'L999001|03|D-100', %s)"
)
conn.execute(ins, (ids["unit"], "2026-10-01T00:00:00Z"))
conn.execute(ins, (ids["unit"], "2026-10-02T00:00:00Z"))
conn.execute(ins, (ids["unit"], None))
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute(ins, (ids["unit"], None))
# Packets without a key never collide.
conn.execute("INSERT INTO packets (unit_id, dept_code) VALUES (%s, '03')", (ids["unit"],))
conn.execute("INSERT INTO packets (unit_id, dept_code) VALUES (%s, '03')", (ids["unit"],))
def test_soft_delete_unique_indexes(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute("INSERT INTO projects (number) VALUES ('l999')")
conn.execute("UPDATE projects SET removed_at = now() WHERE id = %s", (ids["project"],))
conn.execute("INSERT INTO projects (number) VALUES ('l999')")
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '001')", (ids["project"],))
weld = "INSERT INTO welds (packet_id, weld_no, removed_at) VALUES (%s, 1, %s)"
conn.execute(weld, (ids["packet"], "2026-10-01T00:00:00Z"))
conn.execute(weld, (ids["packet"], None))
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute(weld, (ids["packet"], None))
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute(
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open) VALUES (%s, %s, 1, 1)",
(ids["packet"], ids["bom"]),
)
def test_unit_lot_is_text(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '7A')", (ids["project"],))
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '010')", (ids["project"],))
for bad in ("7", "7a", "0001", ""):
with pytest.raises(psycopg.errors.CheckViolation):
conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, %s)", (ids["project"], bad))
numbers = [r[0] for r in conn.execute("SELECT number FROM v_units ORDER BY id").fetchall()]
assert numbers == ["L999001", "L9997A", "L999010"]
def test_v_units(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
conn.execute("INSERT INTO units (project_id, lot, due) VALUES (%s, '002', '2026-11-15')", (ids["project"],))
rows = conn.execute(
"SELECT lot, number, project_number, project_name, project_status::text, due_effective::text "
"FROM v_units ORDER BY lot"
).fetchall()
assert rows == [
("001", "L999001", "L999", "Test", "open", "2026-12-01"),
("002", "L999002", "L999", "Test", "open", "2026-11-15"),
]
def test_v_open_cos(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
co, pk = ids["co"], ids["packet"]
verify = "INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%s, %s, %s, %s)"
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, 'WH'), (%s, '03')", (co, co))
conn.execute(verify, (co, "WH", ids["user"], ids["sig"]))
held = new_co(conn, pk, 2, "visual", "pending", hold=True)
new_co(conn, pk, 3, "drawing", "pending", hold=False)
rejected = new_co(conn, pk, 4, "manual", "rejected", hold=True)
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (rejected,))
rows = conn.execute(
"SELECT packet_id, co_id, co_no, review::text, hold, pending_depts FROM v_open_cos ORDER BY co_no"
).fetchall()
assert rows == [(pk, co, 1, "approved", False, ["03"]), (pk, held, 2, "pending", True, [])]
# Once the last department verifies, the approved CO is no longer open.
conn.execute(verify, (co, "03", ids["user"], ids["sig"]))
assert [r[0] for r in conn.execute("SELECT co_no FROM v_open_cos").fetchall()] == [2]
def test_v_open_cos_pending_depts_sorted(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
co = ids["co"]
conn.execute(
"INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, 'WH'), (%s, 'QC'), (%s, '06')", (co, co, co)
)
assert one(conn, "SELECT pending_depts FROM v_open_cos WHERE co_id = %s", (co,)) == ["06", "QC", "WH"]
def test_one_pending_visual_co_per_packet(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
new_co(conn, ids["packet"], 2, "visual", "pending")
new_co(conn, ids["packet"], 3, "visual", "approved")
with pytest.raises(psycopg.errors.UniqueViolation):
new_co(conn, ids["packet"], 4, "visual", "pending")
with pytest.raises(psycopg.errors.UniqueViolation):
new_co(conn, ids["packet"], 2, "manual", "approved")
def test_co_verifications_insert_only(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
conn.execute(APPEND_INSERTS["co_verifications"], ids)
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute(APPEND_INSERTS["co_verifications"], ids)
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("UPDATE co_verifications SET note = 'x'")
with pytest.raises(psycopg.errors.NotNullViolation):
conn.execute(
"INSERT INTO co_verifications (co_id, dept_code, by) VALUES (%s, '03', %s)", (ids["co"], ids["user"])
)
def test_packet_touch_sets_updated_at_only(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
conn.execute("UPDATE packets SET title = 'Frame' WHERE id = %s", (ids["packet"],))
row = conn.execute(
"SELECT version, updated_at > created_at, updated_at > now() - interval '1 minute' FROM packets "
"WHERE id = %s",
(ids["packet"],),
).fetchone()
assert row == (1, True, True)
# Even an explicit old value is replaced: the trigger owns updated_at.
conn.execute("UPDATE packets SET updated_at = '2000-01-01T00:00:00Z' WHERE id = %s", (ids["packet"],))
assert one(conn, "SELECT updated_at > '2001-01-01' FROM packets WHERE id = %s", (ids["packet"],)) is True
def test_app_can_write_every_table_and_read_every_view(pg_settings: Settings) -> None:
with app_conn(pg_settings) as conn:
ids = seed(conn)
ids["station"] = one(conn, "SELECT id FROM stations WHERE dept_code = '03' ORDER BY sort LIMIT 1")
ids["asset"] = one(
conn,
"INSERT INTO assets (sha256, size, mime, storage_key, pages, uploaded_by) "
"VALUES ('\\x00', 10, 'application/pdf', 'ab/abc', 1, %(user)s) RETURNING id",
ids,
)
ids["stage"] = one(
conn,
"INSERT INTO packet_stages (packet_id, dept_code, station_id, welder_id) "
"VALUES (%(packet)s, '03', %(station)s, %(user)s) RETURNING id",
ids,
)
statements = [
"INSERT INTO user_station_limits (user_id, station_id) VALUES (%(user)s, %(station)s)",
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) "
"VALUES ('L998001', %(project)s, %(unit)s, 'import')",
"INSERT INTO co_items (co_id, old_page, new_page, added) VALUES (%(co)s, 1, 2, true)",
"INSERT INTO co_required_depts (co_id, dept_code) VALUES (%(co)s, 'QC')",
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq) "
"VALUES (%(packet)s, '0670001', '7A', '0', '0', 10)",
"INSERT INTO packet_pages (packet_id, n, type, asset_id, co_id) "
"VALUES (%(packet)s, 1, 'drawing', %(asset)s, %(co)s)",
"INSERT INTO welds (packet_id, weld_no, items, welder_id, dept_code, stage_id, by) "
"VALUES (%(packet)s, 1, '{1,2}', %(user)s, '03', %(stage)s, %(user)s)",
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy) VALUES (%(packet)s, 1, 1, 1, 0.5, 0.5)",
"INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%(packet)s, 1, 0.1, 0.2, 1)",
"INSERT INTO packet_wh (packet_id, stage, by) VALUES (%(packet)s, 'incoming', %(user)s)",
"INSERT INTO qc_inspections (packet_id, stage_id, sent_by, book, spool, location, signature_id) "
"VALUES (%(packet)s, %(stage)s, %(user)s, true, true, 'Bay 2', %(sig)s)",
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by) "
"VALUES (%(packet)s, '\\x01', 'abcd1234', %(user)s)",
"INSERT INTO packet_visual_state (packet_id) VALUES (%(packet)s)",
"INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'mtr', 'MTR')",
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) "
"VALUES (%(project)s, %(unit)s, 'wps', %(asset)s)",
"INSERT INTO itp_lines (itp_id, key, result, by) VALUES (%(itp)s, 'k1', 'approve', %(user)s)",
"INSERT INTO itp_blocks (itp_id, block_key, signed_by, signature_id) "
"VALUES (%(itp)s, 'b1', %(user)s, %(sig)s)",
]
for stmt in statements:
conn.execute(stmt, ids)
conn.execute("UPDATE bom_lines SET pulled = true, row_version = row_version + 1 WHERE id = %(bom)s", ids)
conn.execute(
"UPDATE packets SET version = version + 1, next_weld_no = next_weld_no + 1 WHERE id = %(packet)s", ids
)
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %(packet)s", ids)
conn.execute("DELETE FROM co_items WHERE co_id = %(co)s", ids)
wo = conn.execute("SELECT wo_base, lot, split, sub FROM packet_work_orders").fetchone()
assert wo == ("0670001", "7A", "0", "0")
for view in ("v_units", "v_open_cos"):
conn.execute(q("SELECT * FROM {}", view)).fetchall()
for priv in ("SELECT", "INSERT", "UPDATE", "DELETE"):
allowed = one(conn, "SELECT has_table_privilege('ps_app', %s, %s)", (view, priv))
assert allowed is (priv == "SELECT"), (view, priv)
+5 -3
View File
@@ -158,12 +158,14 @@ def test_departments(pg_client: TestClient) -> None:
deps = pg_client.get("/api/departments").json()
assert [d["code"] for d in deps][:5] == ["CL", "WH", "QC", "PM", "01"]
weld = next(d for d in deps if d["code"] == "03")
assert weld == {
assert {k: v for k, v in weld.items() if k != "stations"} == {
"code": "03",
"slug": "WeldParts",
"name": "Weld Parts",
"kind": "shop",
"stations": [f"Stall {i}" for i in range(1, 7)],
"weld": True,
}
assert [s["name"] for s in weld["stations"]] == [f"Stall {i}" for i in range(1, 7)]
assert all(isinstance(s["id"], int) for s in weld["stations"])
hydro = next(d for d in deps if d["code"] == "09")
assert hydro["stations"] == ["Hydro Bay 1", "Hydro Bay 2"]
assert [s["name"] for s in hydro["stations"]] == ["Hydro Bay 1", "Hydro Bay 2"] and hydro["weld"] is False
+36
View File
@@ -0,0 +1,36 @@
"""clean_text and the input limits."""
from __future__ import annotations
from packetstation.text import RX_HEAT, RX_LOT, RX_PROJECT, RX_STAMP, clean_deep, clean_opt, clean_text, reason_ok
def test_clean_text_strips_controls_and_invisible_marks() -> None:
assert clean_text(" a\u0000b​c‮d⁦e\u007f ") == "abcde"
assert clean_text("tab\tkept\nand\r\nnewlines") == "tab\tkept\nand\r\nnewlines"
assert clean_text(None) == ""
assert clean_text(42) == "42"
def test_clean_text_normalises_and_cuts() -> None:
assert clean_text("é") == "é" # NFC
assert clean_text("x" * 50, 10) == "x" * 10
def test_clean_opt_and_reason() -> None:
assert clean_opt(" ", 10) is None
assert clean_opt(" a ", 10) == "a"
assert reason_ok("short") is None
assert reason_ok(" ticket INC-1234 ") == "ticket INC-1234"
def test_clean_deep() -> None:
out = clean_deep({"a": [" x\u0000 ", {"b": "​y"}], "n": 3})
assert out == {"a": ["x", {"b": "y"}], "n": 3}
def test_regexes() -> None:
assert RX_HEAT.match("AB12 3/4-#7") and not RX_HEAT.match("-x") and not RX_HEAT.match("a" * 41)
assert RX_STAMP.match("W-12") and not RX_STAMP.match("W 12")
assert RX_PROJECT.match("L670") and not RX_PROJECT.match("-L")
assert RX_LOT.match("7A") and RX_LOT.match("001") and not RX_LOT.match("0001")