Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
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:
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user