Files
PacketStation/tests/server/test_pipeline_concurrency.py
spencermandClaude Fable 5.1 37d9a0cb50 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]>
2026-10-16 09:31:45 -06:00

118 lines
4.6 KiB
Python

"""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")