Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""The pipeline under concurrency: the FOR UPDATE lock serialises writers; counters never collide; the audit chain
|
||||
holds after a full packet life; packet_log is append-only for the app role."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pkthelpers import as_user, happy_path, make_unit, phase2_client, ready_draft, seed_users, step
|
||||
from pshelpers import app_conn, needs_pg
|
||||
|
||||
from packetstation import audit
|
||||
from packetstation.domain.pipeline import next_counter
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||
with phase2_client(pg_settings, fake_dir) as client:
|
||||
seed_users(client)
|
||||
yield client
|
||||
|
||||
|
||||
def test_same_step_twice_one_wins(pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
_, unit_id = make_unit(conn)
|
||||
as_user(pg_client2, "clerk.clara")
|
||||
pkt = ready_draft(pg_client2, unit_id)
|
||||
with phase2_client(pg_settings, fake_dir) as other:
|
||||
as_user(other, "clerk.clara")
|
||||
results: list[Any] = [None, None]
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def run(i: int, client: TestClient) -> None:
|
||||
barrier.wait()
|
||||
results[i] = step(client, pkt, "send_to_warehouse")
|
||||
|
||||
threads = [threading.Thread(target=run, args=(i, c)) for i, c in enumerate((pg_client2, other))]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(30)
|
||||
codes = sorted(r.status_code for r in results)
|
||||
assert codes == [200, 409], [r.text for r in results]
|
||||
loser = next(r for r in results if r.status_code == 409)
|
||||
assert loser.json()["code"] == "stale_step"
|
||||
assert loser.json()["current"]["status"] == "warehouse" and loser.json()["current"]["version"] == pkt["version"] + 1
|
||||
with app_conn(pg_settings) as conn:
|
||||
n = conn.execute("SELECT count(*) FROM packet_transitions WHERE packet_id = %s", (pkt["id"],)).fetchone()[0]
|
||||
wh = conn.execute("SELECT count(*) FROM packet_wh WHERE packet_id = %s", (pkt["id"],)).fetchone()[0]
|
||||
assert n == 1 and wh == 1
|
||||
|
||||
|
||||
def test_next_counter_concurrent(pg_client2: TestClient, pg_settings: Settings) -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
_, unit_id = make_unit(conn)
|
||||
pid = conn.execute(
|
||||
"INSERT INTO packets (unit_id, dept_code) VALUES (%s, '03') RETURNING id", (unit_id,)
|
||||
).fetchone()[0]
|
||||
got: list[int] = []
|
||||
lock = threading.Lock()
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def take() -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
barrier.wait()
|
||||
with conn.transaction():
|
||||
n = next_counter(conn, pid, "weld")
|
||||
with lock:
|
||||
got.append(n)
|
||||
|
||||
threads = [threading.Thread(target=take) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(30)
|
||||
assert sorted(got) == list(range(1, 9))
|
||||
with app_conn(pg_settings) as conn:
|
||||
assert conn.execute("SELECT next_weld_no FROM packets WHERE id = %s", (pid,)).fetchone()[0] == 9
|
||||
with conn.transaction():
|
||||
assert next_counter(conn, pid, "page", 3) == 1
|
||||
assert next_counter(conn, pid, "page") == 4
|
||||
|
||||
|
||||
def test_audit_chain_after_happy_path(pg_client2: TestClient, pg_settings: Settings) -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
_, unit_id = make_unit(conn)
|
||||
happy_path(pg_client2, pg_settings, unit_id)
|
||||
with app_conn(pg_settings) as conn:
|
||||
ok, n, detail = audit.verify_chain(conn)
|
||||
actions = {r[0] for r in conn.execute("SELECT action FROM audit_events")}
|
||||
assert ok, detail
|
||||
assert n > 20
|
||||
assert {"packet.created", "packet.file", "packet.qc_accept", "sig.signed", "asset.uploaded"} <= actions
|
||||
|
||||
|
||||
def test_packet_log_append_only(pg_client2: TestClient, pg_settings: Settings) -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
_, unit_id = make_unit(conn)
|
||||
as_user(pg_client2, "clerk.clara")
|
||||
pkt = ready_draft(pg_client2, unit_id)
|
||||
for sql in (
|
||||
"UPDATE packet_log SET note = 'x' WHERE packet_id = %s",
|
||||
"DELETE FROM packet_log WHERE packet_id = %s",
|
||||
"UPDATE packet_transitions SET reason = 'x' WHERE packet_id = %s",
|
||||
):
|
||||
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
|
||||
conn.execute(sql, (pkt["id"],))
|
||||
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
|
||||
conn.execute("TRUNCATE packet_log")
|
||||
Reference in New Issue
Block a user