Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s

The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board
(waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag.
Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the
Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with
receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays
blocked until every line is pulled or short and every pulled line has a heat.

Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short
reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull
counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind,
optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events
table (migration 0005) shown on the Admin Storage tab.

Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat
pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board
and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI
stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 17:13:31 -06:00
co-authored by Claude Fable 5.1
parent 591fe90076
commit 39a4eb5213
65 changed files with 6213 additions and 130 deletions
+168
View File
@@ -0,0 +1,168 @@
"""A fake clamd for tests: a threading TCP server that speaks the part of the clamd protocol Packet Station uses.
PING answers PONG. INSTREAM reads 4-byte big-endian chunk lengths and the chunks until a zero length, then answers
`stream: OK`, or `stream: Eicar-Test-Signature FOUND` when the bytes hold the EICAR test string. A command sent as
`z<CMD>\0` gets its answer ended by NUL, `n<CMD>\n` or a bare `<CMD>\n` by a newline.
`FakeClam(refuse=True)` reserves a port and closes it, so a connection is refused. `mode="garbage"` answers
INSTREAM with a line that is not a clamd answer.
"""
from __future__ import annotations
import socket
import socketserver
import struct
import threading
from typing import Any
# Built from parts so the source file itself does not hold the test string (a desktop scanner would flag it).
EICAR = rb"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + b"EICAR-STANDARD-" + b"ANTIVIRUS-TEST-FILE!$H+H*"
SIGNATURE = "Eicar-Test-Signature"
def _read_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("client closed the stream early")
buf += chunk
return buf
def _read_command(sock: socket.socket) -> tuple[str, bytes]:
"""The command name and the terminator its answer uses."""
first = _read_exact(sock, 1)
if first == b"z":
end = b"\0"
elif first == b"n":
end = b"\n"
else:
end = b"\n"
buf = b"" if first in (b"z", b"n") else first
while True:
c = _read_exact(sock, 1)
if c in (b"\0", b"\n"):
break
buf += c
if len(buf) > 64:
break
return buf.decode("ascii", "replace"), end
class _Handler(socketserver.BaseRequestHandler):
server: _Server
def handle(self) -> None:
sock: socket.socket = self.request
sock.settimeout(10)
try:
cmd, end = _read_command(sock)
except (ConnectionError, OSError):
return
fake = self.server.fake
with fake.lock:
fake.commands.append(cmd)
if cmd == "PING":
sock.sendall(b"PONG" + end)
return
if cmd == "INSTREAM":
data = b""
try:
while True:
(n,) = struct.unpack(">I", _read_exact(sock, 4))
if n == 0:
break
data += _read_exact(sock, n)
except (ConnectionError, OSError):
return
with fake.lock:
fake.scanned.append(data)
if fake.mode == "garbage":
sock.sendall(b"what is this" + end)
elif EICAR in data:
sock.sendall(f"stream: {SIGNATURE} FOUND".encode() + end)
else:
sock.sendall(b"stream: OK" + end)
return
sock.sendall(b"UNKNOWN COMMAND" + end)
class _Server(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
fake: FakeClam
class FakeClam:
"""Start with `with FakeClam() as clam:`; point settings at `clam.host` and `clam.port`."""
def __init__(self, *, refuse: bool = False, mode: str = "normal") -> None:
self.host = "127.0.0.1"
self.refuse = refuse
self.mode = mode
self.lock = threading.Lock()
self.commands: list[str] = []
self.scanned: list[bytes] = []
self._server: _Server | None = None
self._thread: threading.Thread | None = None
self.port = 0
@property
def calls(self) -> int:
with self.lock:
return len(self.commands)
def start(self) -> FakeClam:
if self.refuse:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((self.host, 0))
self.port = int(s.getsockname()[1])
s.close() # nothing listens there now: a connect is refused
return self
self._server = _Server((self.host, 0), _Handler)
self._server.fake = self
self.port = int(self._server.server_address[1])
self._thread = threading.Thread(target=self._server.serve_forever, name="fakeclam", daemon=True)
self._thread.start()
return self
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._thread is not None:
self._thread.join(5)
self._thread = None
def __enter__(self) -> FakeClam:
return self.start()
def __exit__(self, *exc: Any) -> None:
self.stop()
def ping(host: str, port: int) -> bytes:
"""A raw zPING, for checking the fake itself."""
with socket.create_connection((host, port), timeout=5) as s:
s.sendall(b"zPING\0")
return s.recv(64)
def instream(host: str, port: int, data: bytes, chunk: int = 7) -> bytes:
"""A raw zINSTREAM in small chunks, for checking the fake itself."""
with socket.create_connection((host, port), timeout=5) as s:
s.sendall(b"zINSTREAM\0")
for i in range(0, len(data), chunk):
part = data[i : i + chunk]
s.sendall(struct.pack(">I", len(part)) + part)
s.sendall(struct.pack(">I", 0))
out = b""
while not out.endswith(b"\0"):
got = s.recv(256)
if not got:
break
out += got
return out
+21 -6
View File
@@ -77,7 +77,9 @@ 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"])
f"/api/packets/{pkt['id']}/bom/{b['item']}",
json={"pulled": True, "heat": f"HT-{b['item']}"},
headers=_im(b["row_version"]),
)
assert r.status_code == 200, r.text
pkt = r.json()
@@ -202,7 +204,14 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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}
body = {
"qty": 3,
"reason": "Not in stock",
"ref": "PO-991",
"eta": "2026-11-02",
"heat": "H0",
"expect_open": False,
}
r = c.post(url, json=body)
assert r.status_code == 200, r.text
out = r.json()
@@ -222,12 +231,16 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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})
r = c.post(f"/api/packets/{pid}/bom/2/short", json={"qty": 1, "reason": "Damaged / rejected", "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
# The release gate needs a heat on every pulled line.
rv2 = _line(r.json(), 2)["row_version"]
r = c.patch(f"/api/packets/{pid}/bom/2", json={"heat": "H2"}, headers=_im(rv2))
assert r.status_code == 200, r.text
pkt = ok_step(c, r.json(), "wh_release")
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
@@ -237,7 +250,7 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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 line["heat"] == "H0 / 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:
@@ -247,6 +260,8 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
pkt = ok_step(c, r.json(), "assign", station_id=stall, welder_id=andy)
as_user(c, "weld.andy")
pkt = ok_step(c, pkt, "start")
# Receive by custody: someone who neither is in the warehouse nor holds the packet is refused.
as_user(c, "qc.quinn")
no = c.post(rec, json={"qty": 1, "heat": "H200"})
assert no.status_code == 403 and no.json()["code"] == "forbidden"
as_user(c, "wh.wade")
@@ -257,7 +272,7 @@ def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit
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
assert line["heat"] == "H0 / 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"
@@ -434,7 +449,7 @@ def test_wh_stage_board(pg_client2: TestClient, unit: tuple[int, int]) -> None:
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"
assert r.status_code == 400 and r.json()["code"] == "invalid_reason"
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"}
+1
View File
@@ -365,6 +365,7 @@ PRECONDITION_CASES: list[tuple[Action, str, str, dict[str, Any], dict[str, Any],
(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.WH_RELEASE, "warehouse", "warehouse", {"bom_missing_heat": 1}, {}, "heats_recorded"),
(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"),
+176
View File
@@ -0,0 +1,176 @@
"""The phase 4 release gate in the policy: heats_recorded over PacketState values, WH_RELEASE with both rules,
and that can() and check() agree. No database."""
from __future__ import annotations
import dataclasses
import itertools
from typing import Any
import pytest
from packetstation.policy import RULES, Action, PacketState, Principal, can, check, principal_from_roles
from packetstation.policy.preconditions import INPUT_ONLY, MESSAGES, PRECONDITIONS, heats_recorded
A = Action
USER = "00000000-0000-0000-0000-0000000000c1"
IT = "Ticket 4411: release by hand"
MSG_PULL = "Pull every material line or mark it short first."
MSG_HEAT = "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability."
def P(*roles: str) -> Principal:
return principal_from_roles(USER, list(roles))
WH = P("warehouse")
WH_SUP = P("warehouse_supervisor")
ADMIN = P("admin")
LEAD = P("shop:03:lead")
SUP = P("shop:03:supervisor")
QC = P("qc")
CL = P("clerical")
def state(status: str = "warehouse", **kw: Any) -> PacketState:
base = PacketState(
id=1,
status=status,
dept="03",
station_id=None,
welder_id=None,
wh_skipped=False,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
drawing_attached=True,
heat_map=True,
bom_total=3,
bom_ready=3,
bom_missing_heat=0,
)
return dataclasses.replace(base, **kw)
def test_packet_state_defaults_to_no_missing_heat() -> None:
s = PacketState(
id=1,
status="warehouse",
dept="03",
station_id=None,
welder_id=None,
wh_skipped=False,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
)
assert s.bom_missing_heat == 0
def test_heats_recorded_is_a_state_precondition() -> None:
assert MESSAGES["heats_recorded"] == MSG_HEAT
assert PRECONDITIONS["heats_recorded"] is heats_recorded
assert "heats_recorded" not in INPUT_ONLY
assert RULES[A.WH_RELEASE].preconditions == ("bom_all_pulled_or_short", "heats_recorded")
@pytest.mark.parametrize(("missing", "fails"), [(0, False), (1, True), (2, True), (40, True)])
def test_heats_recorded_over_state(missing: int, fails: bool) -> None:
got = heats_recorded(state(bom_missing_heat=missing), {})
assert got == (MSG_HEAT if fails else None)
def test_heats_recorded_ignores_input() -> None:
assert heats_recorded(state(bom_missing_heat=1), {"heat": "H1", "na": True}) == MSG_HEAT
assert heats_recorded(state(bom_missing_heat=0), {"anything": 1}) is None
@pytest.mark.parametrize("who", [WH, WH_SUP])
def test_release_blocked_by_a_missing_heat(who: Principal) -> None:
d = check(who, state(bom_missing_heat=1), A.WH_RELEASE)
assert not d.ok and d.code == "precondition" and d.why == MSG_HEAT
assert check(who, state(bom_missing_heat=0), A.WH_RELEASE).ok
def test_release_rules_in_order() -> None:
d = check(WH, state(bom_ready=2, bom_missing_heat=1), A.WH_RELEASE)
assert not d.ok and d.why == MSG_PULL
allowed, blocked = can(WH, state(bom_ready=2, bom_missing_heat=1))
assert A.WH_RELEASE not in allowed
assert blocked[A.WH_RELEASE] == [MSG_PULL, MSG_HEAT]
allowed, blocked = can(WH, state(bom_ready=3, bom_missing_heat=1))
assert A.WH_RELEASE not in allowed and blocked[A.WH_RELEASE] == [MSG_HEAT]
allowed, blocked = can(WH, state())
assert A.WH_RELEASE in allowed and A.WH_RELEASE not in blocked
def test_admin_cannot_bypass_the_heat_rule() -> None:
d = check(ADMIN, state(bom_missing_heat=1), A.WH_RELEASE, it_reason=IT)
assert not d.ok and d.code == "precondition" and d.why == MSG_HEAT
d = check(ADMIN, state(bom_missing_heat=0), A.WH_RELEASE, it_reason=IT)
assert d.ok and d.admin_bypass
d = check(ADMIN, state(bom_missing_heat=0), A.WH_RELEASE)
assert not d.ok and d.code == "it_reason_required"
@pytest.mark.parametrize("who", [CL, QC, LEAD, SUP])
def test_others_are_refused_before_the_gate(who: Principal) -> None:
d = check(who, state(bom_missing_heat=1), A.WH_RELEASE)
assert not d.ok and d.code == "forbidden"
allowed, blocked = can(who, state(bom_missing_heat=1))
assert A.WH_RELEASE not in allowed and A.WH_RELEASE not in blocked
def test_the_gate_only_touches_wh_release() -> None:
"""A missing heat blocks nothing else the warehouse can do on a warehouse packet."""
ok_allowed, ok_blocked = can(WH, state())
bad_allowed, bad_blocked = can(WH, state(bom_missing_heat=3))
assert set(ok_allowed) - set(bad_allowed) == {A.WH_RELEASE}
assert set(bad_blocked) - set(ok_blocked) == {A.WH_RELEASE}
PRINCIPALS = {"wh": WH, "wh_sup": WH_SUP, "admin": ADMIN, "lead": LEAD, "sup": SUP, "qc": QC, "cl": CL}
@pytest.mark.parametrize(
("name", "status", "total", "ready", "missing", "co_hold"),
[
(n, st, t, r, m, h)
for n, st, (t, r), m, h in itertools.product(
PRINCIPALS, ("warehouse", "ready", "draft"), ((3, 3), (3, 2), (0, 0)), (0, 1), (False, True)
)
],
)
def test_can_and_check_agree(name: str, status: str, total: int, ready: int, missing: int, co_hold: bool) -> None:
who = PRINCIPALS[name]
s = state(status, bom_total=total, bom_ready=ready, bom_missing_heat=missing, co_hold=co_hold)
allowed, blocked = can(who, s)
d = check(who, s, A.WH_RELEASE, it_reason=IT if who.admin else None)
assert (A.WH_RELEASE in allowed) == d.ok
who_passes = who in (WH, WH_SUP, ADMIN) and status == "warehouse"
if not d.ok and who_passes:
assert d.code in ("precondition", "co_hold")
assert A.WH_RELEASE in blocked and d.why == blocked[A.WH_RELEASE][0]
if d.ok:
assert A.WH_RELEASE not in blocked
assert total <= ready and missing == 0 and not co_hold
def test_receive_by_custody_in_the_policy() -> None:
"""The warehouse receives anywhere but filed (WH_RECEIVE_SHORT); the holder through EDIT_BOM."""
for status in ("warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete"):
assert check(WH, state(status, open_shorts=1), A.WH_RECEIVE_SHORT).ok, status
assert check(WH, state("filed", open_shorts=1), A.WH_RECEIVE_SHORT).code == "filed"
assert check(WH, state("ready", open_shorts=0), A.WH_RECEIVE_SHORT).why == MESSAGES["short_open"]
ready = state("ready", open_shorts=1)
assert check(LEAD, ready, A.EDIT_BOM).ok and check(SUP, ready, A.EDIT_BOM).ok
assert check(WH, ready, A.EDIT_BOM).code == "forbidden"
for who in (QC, CL):
assert check(who, ready, A.EDIT_BOM).code == "forbidden"
assert check(who, ready, A.WH_RECEIVE_SHORT).code == "forbidden"
+32
View File
@@ -465,3 +465,35 @@ def test_two_operations_of_one_work_order_on_one_packet(pg_settings: Settings) -
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub) VALUES (%s, 'H1', '1', '0', '0')",
(ids["packet"],),
)
def test_notification_events(pg_settings: Settings) -> None:
"""0005: the notification placeholder. One open row per kind and subject; the app may write it."""
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
assert "0005_warehouse.sql" in names
with app_conn(pg_settings) as conn:
for priv in ("SELECT", "INSERT", "UPDATE", "DELETE"):
assert one(conn, "SELECT has_table_privilege('ps_app', 'notification_events', %s)", (priv,)) is True
ids = seed(conn)
first = one(
conn,
"INSERT INTO notification_events (kind, subject, detail) VALUES ('t_kind', 'assets', '{\"pct\": 90}')"
" RETURNING id",
)
assert one(conn, "SELECT severity FROM notification_events WHERE id = %s", (first,)) == "warning"
with pytest.raises(psycopg.errors.UniqueViolation), conn.transaction():
conn.execute("INSERT INTO notification_events (kind, subject) VALUES ('t_kind', 'assets')")
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute("INSERT INTO notification_events (kind, severity) VALUES ('t_other', 'loud')")
# A null subject is one subject: a second open row for it is refused too.
conn.execute("INSERT INTO notification_events (kind) VALUES ('t_null')")
with pytest.raises(psycopg.errors.UniqueViolation), conn.transaction():
conn.execute("INSERT INTO notification_events (kind) VALUES ('t_null')")
conn.execute(
"UPDATE notification_events SET resolved_at = now(), acknowledged_at = now(), acknowledged_by = %s"
" WHERE id = %s",
(ids["user"], first),
)
conn.execute("INSERT INTO notification_events (kind, subject) VALUES ('t_kind', 'assets')")
assert one(conn, "SELECT count(*) FROM notification_events WHERE kind = 't_kind'") == 2
+465
View File
@@ -0,0 +1,465 @@
"""Phase 4 upload hardening: content sniffing (PDF, PNG, JPEG, XLSX, CSV), the purpose and type matrix, the limit
per kind, safe names, the blob headers, the optional ClamAV scan (with tests/server/fakeclam.py), the storage quota
alert, and the admin storage and alerts endpoints."""
from __future__ import annotations
import io
import struct
import zipfile
import zlib
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fakeclam import EICAR, SIGNATURE, FakeClam
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import as_user, phase2_client, seed_users, tiny_pdf
from pshelpers import PG_URL, app_conn, make_settings
from packetstation.api.errors import ApiError
from packetstation.domain import assets, scan
from packetstation.settings import Settings
needs_pg = pytest.mark.skipif(not PG_URL, reason="TEST_PG_SUPERUSER_URL not set (no PostgreSQL)")
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
WARN = {"total": 1000, "used": 900, "free": 100, "pct": 90.0, "warn": True}
OK = {"total": 1000, "used": 100, "free": 900, "pct": 10.0, "warn": False}
# ---------------------------------------------------------------- sample files
def png_bytes(extra: bytes = b"") -> bytes:
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
body = chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(b"\x00\x10\x20\x30"))
if extra:
body += chunk(b"tEXt", b"Comment\x00" + extra)
return b"\x89PNG\r\n\x1a\n" + body + chunk(b"IEND", b"")
def jpeg_bytes() -> bytes:
return b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + b"\x00" * 32 + b"\xff\xd9"
def xlsx_bytes() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", '<?xml version="1.0"?><Types/>')
z.writestr("xl/workbook.xml", '<?xml version="1.0"?><workbook/>')
z.writestr("xl/worksheets/sheet1.xml", '<?xml version="1.0"?><worksheet/>')
return buf.getvalue()
def zip_bytes() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("readme.txt", "just a zip")
return buf.getvalue()
CSV = b"item,qty,part\r\n1,4,PIPE-4\r\n2,2,FLG-4\r\n"
CSV_BOM = b"\xef\xbb\xbf" + CSV
# ---------------------------------------------------------------- sniffing (no database)
def test_sniff_by_content() -> None:
assert assets.sniff(tiny_pdf()) == "application/pdf"
assert assets.sniff(png_bytes()) == "image/png"
assert assets.sniff(jpeg_bytes()) == "image/jpeg"
assert assets.sniff(xlsx_bytes()) == XLSX_MIME
assert assets.sniff(zip_bytes()) is None # a plain zip is not a workbook
assert assets.sniff(b"just text") is None
def test_sniff_xlsx_from_a_path(tmp_path: Path) -> None:
good = tmp_path / "a.bin"
good.write_bytes(xlsx_bytes())
plain = tmp_path / "b.bin"
plain.write_bytes(zip_bytes())
assert assets.sniff(good.read_bytes()[:16], good) == XLSX_MIME
assert assets.sniff(plain.read_bytes()[:16], plain) is None
def test_sniff_csv_only_for_a_bom() -> None:
assert assets.sniff(CSV, purpose="bom") == "text/csv"
assert assets.sniff(CSV_BOM, purpose="bom") == "text/csv"
assert assets.sniff(b"a\tb\n1\t2\n", purpose="bom") == "text/csv"
assert assets.sniff(CSV) is None and assets.sniff(CSV, purpose="document") is None
assert assets.sniff(b"a,b\n\x00\x01,2\n", purpose="bom") is None
assert assets.sniff(b"a,b\n\x07,2\n", purpose="bom") is None
assert assets.sniff(b"a,b\n\xff\xfe,2\n", purpose="bom") is None # not UTF-8
def test_safe_name_and_download_name() -> None:
name = assets.safe_name("..\\..\\x<y>.pdf")
assert name is not None and name.endswith(".pdf")
assert not any(ch in name for ch in '\\/<>:"|?*') and ".." not in name
row = {"id": "0f0e", "mime": XLSX_MIME, "original_name": None}
assert assets.download_name(row).endswith(".xlsx")
assert assets.download_name({"id": "0f0e", "mime": "text/csv", "original_name": None}).endswith(".csv")
assert assets.disposition({"id": "1", "mime": XLSX_MIME, "original_name": "b.xlsx"}).startswith("attachment;")
assert assets.disposition({"id": "1", "mime": "text/csv", "original_name": "b.csv"}).startswith("attachment;")
assert assets.disposition({"id": "1", "mime": "application/pdf", "original_name": "d.pdf"}).startswith("inline;")
# ---------------------------------------------------------------- the scanner (no database)
def test_fake_clamd_and_scanner(tmp_path: Path) -> None:
clean = tmp_path / "clean.png"
clean.write_bytes(png_bytes())
bad = tmp_path / "bad.png"
bad.write_bytes(png_bytes(EICAR))
with FakeClam() as clam:
s = scan.ClamdScanner(clam.host, clam.port, timeout=5)
assert s.ping() is True
assert s.scan_path(clean) == scan.ScanResult(clean=True, signature=None)
assert s.scan_path(bad) == scan.ScanResult(clean=False, signature=SIGNATURE)
assert clam.commands == ["PING", "INSTREAM", "INSTREAM"]
assert clam.scanned[0] == clean.read_bytes()
with FakeClam(refuse=True) as down:
s = scan.ClamdScanner(down.host, down.port, timeout=2)
assert s.ping() is False
with pytest.raises(scan.ScanError):
s.scan_path(clean)
with FakeClam(mode="garbage") as odd:
with pytest.raises(scan.ScanError):
scan.ClamdScanner(odd.host, odd.port, timeout=2).scan_path(clean)
def test_scan_check(tmp_path: Path) -> None:
clean = tmp_path / "clean.png"
clean.write_bytes(png_bytes())
bad = tmp_path / "bad.png"
bad.write_bytes(png_bytes(EICAR))
off = make_settings(tmp_path)
assert off.clamav_host == "" and off.clamav_port == 3310 and off.clamav_timeout_seconds == 30
assert scan.scanner_for(off) is None and scan.check(off, bad) is None
with FakeClam() as clam:
on = off.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
assert isinstance(scan.scanner_for(on), scan.ClamdScanner)
assert scan.check(on, clean) == scan.ScanResult(clean=True, signature=None)
with pytest.raises(ApiError) as e:
scan.check(on, bad)
assert e.value.status == 422 and e.value.code == "infected"
assert e.value.detail == "That file failed the virus scan and was not stored."
assert e.value.extra == {"signature": SIGNATURE}
with FakeClam(refuse=True) as down:
dead = off.model_copy(update={"clamav_host": down.host, "clamav_port": down.port, "clamav_timeout_seconds": 2})
with pytest.raises(ApiError) as e:
scan.check(dead, clean)
assert e.value.status == 503 and e.value.code == "scan_unavailable"
assert e.value.detail == "The virus scanner is not reachable. Try again in a minute."
# ---------------------------------------------------------------- the API (PostgreSQL)
@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)
as_user(client, "clerk.clara")
yield client
def up(
c: TestClient, data: bytes, name: str, purpose: str | None = None, mime: str = "application/octet-stream"
) -> Any:
form = {} if purpose is None else {"purpose": purpose}
return c.post("/api/assets", files={"file": (name, data, mime)}, data=form)
def _one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def stored_files(settings: Settings) -> list[Path]:
root = Path(settings.assets_dir)
return sorted(p for p in root.rglob("*") if p.is_file()) if root.exists() else []
SAMPLES = {
"pdf": (tiny_pdf, "d.pdf"),
"png": (png_bytes, "p.png"),
"jpeg": (jpeg_bytes, "j.jpg"),
"xlsx": (xlsx_bytes, "b.xlsx"),
"csv": (lambda: CSV, "b.csv"),
"zip": (zip_bytes, "z.zip"),
}
# purpose -> what is accepted (everything else in SAMPLES is 415)
ACCEPTS = {
"drawing": {"pdf"},
"mtr": {"pdf", "png", "jpeg"},
"document": {"pdf", "png", "jpeg"},
None: {"pdf", "png", "jpeg"},
"bom": {"xlsx", "csv"},
}
WRONG_TYPE = {
"drawing": "A drawing is a PDF file.",
"mtr": "An MTR is a PDF, PNG or JPG file.",
"bom": "A BOM is an Excel (.xlsx) or CSV file.",
}
@needs_pg
@pytest.mark.parametrize("purpose", ["drawing", "mtr", "document", None, "bom"])
def test_purpose_and_type_matrix(pg_client2: TestClient, pg_settings: Settings, purpose: str | None) -> None:
for kind, (make, name) in SAMPLES.items():
r = up(pg_client2, make(), name, purpose)
if kind in ACCEPTS[purpose]:
assert r.status_code == 201, (purpose, kind, r.text)
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
# the same bytes may have been stored before under another purpose (dedupe returns that row)
assert ref is None or "purpose" in ref, ref
assert r.json()["mime"] in ("application/pdf", "image/png", "image/jpeg", XLSX_MIME, "text/csv")
else:
assert r.status_code == 415 and r.json()["code"] == "unsupported_type", (purpose, kind, r.text)
if purpose in WRONG_TYPE:
assert r.json()["detail"] == WRONG_TYPE[purpose]
assert isinstance(r.json()["allowed"], list) and r.json()["allowed"]
@needs_pg
def test_purpose_recorded_and_unknown_refused(pg_client2: TestClient, pg_settings: Settings) -> None:
r = up(pg_client2, png_bytes(b"mtr one"), "m.png", "mtr")
assert r.status_code == 201
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
assert ref == {"purpose": "mtr"}
r = up(pg_client2, png_bytes(b"doc one"), "d.png")
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
assert ref == {"purpose": "document"}
r = up(pg_client2, xlsx_bytes(), "b.xlsx", "bom")
assert r.status_code == 201 and r.json()["mime"] == XLSX_MIME
r = up(pg_client2, CSV_BOM, "b.csv", "bom")
assert r.status_code == 201 and r.json()["mime"] == "text/csv"
r = up(pg_client2, png_bytes(b"doc alias"), "a.png", "doc")
assert r.status_code == 201, r.text
ref = _one(pg_settings, "SELECT source_ref FROM assets WHERE id = %s", (r.json()["id"],))[0]
assert ref == {"purpose": "document"}
bad = up(pg_client2, tiny_pdf(), "d.pdf", "selfie")
assert bad.status_code == 422 and bad.json()["code"] == "invalid_request", bad.text
nul = up(pg_client2, b"a,b\n\x00,1\n", "n.csv", "bom")
assert nul.status_code == 415 and nul.json()["code"] == "unsupported_type"
@needs_pg
def test_limits_per_kind(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> None:
small = pg_settings.model_copy(update={"upload_max_bytes": 4096, "upload_max_bytes_bom": 1024})
with phase2_client(small, fake_dir) as c:
seed_users(c, ("clerk.clara",))
as_user(c, "clerk.clara")
big_png = png_bytes(b"x" * 2000)
assert 1024 < len(big_png) < 4096
assert up(c, big_png, "p.png", "mtr").status_code == 201
csv = b"item,qty\n" + b"1,4\n" * 400
assert 1024 < len(csv) < 4096
r = up(c, csv, "b.csv", "bom")
assert r.status_code == 413 and r.json()["code"] == "too_large", r.text
assert r.json()["limit"] == 1024 and "limit" in r.json()["detail"]
assert up(c, b"item,qty\n1,4\n", "s.csv", "bom").status_code == 201
huge = png_bytes(b"y" * 6000)
r = up(c, huge, "h.png", "document")
assert r.status_code == 413 and r.json()["code"] == "too_large" and r.json()["limit"] == 4096
r = up(c, b"%PDF-1.4\n" + b"0" * 200_000, "h.pdf", "drawing") # over the pre-check too
assert r.status_code == 413 and r.json()["code"] == "too_large"
assert not [p for p in stored_files(small) if p.name.startswith(".up-")]
@needs_pg
def test_names_and_blob_headers(pg_client2: TestClient) -> None:
r = up(pg_client2, tiny_pdf(2), "..\\..\\x<y>.pdf", "drawing", "application/pdf")
assert r.status_code == 201, r.text
a = r.json()
name = a["original_name"]
assert name and name.endswith(".pdf") and not any(ch in name for ch in "\\/<>") and not name.startswith(".")
b = pg_client2.get(a["url"])
assert b.status_code == 200 and b.headers["content-disposition"].startswith("inline;")
assert b.headers["x-content-type-options"] == "nosniff"
x = up(pg_client2, xlsx_bytes(), "bom list.xlsx", "bom").json()
b = pg_client2.get(x["url"])
assert b.status_code == 200 and b.headers["content-disposition"] == 'attachment; filename="bom list.xlsx"'
assert b.headers["x-content-type-options"] == "nosniff" and b.headers["content-type"].startswith(XLSX_MIME)
cv = up(pg_client2, CSV, "...", "bom").json() # no usable name: the id and the extension
b = pg_client2.get(cv["url"])
assert b.headers["content-disposition"].startswith("attachment;")
assert b.headers["content-disposition"].endswith('.csv"') and b.headers["x-content-type-options"] == "nosniff"
# ---------------------------------------------------------------- ClamAV through the API
@pytest.fixture
def clam() -> Iterator[FakeClam]:
with FakeClam() as fake:
yield fake
@needs_pg
def test_scan_on_upload(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None, clam: FakeClam) -> None:
on = pg_settings.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
with phase2_client(on, fake_dir) as c:
seed_users(c, ("clerk.clara",))
as_user(c, "clerk.clara")
clean = png_bytes(b"clean one")
r = up(c, clean, "ok.png", "mtr")
assert r.status_code == 201, r.text
assert "INSTREAM" in clam.commands and clean in clam.scanned
before = stored_files(on)
bad = png_bytes(EICAR)
r = up(c, bad, "bad.png", "mtr")
assert r.status_code == 422 and r.json()["code"] == "infected", r.text
assert r.json()["detail"] == "That file failed the virus scan and was not stored."
assert r.json()["signature"] == SIGNATURE
assert stored_files(on) == before # nothing placed, no temp file left
assert _one(on, "SELECT count(*) FROM assets WHERE size = %s", (len(bad),))[0] == 0
au = _one(on, "SELECT detail, user_id FROM audit_events WHERE action = 'asset.rejected' ORDER BY id DESC")
assert au is not None
assert au[0]["reason"] == "infected" and au[0]["signature"] == SIGNATURE
assert au[0]["size"] == len(bad) and au[0]["mime"] == "image/png" and au[1] is not None
# The same infected bytes are never deduped against a stored row: a second try is refused again
assert up(c, bad, "bad2.png", "document").status_code == 422
@needs_pg
def test_scanner_down_fails_closed(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> None:
with FakeClam(refuse=True) as down:
dead = pg_settings.model_copy(
update={"clamav_host": down.host, "clamav_port": down.port, "clamav_timeout_seconds": 2}
)
with phase2_client(dead, fake_dir) as c:
seed_users(c, ("clerk.clara",))
as_user(c, "clerk.clara")
r = up(c, png_bytes(b"down"), "p.png", "mtr")
assert r.status_code == 503 and r.json()["code"] == "scan_unavailable", r.text
assert r.json()["detail"] == "The virus scanner is not reachable. Try again in a minute."
assert stored_files(dead) == []
@needs_pg
def test_no_scanner_means_no_call(pg_client2: TestClient, clam: FakeClam) -> None:
r = up(pg_client2, png_bytes(EICAR), "p.png", "mtr")
assert r.status_code == 201, r.text # ClamAV is off: nothing scans
assert clam.calls == 0
@needs_pg
def test_store_file_is_scanned(pg_settings: Settings, clam: FakeClam) -> None:
on = pg_settings.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
tmp = Path(on.assets_dir) / "tmp"
tmp.mkdir(parents=True, exist_ok=True)
bad = tmp / "dl-bad.png"
bad.write_bytes(png_bytes(EICAR))
with app_conn(on) as conn:
with pytest.raises(ApiError) as e:
assets.store_file(conn, on, bad, mime="image/png", original_name="m.png", source="mfiles")
assert e.value.code == "infected"
assert not bad.exists()
assert conn.execute("SELECT count(*) FROM assets").fetchone()[0] == 0
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'asset.rejected'").fetchone()[0] == 1
good = tmp / "dl-good.pdf"
good.write_bytes(tiny_pdf())
row = assets.store_file(conn, on, good, original_name="d.pdf", source="mfiles")
assert row["reused"] is False and row["mime"] == "application/pdf"
assert clam.commands.count("INSTREAM") == 2
assert [p for p in stored_files(on) if p.parent.name != "tmp"]
# ---------------------------------------------------------------- the quota alert
@needs_pg
def test_quota_alert_opens_and_resolves(
pg_client2: TestClient, pg_settings: Settings, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(assets, "volume_usage", lambda *a, **k: dict(WARN))
assert up(pg_client2, png_bytes(b"q1"), "q1.png", "mtr").status_code == 201
row = _one(
pg_settings,
"SELECT id, kind, severity, subject, detail, resolved_at FROM notification_events WHERE kind = 'assets_quota'",
)
assert row is not None and row[1:4] == ("assets_quota", "warning", "assets") and row[5] is None
assert row[4]["pct"] == 90.0 and row[4]["warn"] is True
assert _one(pg_settings, "SELECT count(*) FROM audit_events WHERE action = 'storage.quota_warning'")[0] == 1
# A second upload while it is open adds nothing
assert up(pg_client2, png_bytes(b"q2"), "q2.png", "mtr").status_code == 201
assert _one(pg_settings, "SELECT count(*) FROM notification_events WHERE resolved_at IS NULL")[0] == 1
assert _one(pg_settings, "SELECT count(*) FROM audit_events WHERE action = 'storage.quota_warning'")[0] == 1
as_user(pg_client2, "admin.ann")
st = pg_client2.get("/api/admin/storage")
assert st.status_code == 200, st.text
body = st.json()
assert body["usage"] == WARN and body["quota_warn_pct"] == 85
assert body["alert"]["id"] == row[0] and body["alert"]["acknowledged_at"] is None
assert body["clamav"]["configured"] is False and body["clamav"]["reachable"] is None
assert body["clamav"]["host"] is None
al = pg_client2.get("/api/admin/alerts")
assert al.status_code == 200 and [a["id"] for a in al.json()["alerts"]] == [row[0]]
ack = pg_client2.post(f"/api/admin/alerts/{row[0]}/ack")
assert ack.status_code == 200, ack.text
assert ack.json()["acknowledged_at"] and ack.json()["acknowledged_by"]["display_name"] == "Ann Admin"
assert _one(pg_settings, "SELECT count(*) FROM audit_events WHERE action = 'alert.acknowledged'")[0] == 1
assert pg_client2.post("/api/admin/alerts/999999/ack").status_code == 404
monkeypatch.setattr(assets, "volume_usage", lambda *a, **k: dict(OK))
with app_conn(pg_settings) as conn:
assets.check_quota(conn, pg_settings)
resolved = _one(pg_settings, "SELECT resolved_at FROM notification_events WHERE id = %s", (row[0],))[0]
assert resolved is not None
assert pg_client2.get("/api/admin/storage").json()["alert"] is None
assert pg_client2.get("/api/admin/alerts").json()["alerts"] == []
@needs_pg
def test_quota_check_failure_never_fails_the_upload(
pg_client2: TestClient, pg_settings: Settings, monkeypatch: pytest.MonkeyPatch
) -> None:
def boom(*a: Any, **k: Any) -> dict[str, Any]:
raise OSError("disk usage unavailable")
monkeypatch.setattr(assets, "volume_usage", boom)
r = up(pg_client2, png_bytes(b"still stored"), "s.png", "mtr")
assert r.status_code == 201, r.text
@needs_pg
def test_admin_storage_and_alerts_are_admin_only(pg_client2: TestClient) -> None:
for path in ("/api/admin/storage", "/api/admin/alerts"):
r = pg_client2.get(path)
assert r.status_code == 403 and r.json()["code"] == "forbidden", path
assert pg_client2.post("/api/admin/alerts/1/ack").status_code == 403
as_user(pg_client2, "admin.ann")
assert pg_client2.get("/api/admin/storage").status_code == 200
assert pg_client2.get("/api/admin/alerts").json() == {"alerts": []}
@needs_pg
def test_admin_storage_shows_clamav(
pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None, clam: FakeClam
) -> None:
on = pg_settings.model_copy(update={"clamav_host": clam.host, "clamav_port": clam.port})
with phase2_client(on, fake_dir) as c:
seed_users(c, ("admin.ann",))
as_user(c, "admin.ann")
cl = c.get("/api/admin/storage").json()["clamav"]
assert cl["configured"] is True and cl["host"] == clam.host and cl["reachable"] is True
assert "PING" in clam.commands
with FakeClam(refuse=True) as down:
off = pg_settings.model_copy(
update={"clamav_host": down.host, "clamav_port": down.port, "clamav_timeout_seconds": 2}
)
with phase2_client(off, fake_dir) as c:
as_user(c, "admin.ann")
cl = c.get("/api/admin/storage").json()["clamav"]
assert cl["configured"] is True and cl["reachable"] is False
+780
View File
@@ -0,0 +1,780 @@
"""Phase 4 warehouse API: every warehouse action as every role, hold reasons, the Pulled auto-start, the heat
pattern, the release gate (pulled-or-short and heats), receiving a short by custody, the board endpoint, the card
fields, and the exact packet_log texts and audit rows of each write."""
from __future__ import annotations
import struct
import zlib
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import as_user, ok_step, phase2_client, ready_draft, seed_users, station_id, step, user_id
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
from packetstation.settings import Settings
pytestmark = needs_pg
IT = "Ticket 5120: warehouse lead is out"
MSG_PULL = "Pull every material line or mark it short first."
MSG_HEAT = "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability."
HOLD_REASONS = (
"Waiting on material (not in stock)",
"Waiting on purchasing / PO",
"Question for engineering or clerical",
"Drawing / BOM problem",
"Other",
)
# Everyone but the warehouse and the admin, on a packet the warehouse holds.
REFUSED_IN_WH = ("clerk.clara", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes")
# On a ready packet, lead.lou and sup.wes hold it and may receive; these may not.
REFUSED_IN_READY = ("clerk.clara", "qc.quinn", "pm.paula", "weld.andy")
# A normal line, an N/A line (set by SQL), a sub-assembly line (sub = 'Y' by SQL) and a line with qty 4.
BOM = [
{"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B", "length": "20ft"},
{"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"},
{"item": 3, "qty": 1, "part_id": "SUB-7", "description": "Nozzle sub-assembly"},
{"item": 4, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"},
]
# ---------------------------------------------------------------- helpers
@dataclass
class Shop:
"""One app, one signed-in TestClient per user (separate cookie jars)."""
main: TestClient
settings: Settings
clients: dict[str, TestClient] = field(default_factory=dict)
def __call__(self, sam: str) -> TestClient:
c = self.clients.get(sam)
if c is None:
c = TestClient(self.main.app, headers=ORIGIN_HEADERS)
r = sign_in(c, sam)
assert r.status_code == 200, r.text
self.clients[sam] = c
return c
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
with phase2_client(pg_settings, fake_dir) as client:
seed_users(client)
yield Shop(client, pg_settings)
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()
def _im(v: int) -> dict[str, str]:
return {"If-Match": str(v)}
def line(pkt: dict[str, Any], item: int) -> dict[str, Any]:
return next(b for b in pkt["bom"] if b["item"] == item)
def fresh(c: TestClient, pid: int) -> dict[str, Any]:
r = c.get(f"/api/packets/{pid}")
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def log_rows(c: TestClient, pid: int) -> list[dict[str, Any]]:
r = c.get(f"/api/packets/{pid}/log")
assert r.status_code == 200, r.text
out: list[dict[str, Any]] = r.json()
return out
def last_log(c: TestClient, pid: int) -> dict[str, Any]:
return log_rows(c, pid)[0]
def last_audit(settings: Settings, pid: int) -> Any:
return _one(
settings,
"SELECT action, detail, it_reason FROM audit_events WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
(pid,),
)
def audit_count(settings: Settings, pid: int) -> int:
return int(_one(settings, "SELECT count(*) FROM audit_events WHERE packet_id = %s", (pid,))[0])
def make_unit_sql(settings: Settings, number: str, lot: str = "001") -> tuple[int, int]:
with app_conn(settings) as conn:
pid = conn.execute(
"INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Test project', 'Acme Refining', 'PO-77')"
" RETURNING id",
(number,),
).fetchone()[0]
uid = conn.execute(
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit one', 'open') RETURNING id",
(pid, lot),
).fetchone()[0]
return int(pid), int(uid)
def warehouse_packet(shop: Shop, unit_id: int, **over: Any) -> dict[str, Any]:
"""A hand draft by clerk.clara with the four BOM lines, drawing attached, heat map answered, sent to the
warehouse. Item 2 is N/A and item 3 a sub-assembly (both by SQL)."""
c = shop("clerk.clara")
pkt = ready_draft(c, unit_id, bom=BOM, **over)
with app_conn(shop.settings) as conn:
conn.execute("UPDATE bom_lines SET na = true WHERE packet_id = %s AND item = 2", (pkt["id"],))
conn.execute("UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s AND item = 3", (pkt["id"],))
pkt = fresh(c, pkt["id"])
out = ok_step(c, pkt, "send_to_warehouse")
assert out["status"] == "warehouse" and out["wh"]["stage"] == "incoming"
return out
def tiny_png() -> bytes:
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
idat = zlib.compress(b"\x00\xff\x00\x00")
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
def upload_mtr(c: TestClient, data: bytes, name: str = "mtr.png", mime: str = "image/png") -> dict[str, Any]:
r = c.post("/api/assets", files={"file": (name, data, mime)}, data={"purpose": "mtr"})
assert r.status_code == 201, r.text
out: dict[str, Any] = r.json()
return out
def _it(body: dict[str, Any], it: str | None) -> dict[str, Any]:
return {**body, "it_reason": it} if it else body
Call = Callable[[TestClient, dict[str, Any], str | None], Any]
def wh_move(stage: str, expect: str, **kw: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.post(f"/api/packets/{pkt['id']}/wh", json=_it({"stage": stage, "expect_stage": expect, **kw}, it))
return call
def bom_patch(item: int, **fields: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
rv = line(pkt, item)["row_version"]
return c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json=_it(fields, it), headers=_im(rv))
return call
def short_save(item: int, **body: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.post(f"/api/packets/{pkt['id']}/bom/{item}/short", json=_it(body, it))
return call
def short_clear(item: int) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.request("DELETE", f"/api/packets/{pkt['id']}/bom/{item}/short", json=_it({"expect_open": True}, it))
return call
def receipt(item: int, **body: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return c.post(f"/api/packets/{pkt['id']}/bom/{item}/short/receipts", json=_it(body, it))
return call
def transition(action: str, **kw: Any) -> Call:
def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any:
return step(c, pkt, action, **_it(kw, it))
return call
def refused_then_done(
shop: Shop,
pkt: dict[str, Any],
call: Call,
performer: str,
refused: tuple[str, ...] = REFUSED_IN_WH,
) -> dict[str, Any]:
"""Every refused role gets 403 forbidden, admin.ann without an IT reason 400 it_reason_required; then the
performer (wh.wade, or admin.ann with an IT reason) does it. Returns the new read model."""
for sam in refused:
r = call(shop(sam), pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, r.text)
r = call(shop("admin.ann"), pkt, None)
assert r.status_code == 400 and r.json()["code"] == "it_reason_required", r.text
it = IT if performer == "admin.ann" else None
r = call(shop(performer), pkt, it)
assert r.status_code == 200, (performer, r.text)
out: dict[str, Any] = r.json()
return out
# ---------------------------------------------------------------- every action, every role, the log texts
# (call, log action, log note or None for "do not check", audit action)
def walk_steps(mtr_id: str) -> list[tuple[Call, str, str | None, str]]:
return [
(wh_move("pulling", "incoming"), "Warehouse: Started pulling", None, "packet.set_wh_stage"),
(
wh_move("hold", "pulling", reason="Drawing / BOM problem", note="Asked Leo"),
"Warehouse: Put on hold",
"Drawing / BOM problem · Asked Leo",
"packet.set_wh_stage",
),
(wh_move("pulling", "hold"), "Warehouse: Pulling resumed", None, "packet.set_wh_stage"),
(bom_patch(1, heat="HT-1"), "Heat recorded · item 1", "HT-1", "packet.edit_bom"),
(bom_patch(1, pulled=True), "Pulled · item 1", None, "packet.edit_bom"),
(bom_patch(1, pulled=False), "Pulled undone · item 1", None, "packet.edit_bom"),
(bom_patch(1, pulled=True), "Pulled · item 1", None, "packet.edit_bom"),
(bom_patch(2, na=False), "Traceable again · item 2", None, "packet.edit_bom"),
(bom_patch(2, na=True), "Marked N/A · item 2", None, "packet.edit_bom"),
(bom_patch(4, issued=2), "Issued qty changed · item 4", "2 of 4", "packet.edit_bom"),
(bom_patch(4, loc="Rack 4"), "Location set · item 4", "Rack 4", "packet.edit_bom"),
(bom_patch(1, heat=None), "Heat cleared · item 1", None, "packet.edit_bom"),
(bom_patch(1, mtr_asset_id=mtr_id), "MTR attached · item 1", None, "packet.edit_bom"),
(bom_patch(1, mtr_asset_id=None), "MTR removed · item 1", None, "packet.edit_bom"),
(bom_patch(1, heat="HT-2", loc="Bin 2"), "Line changed · item 1", None, "packet.edit_bom"),
(
short_save(4, qty=2, reason="On order", ref="PO-4471", eta="2026-11-20", expect_open=False),
"Short recorded · item 4",
"2 of 4 short · On order · PO-4471 · ETA 2026-11-20",
"packet.edit_bom",
),
(
short_save(4, qty=1, reason="Back-ordered", expect_open=True),
"Short updated · item 4",
"1 of 4 short · Back-ordered",
"packet.edit_bom",
),
(short_clear(4), "Short cleared · item 4", None, "packet.edit_bom"),
(
short_save(4, qty=1, reason="Not in stock", heat="HT-4", expect_open=False),
"Short recorded · item 4",
"1 of 4 short · Not in stock",
"packet.edit_bom",
),
(bom_patch(3, pulled=True), "Pulled · item 3", None, "packet.edit_bom"),
(wh_move("pulled", "pulling"), "Warehouse: Marked pulled, ready for production", None, "packet.set_wh_stage"),
(wh_move("pulling", "pulled"), "Warehouse: Back to pulling", None, "packet.set_wh_stage"),
(wh_move("pulled", "pulling"), "Warehouse: Marked pulled, ready for production", None, "packet.set_wh_stage"),
(
transition("wh_release"),
"Released to supervisor with 1 short",
"2 heats recorded · SHORT: item 4 ×1 (Not in stock)",
"packet.wh_release",
),
]
@pytest.mark.parametrize("performer", ["wh.wade", "admin.ann"])
def test_every_warehouse_action_as_every_role(shop: Shop, performer: str) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9W" + ("1" if performer == "wh.wade" else "2"))
pkt = warehouse_packet(shop, uid)
pid = pkt["id"]
mtr = upload_mtr(shop("wh.wade"), tiny_png())
for call, action, note, audit_action in walk_steps(mtr["id"]):
before = audit_count(s, pid)
# The refusals are checked once, on the wh.wade walk; the admin walk checks the IT reason only.
pkt = refused_then_done(shop, pkt, call, performer, REFUSED_IN_WH if performer == "wh.wade" else ())
lg = last_log(shop("wh.wade"), pid)
assert lg["action"] == action, (action, lg)
if note is not None:
assert lg["note"] == note, (action, lg)
if action.startswith("Heat recorded"):
assert lg["note"] == "HT-1"
assert audit_count(s, pid) == before + 1, action
au = last_audit(s, pid)
assert au[0] == audit_action, (action, au)
if performer == "admin.ann":
assert lg["it_reason"] == IT and lg["by"]["display_name"] == "Ann Admin", lg
assert au[2] == IT
else:
assert lg["it_reason"] is None and lg["by"]["display_name"] == "Wade Warehouse"
if audit_action == "packet.edit_bom" and "item" in action:
assert int(au[1]["item"]) == int(action.rsplit(" ", 1)[1])
if action.startswith("Short recorded") or action.startswith("Short updated"):
assert float(au[1]["qty"]) in (1.0, 2.0), au
if audit_action == "packet.set_wh_stage":
assert au[1]["wh_from"] and au[1]["wh_to"]
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
assert line(pkt, 1)["heat"] == "HT-2" and line(pkt, 1)["loc"] == "Bin 2" and line(pkt, 1)["mtr_asset_id"] is None
assert line(pkt, 2)["na"] is True and line(pkt, 4)["heat"] == "HT-4"
# The short receipt on the ready packet: the warehouse (or IT) receives; Clerical, Q.C., PM and a welder cannot.
pkt = refused_then_done(shop, pkt, receipt(4, qty=1, heat="HT-5"), performer, REFUSED_IN_READY)
lg = last_log(shop("wh.wade"), pid)
assert lg["action"] == "Short received · item 4"
assert lg["note"] == "1 pc · heat HT-5 · short closed · packet ready to assign"
au = last_audit(s, pid)
assert float(au[1]["qty"]) == 1 and int(au[1]["item"]) == 4
assert au[0] == ("packet.wh_receive_short" if performer == "wh.wade" else au[0])
assert pkt["open_shorts"] == 0 and line(pkt, 4)["short"] is None
@pytest.mark.parametrize("performer", ["wh.wade", "admin.ann"])
def test_send_back_to_clerical(shop: Shop, performer: str) -> None:
_, uid = make_unit_sql(shop.settings, "L9S" + ("1" if performer == "wh.wade" else "2"))
pkt = warehouse_packet(shop, uid)
reason = "The BOM is the old revision"
short = shop("wh.wade").post(f"/api/packets/{pkt['id']}/transitions", json={"action": "wh_send_back"})
assert short.status_code in (400, 422), short.text
r = step(shop("wh.wade"), pkt, "wh_send_back", reason="too few")
assert r.status_code == 400 and r.json()["code"] == "reason_required", r.text
pkt = refused_then_done(shop, pkt, transition("wh_send_back", reason=reason), performer)
assert pkt["status"] == "draft"
lg = last_log(shop("clerk.clara"), pkt["id"])
assert lg["action"] == "Sent back to Clerical" and lg["note"] == reason
assert last_audit(shop.settings, pkt["id"])[0] == "packet.wh_send_back"
# ---------------------------------------------------------------- hold reasons and stage moves
def test_hold_reasons_and_stage_moves(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9H1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
url = f"/api/packets/{pkt['id']}/wh"
assert c.post(url, json={"stage": "pulling", "expect_stage": "incoming"}).status_code == 200
same = c.post(url, json={"stage": "pulling", "expect_stage": "pulling"})
assert same.status_code == 409 and same.json()["code"] == "wh_transition", same.text
stale = c.post(url, json={"stage": "hold", "reason": "Other", "note": "x", "expect_stage": "incoming"})
assert stale.status_code == 409 and stale.json()["code"] == "stale_step", stale.text
assert stale.json()["current"]["stage"] == "pulling"
bad = c.post(url, json={"stage": "hold", "reason": "Lunch break", "expect_stage": "pulling"})
assert bad.status_code == 400 and bad.json()["code"] == "invalid_reason", bad.text
assert bad.json()["detail"] == "Pick one of the hold reasons."
other = c.post(url, json={"stage": "hold", "reason": "Other", "expect_stage": "pulling"})
assert other.status_code == 400 and other.json()["code"] == "reason_required", other.text
assert other.json()["detail"] == "Say what it's waiting on."
none = c.post(url, json={"stage": "hold", "expect_stage": "pulling"})
assert none.status_code == 400, none.text
for reason in HOLD_REASONS:
body: dict[str, Any] = {"stage": "hold", "reason": reason, "expect_stage": "pulling"}
if reason == "Other":
body["note"] = "Crane is down"
r = c.post(url, json=body)
assert r.status_code == 200, (reason, r.text)
assert r.json()["wh"]["stage"] == "hold" and r.json()["wh"]["reason"] == reason
lg = last_log(c, pkt["id"])
assert lg["action"] == "Warehouse: Put on hold"
assert lg["note"] == (f"{reason} · Crane is down" if reason == "Other" else reason)
r = c.post(url, json={"stage": "pulling", "expect_stage": "hold"})
assert r.status_code == 200 and last_log(c, pkt["id"])["action"] == "Warehouse: Pulling resumed"
long = c.post(url, json={"stage": "hold", "reason": "Other", "note": "n" * 300, "expect_stage": "pulling"})
assert long.status_code == 200, long.text
assert len(long.json()["wh"]["note"]) == 200
c.post(url, json={"stage": "pulling", "expect_stage": "hold"})
r = c.post(url, json={"stage": "pulled", "expect_stage": "pulling"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulled"
r = c.post(url, json={"stage": "pulling", "expect_stage": "pulled"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling"
assert last_log(c, pkt["id"])["action"] == "Warehouse: Back to pulling"
bad = c.post(url, json={"stage": "incoming", "expect_stage": "pulling"})
assert bad.status_code in (409, 422), bad.text
def test_pulled_tick_auto_starts(shop: Shop) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9A1")
c = shop("wh.wade")
# From incoming
a = warehouse_packet(shop, uid)
r = bom_patch(1, pulled=True)(c, a, None)
assert r.status_code == 200, r.text
assert r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is True
lg = last_log(c, a["id"])
assert lg["action"] == "Pulled · item 1" and lg["note"].endswith("started pulling"), lg
row = _one(s, "SELECT stage, auto FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (a["id"],))
assert row == ("pulling", True)
# A second tick does not move the stage again
r = bom_patch(4, pulled=True)(c, r.json(), None)
assert last_log(c, a["id"])["action"] == "Pulled · item 4"
assert int(_one(s, "SELECT count(*) FROM packet_wh WHERE packet_id = %s", (a["id"],))[0]) == 2
# From hold
_, uid2 = make_unit_sql(s, "L9A2")
b = warehouse_packet(shop, uid2)
r = c.post(
f"/api/packets/{b['id']}/wh",
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
)
assert r.status_code == 200, r.text
r = bom_patch(1, pulled=True)(c, r.json(), None)
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is True
lg = last_log(c, b["id"])
assert lg["action"] == "Pulled · item 1" and lg["note"].endswith("pulling resumed"), lg
row = _one(s, "SELECT stage, auto FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (b["id"],))
assert row == ("pulling", True)
# ---------------------------------------------------------------- heats and MTRs
BAD_HEATS = ("<img src=x>", "A" * 41, " HT1", "HT@1", "-HT1")
def test_bad_heat_is_refused(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9B1")
pkt = warehouse_packet(shop, uid)
for value in BAD_HEATS:
r = bom_patch(1, heat=value)(shop("wh.wade"), pkt, None)
assert r.status_code == 422 and r.json()["code"] == "invalid_heat", (value, r.text)
if value in ("<img src=x>", "HT@1"):
assert r.json()["detail"] == (
f'Heat number "{value}" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.'
)
assert fresh(shop("wh.wade"), pkt["id"])["bom"][0]["heat"] is None
def test_good_heats_are_recorded(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9G1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
for value in ("HT-1234/7A", "7A", "A 1.2#3_x", "A" * 40):
r = bom_patch(1, heat=value)(c, pkt, None)
assert r.status_code == 200, (value, r.text)
pkt = r.json()
assert line(pkt, 1)["heat"] == value
lg = last_log(c, pkt["id"])
assert lg["action"] == "Heat recorded · item 1" and lg["note"] == value
# A trailing space from a scanner is trimmed
r = bom_patch(1, heat="HT-9 ")(c, pkt, None)
assert r.status_code == 200 and line(r.json(), 1)["heat"] == "HT-9"
def test_mtr_must_be_a_pdf_or_image(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9M1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
missing = bom_patch(1, mtr_asset_id="6f1c1d1e-0000-4000-8000-000000000000")(c, pkt, None)
assert missing.status_code == 422 and missing.json()["code"] == "invalid_asset"
csv = shop("clerk.clara").post(
"/api/assets", files={"file": ("bom.csv", b"item,qty\n1,4\n", "text/csv")}, data={"purpose": "bom"}
)
assert csv.status_code == 201, csv.text
wrong = bom_patch(1, mtr_asset_id=csv.json()["id"])(c, pkt, None)
assert wrong.status_code == 422 and wrong.json()["code"] == "invalid_asset", wrong.text
assert wrong.json()["detail"] == "An MTR is a PDF, PNG or JPG file."
png = upload_mtr(c, tiny_png())
r = bom_patch(1, mtr_asset_id=png["id"])(c, pkt, None)
assert r.status_code == 200 and line(r.json(), 1)["mtr_asset_id"] == png["id"], r.text
r = bom_patch(1, mtr_asset_id=None)(c, r.json(), None)
assert r.status_code == 200 and line(r.json(), 1)["mtr_asset_id"] is None
# ---------------------------------------------------------------- the release gate
def test_release_gate(shop: Shop) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9R1")
pkt = warehouse_packet(shop, uid)
pid = pkt["id"]
c = shop("wh.wade")
pkt = bom_patch(1, heat="HT-1")(c, pkt, None).json()
pkt = bom_patch(1, pulled=True)(c, pkt, None).json()
pkt = bom_patch(3, pulled=True)(c, pkt, None).json() # the sub line needs no heat
# Item 4 is not pulled: the first rule only (the N/A line and the sub line need nothing)
assert "wh_release" not in pkt["can"] and pkt["blocked"]["wh_release"] == [MSG_PULL], pkt["blocked"]
r = step(c, pkt, "wh_release")
assert r.status_code == 403 and r.json()["code"] == "precondition" and r.json()["detail"] == MSG_PULL
# Every line pulled, item 4 has no heat
pkt = bom_patch(4, pulled=True)(c, pkt, None).json()
assert pkt["blocked"]["wh_release"] == [MSG_HEAT], pkt["blocked"]
r = step(c, pkt, "wh_release")
assert r.status_code == 403 and r.json()["code"] == "precondition" and r.json()["detail"] == MSG_HEAT
card = next(x for x in c.get("/api/packets", params={"status": "warehouse"}).json()["packets"] if x["id"] == pid)
assert card["bom_total"] == 3 and card["bom_pulled"] == 3 and card["bom_missing_heat"] == 1
# Unpull item 4 and record a short instead: the first rule passes, the heat of the pulled pieces is missing
pkt = bom_patch(4, pulled=False)(c, pkt, None).json()
assert pkt["blocked"]["wh_release"] == [MSG_PULL]
r = short_save(4, qty=1, reason="Not in stock", expect_open=False)(c, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert pkt["blocked"]["wh_release"] == [MSG_HEAT], pkt["blocked"]
r = short_save(4, qty=1, reason="Not in stock", heat="HT-4", expect_open=True)(c, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert "wh_release" in pkt["can"] and "wh_release" not in pkt["blocked"]
pkt = ok_step(c, pkt, "wh_release")
assert pkt["status"] == "ready"
lg = last_log(c, pid)
assert lg["action"] == "Released to supervisor with 1 short"
assert lg["note"] == "2 heats recorded · SHORT: item 4 ×1 (Not in stock)"
board = c.get("/api/warehouse/board").json()
rel = [x for x in board["columns"]["released"] if x["id"] == pid]
assert len(rel) == 1 and rel[0]["released_at"], board["columns"]["released"]
assert board["kpis"]["released_24h"] >= 1
assert all(pid not in [x["id"] for x in board["columns"][k]] for k in ("incoming", "pulling", "hold", "pulled"))
# Sent back and released again: it shows once in the released column
pkt = ok_step(shop("lead.lou"), pkt, "send_back_to_warehouse", reason="Wrong studs were pulled")
assert pkt["status"] == "warehouse"
pkt = ok_step(c, fresh(c, pid), "wh_release")
lg = last_log(c, pid)
assert lg["action"] == "Released to supervisor with 1 short"
board = c.get("/api/warehouse/board").json()
assert [x["id"] for x in board["columns"]["released"]].count(pid) == 1
# Without shorts the label has no count
_, uid2 = make_unit_sql(s, "L9R2")
other = warehouse_packet(shop, uid2)
for item in (1, 3, 4):
other = bom_patch(item, pulled=True)(c, other, None).json()
for item in (1, 4):
other = bom_patch(item, heat=f"HT-{item}0")(c, other, None).json()
other = ok_step(c, other, "wh_release")
lg = last_log(c, other["id"])
assert lg["action"] == "Released to supervisor" and lg["note"] == "2 heats recorded"
def test_release_gate_without_traceable_lines(shop: Shop) -> None:
"""A packet whose lines are all N/A or sub-assemblies needs no heat at all."""
_, uid = make_unit_sql(shop.settings, "L9R3")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
pkt = bom_patch(1, na=True)(c, pkt, None).json()
pkt = bom_patch(4, na=True)(c, pkt, None).json()
assert pkt["blocked"]["wh_release"] == [MSG_PULL]
pkt = bom_patch(3, pulled=True)(c, pkt, None).json()
assert "wh_release" in pkt["can"], pkt["blocked"]
# ---------------------------------------------------------------- receiving a short by custody
def test_receive_short_by_custody(shop: Shop) -> None:
s = shop.settings
_, uid = make_unit_sql(s, "L9C1")
pkt = warehouse_packet(shop, uid)
pid = pkt["id"]
wade = shop("wh.wade")
pkt = bom_patch(1, heat="HT-1")(wade, pkt, None).json()
pkt = bom_patch(1, pulled=True)(wade, pkt, None).json()
pkt = bom_patch(3, pulled=True)(wade, pkt, None).json()
pkt = short_save(4, qty=3, reason="On order", ref="PO-88", heat="HT-4", expect_open=False)(wade, pkt, None).json()
pkt = ok_step(wade, pkt, "wh_release")
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
r = receipt(4, qty=1, heat="HT-R1")(wade, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
lg = last_log(wade, pid)
assert lg["action"] == "Short received · item 4"
assert lg["note"] == "1 pc · heat HT-R1 · 2 still short · packet ready to assign"
assert last_audit(s, pid)[0] == "packet.wh_receive_short"
# The warehouse does not hold the ready packet: no other line edit
r = bom_patch(1, heat="HT-X")(wade, pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
r = bom_patch(1, loc="Rack 9")(wade, pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
for sam in ("qc.quinn", "clerk.clara", "pm.paula", "weld.andy"):
r = receipt(4, qty=1, heat="HT-R2")(shop(sam), pkt, None)
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, r.text)
lou = shop("lead.lou")
over = receipt(4, qty=3, heat="HT-R2")(lou, pkt, None)
assert over.status_code == 422, over.text
noheat = receipt(4, qty=1)(lou, pkt, None)
assert noheat.status_code == 422 and noheat.json()["code"] == "heat_required", noheat.text
r = receipt(4, qty=1, heat="HT-R2")(lou, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert last_log(lou, pid)["by"]["display_name"] == "Lou Lead"
assert last_audit(s, pid)[0] == "packet.edit_bom"
r = receipt(4, qty=1, heat="HT-R3")(shop("sup.wes"), pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
assert pkt["open_shorts"] == 0 and line(pkt, 4)["short"] is None
assert line(pkt, 4)["heat"] == "HT-4 / HT-R1 / HT-R2 / HT-R3"
assert last_log(wade, pid)["note"] == "1 pc · heat HT-R3 · short closed · packet ready to assign"
closed = _one(
s,
"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 = 4",
(pid,),
)
assert closed[0] is not None and float(closed[1]) == 0
r = receipt(4, qty=1, heat="HT-R4")(wade, pkt, None)
assert r.status_code in (403, 404), r.text # nothing is short now
# The holder may open a short on a ready packet; a receipt on an N/A line needs no heat.
r = short_save(2, qty=2, reason="Damaged / rejected", expect_open=False)(lou, pkt, None)
assert r.status_code == 200, r.text
pkt = r.json()
with app_conn(s) as conn:
stall = station_id(conn)
andy = user_id(conn, "weld.andy")
pkt = ok_step(shop("sup.wes"), pkt, "assign", station_id=stall, welder_id=andy)
r = receipt(2, qty=2)(wade, pkt, None)
assert r.status_code == 200, r.text
lg = last_log(wade, pid)
assert lg["action"] == "Short received · item 2"
assert lg["note"] == "2 pc · short closed · packet at a station in Stall 1"
def test_short_reasons_are_a_closed_set(shop: Shop) -> None:
_, uid = make_unit_sql(shop.settings, "L9Q1")
pkt = warehouse_packet(shop, uid)
c = shop("wh.wade")
bad = short_save(4, qty=1, reason="Lost it", expect_open=False)(c, pkt, None)
assert bad.status_code == 400 and bad.json()["code"] == "invalid_reason", bad.text
assert bad.json()["detail"] == "Pick one of the short reasons."
for q in (0, 5):
r = short_save(4, qty=q, reason="Other", note="x", expect_open=False)(c, pkt, None)
assert r.status_code == 422, (q, r.text)
reasons = (
"Not in stock",
"On order",
"Back-ordered",
"Damaged / rejected",
"Wrong part received",
"Needs cutting / prep",
"Other",
)
open_ = False
for reason in reasons:
r = short_save(4, qty=1, reason=reason, note="see PO", expect_open=open_)(c, pkt, None)
assert r.status_code == 200, (reason, r.text)
open_ = True
assert line(r.json(), 4)["short"]["reason"] == reason
# ---------------------------------------------------------------- the board endpoint
def test_board(shop: Shop) -> None:
s = shop.settings
proj_a, unit_a = make_unit_sql(s, "L9K1")
proj_b, unit_b = make_unit_sql(s, "L9K2")
p1 = warehouse_packet(shop, unit_a, drawing="D-OLD")
p2 = warehouse_packet(shop, unit_a, drawing="D-NEW")
p5 = warehouse_packet(shop, unit_a, drawing="D-REL")
p3 = warehouse_packet(shop, unit_a, drawing="D-HOLD")
p4 = warehouse_packet(shop, unit_b, drawing="D-OTHER")
with app_conn(s) as conn:
conn.execute("UPDATE packets SET wh_sent_at = now() - interval '3 days' WHERE id = %s", (p1["id"],))
conn.execute(
"UPDATE packets SET wh_sent_at = date_trunc('day', now()) - interval '1 day' + interval '9 hours'"
" WHERE id = %s",
(p2["id"],),
)
conn.execute(
"UPDATE packets SET wh_sent_at = date_trunc('day', now()) - interval '1 day' + interval '10 hours',"
" visual_rel = '2026-11-01' WHERE id = %s",
(p5["id"],),
)
wade = shop("wh.wade")
r = wade.post(
f"/api/packets/{p3['id']}/wh",
json={"stage": "hold", "reason": "Drawing / BOM problem", "note": "Asked Leo", "expect_stage": "incoming"},
)
assert r.status_code == 200, r.text
r = wade.post(f"/api/packets/{p4['id']}/wh", json={"stage": "pulling", "expect_stage": "incoming"})
assert r.status_code == 200, r.text
p4 = r.json()
p4 = short_save(4, qty=1, reason="On order", ref="PO-1", eta="2020-01-02", expect_open=False)(wade, p4, None).json()
p1 = short_save(1, qty=1, reason="Not in stock", expect_open=False)(wade, p1, None).json()
assert p1["wh"]["stage"] == "pulling" # the short auto-started it
r = shop("pm.paula").get("/api/warehouse/board")
assert r.status_code == 200, r.text
b = r.json()
assert set(b) >= {"kpis", "columns", "shorts"}
assert b["kpis"] == {"waiting": 2, "pulling": 2, "hold": 1, "pulled": 0, "open_shorts": 2, "released_24h": 0}
cols = {k: [x["id"] for x in v] for k, v in b["columns"].items()}
assert set(cols) == {"incoming", "pulling", "hold", "pulled", "released"}
assert cols["incoming"] == [p5["id"], p2["id"]] # same day: the earlier VISUAL release date first
assert cols["pulling"] == [p1["id"], p4["id"]] # the older day first
assert cols["hold"] == [p3["id"]] and cols["pulled"] == [] and cols["released"] == []
hold = b["columns"]["hold"][0]
assert hold["wh"]["stage"] == "hold" and hold["wh"]["reason"] == "Drawing / BOM problem"
assert hold["wh"]["note"] == "Asked Leo" and hold["wh"]["auto"] is False
assert hold["wh"]["by"]["display_name"] == "Wade Warehouse" and hold["wh"]["at"]
inc = b["columns"]["incoming"][0]
assert inc["visual_rel"] == "2026-11-01" and inc["wh_sent_at"]
assert inc["bom_total"] == 3 and inc["bom_pulled"] == 0 and inc["bom_missing_heat"] == 0
shorts = b["shorts"]
assert [(x["packet"]["id"], x["item"]) for x in shorts] == [(p4["id"], 4), (p1["id"], 1)]
late, plain = shorts
assert late["late"] is True and plain["late"] is False
assert float(late["qty"]) == 4 and float(late["qty_open"]) == 1 and late["reason"] == "On order"
assert late["ref"] == "PO-1" and late["eta"] == "2020-01-02" and late["age_days"] == 0
assert late["part_id"] == "STUD-58" and late["description"] == "5/8 stud bolt"
assert late["short_id"] and late["row_version"] >= 1 and late["bom_line_id"] and late["opened_at"]
pk = late["packet"]
assert pk["status"] == "warehouse" and pk["wh_stage"] == "pulling" and pk["holder"] == "Warehouse"
assert pk["dept"]["code"] == "03" and pk["station"] is None and pk["drawing"] == "D-OTHER"
assert plain["eta"] is None and plain["material"] == "A106-B" and plain["length"] == "20ft"
def board(**params: Any) -> dict[str, Any]:
r = wade.get("/api/warehouse/board", params=params)
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def ids(bd: dict[str, Any]) -> set[int]:
return {x["id"] for v in bd["columns"].values() for x in v}
by_project = board(project_id=proj_b)
assert ids(by_project) == {p4["id"]} and [x["packet"]["id"] for x in by_project["shorts"]] == [p4["id"]]
assert by_project["kpis"]["waiting"] == 0 and by_project["kpis"]["pulling"] == 1
assert by_project["kpis"]["hold"] == 0 and by_project["kpis"]["open_shorts"] == 1
by_unit = board(unit_id=unit_a)
assert ids(by_unit) == {p1["id"], p2["id"], p3["id"], p5["id"]}
assert by_unit["kpis"]["waiting"] == 2 and by_unit["kpis"]["pulling"] == 1 and by_unit["kpis"]["hold"] == 1
by_q = board(q="d-hold")
assert ids(by_q) == {p3["id"]} and by_q["shorts"] == []
assert by_q["kpis"] == b["kpis"] # the KPIs follow the project and unit only
by_dept = board(dept="06")
assert ids(by_dept) == set() and by_dept["shorts"] == [] and by_dept["kpis"] == b["kpis"]
assert ids(board(dept="03")) == ids(b)
assert proj_a != proj_b
# Every card of the packet list carries the warehouse fields
cards = {x["id"]: x for x in wade.get("/api/packets", params={"status": "warehouse"}).json()["packets"]}
c4 = cards[p4["id"]]
assert c4["wh"]["stage"] == "pulling" and c4["wh_stage"] == "pulling"
assert c4["bom_total"] == 3 and c4["bom_pulled"] == 1 and c4["bom_missing_heat"] == 1
assert "wh_sent_at" in c4 and "visual_rel" in c4
# A removed packet's shorts are not listed
with app_conn(s) as conn:
conn.execute("UPDATE packets SET removed_at = now() WHERE id = %s", (p4["id"],))
after = board()
assert p4["id"] not in [x["packet"]["id"] for x in after["shorts"]]
assert p4["id"] not in ids(after)
def test_board_needs_a_session(shop: Shop) -> None:
as_user(shop.main, "weld.andy")
r = shop.main.get("/api/warehouse/board")
assert r.status_code == 200 and r.json()["kpis"]["waiting"] == 0
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
assert anon.get("/api/warehouse/board").status_code == 401