118 lines
4.6 KiB
Python
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")
|