Files

222 lines
9.5 KiB
Python
Raw Permalink Normal View History

"""Phase 5 shop writes under concurrency: weld and heat tag numbers come from the packet counter under the row
lock, so two sessions marking at once never collide; two supervisors verify two departments of one change order
at once and both land; two users tick different checklist keys and both stay; a stale expect is refused with the
message; a write to a packet filed in the meantime is refused."""
from __future__ import annotations
import threading
from collections.abc import Callable, Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import step
from pshelpers import needs_pg
from shophelpers import (
SIGN,
STALE,
Shop,
all_rows,
finish_stage,
fresh,
in_progress,
issue_co,
ok,
open_shop,
refused,
run,
to_station,
verify,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def together(calls: list[Callable[[], Any]]) -> list[Any]:
"""Run the calls in threads released by one barrier. Returns their answers in order."""
results: list[Any] = [None] * len(calls)
errors: list[BaseException] = []
barrier = threading.Barrier(len(calls))
def run_one(i: int) -> None:
try:
barrier.wait(10)
results[i] = calls[i]()
except BaseException as e: # reported below
errors.append(e)
threads = [threading.Thread(target=run_one, args=(i,)) for i in range(len(calls))]
for t in threads:
t.start()
for t in threads:
t.join(60)
assert not errors, errors
return results
def weld_call(c: TestClient, pid: int, fx: float) -> Callable[[], Any]:
body = {"page": 1, "fx": fx, "fy": 0.5, "items": [1], "type": "Fillet", "process": "GMAW"}
return lambda: c.post(f"/api/packets/{pid}/welds", json=body)
def tag_call(c: TestClient, pid: int, fx: float) -> Callable[[], Any]:
return lambda: c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 2, "fx": fx, "fy": 0.4})
def test_concurrent_welds_never_collide(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
# weld.andy on two sessions (two tablets) and sup.wes, three welds each, all at once
clients = [shop.fresh("weld.andy"), shop.fresh("weld.andy"), shop.fresh("sup.wes")]
calls = [weld_call(c, pid, 0.1 + 0.1 * i + 0.01 * k) for i, c in enumerate(clients) for k in range(3)]
answers = together(calls)
assert [r.status_code for r in answers] == [200] * len(calls), [r.text for r in answers if r.status_code != 200]
nos = sorted(w["weld_no"] for w in fresh(shop("sup.wes"), pid)["welds"])
assert nos == list(range(1, len(calls) + 1))
rows = all_rows(shop.settings, "SELECT weld_no FROM welds WHERE packet_id = %s ORDER BY weld_no", (pid,))
assert [r[0] for r in rows] == nos
marked = sorted(
r[0]
for r in all_rows(shop.settings, "SELECT action FROM packet_log WHERE packet_id = %s", (pid,))
if r[0].endswith(" marked") and r[0].startswith("Weld W")
)
assert marked == sorted(f"Weld W{n} marked" for n in nos)
assert (
int(all_rows(shop.settings, "SELECT next_weld_no FROM packets WHERE id = %s", (pid,))[0][0]) == len(calls) + 1
)
def test_two_welds_at_once_are_w1_and_w2(shop: Shop) -> None:
pkt = in_progress(shop)
a, b = together(
[weld_call(shop.fresh("weld.andy"), pkt["id"], 0.2), weld_call(shop.fresh("sup.wes"), pkt["id"], 0.7)]
)
assert a.status_code == 200 and b.status_code == 200, (a.text, b.text)
got = {w["fx"]: w["weld_no"] for w in fresh(shop("sup.wes"), pkt["id"])["welds"]}
assert sorted(got.values()) == [1, 2] and got[0.2] != got[0.7]
def test_concurrent_heat_tags_never_collide(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
clients = [shop.fresh("weld.andy"), shop.fresh("sup.wes"), shop.fresh("lead.lou")]
calls = [tag_call(c, pid, 0.1 + 0.1 * i + 0.01 * k) for i, c in enumerate(clients) for k in range(3)]
answers = together(calls)
assert [r.status_code for r in answers] == [200] * len(calls), [r.text for r in answers if r.status_code != 200]
nos = sorted(t["tag_no"] for t in fresh(shop("sup.wes"), pid)["heat_tags"])
assert nos == list(range(1, len(calls) + 1))
def test_two_supervisors_verify_at_once(shop: Shop) -> None:
pkt = in_progress(shop)
pkt = issue_co(shop, pkt, ["03", "06"])
assert pkt["change_orders"][0]["required"] == ["03", "06"]
wes, mia = shop.fresh("sup.wes"), shop.fresh("multi.mia")
def call(c: TestClient, dept: str) -> Callable[[], Any]:
body = {"dept": dept, "note": f"Checked in {dept}", "sign": SIGN}
return lambda: c.post(f"/api/packets/{pkt['id']}/change-orders/1/verify", json=body)
a, b = together([call(wes, "03"), call(mia, "06")])
assert a.status_code == 200 and b.status_code == 200, (a.text, b.text)
out = fresh(shop("sup.wes"), pkt["id"])
co = out["change_orders"][0]
assert set(co["verified"]) == {"03", "06"} and co["pending_depts"] == [] and co["open"] is False
assert out["open_cos"] == []
rows = all_rows(shop.settings, "SELECT dept_code FROM co_verifications ORDER BY dept_code")
assert [r[0] for r in rows] == ["03", "06"]
notes = sorted(
r[0]
for r in all_rows(
shop.settings,
"SELECT note FROM packet_log WHERE packet_id = %s AND action LIKE 'Change order CO-1 verified by %%'",
(pkt["id"],),
)
)
# one of them landed first and still waited on the other; the second fully verified it
assert len(notes) == 2
assert any(n.endswith("change order fully verified") for n in notes)
assert any("still waiting on" in n for n in notes)
# the same department twice at once: one lands, one is told it is done
pkt2 = issue_co(shop, out, ["03"])
no = pkt2["change_orders"][-1]["co_no"]
def again(c: TestClient) -> Callable[[], Any]:
return lambda: c.post(
f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json={"dept": "03", "note": "x", "sign": SIGN}
)
a, b = together([again(shop.fresh("sup.wes")), again(shop.fresh("sup.wes"))])
assert sorted([a.status_code, b.status_code]) == [200, 409], (a.text, b.text)
loser = a if a.status_code == 409 else b
assert loser.json()["code"] == "already_verified"
def test_checklist_keys_merge(shop: Shop) -> None:
pkt = in_progress(shop)
andy, wes = shop.fresh("weld.andy"), shop.fresh("sup.wes")
url = f"/api/packets/{pkt['id']}/stage/checklist"
pairs = [(andy, "dwg"), (wes, "heat"), (andy, "wps"), (wes, "fit")]
answers = together([(lambda c=c, k=k: c.put(f"{url}/{k}", json={"done": True})) for c, k in pairs])
assert [r.status_code for r in answers] == [200] * 4, [r.text for r in answers]
ck = fresh(shop("sup.wes"), pkt["id"])["stage"]["checklist"]
assert {k for k, v in ck.items() if v.get("done")} == {"dwg", "heat", "wps", "fit"}
assert ck["dwg"]["by"] == shop.uid("weld.andy") and ck["heat"]["by"] == shop.uid("sup.wes")
def test_stale_expect_is_refused(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
andy_view = fresh(shop("weld.andy"), pkt["id"])
ok(step(shop("sup.wes"), pkt, "pull_back"))
r = refused(step(shop("weld.andy"), andy_view, "submit", sign=SIGN), 409, "stale_step", STALE)
assert r["current"]["status"] == "ready" and r["current"]["dept"] == "03"
assert r["current"]["version"] == fresh(shop("sup.wes"), pkt["id"])["version"]
# pull back and start at once: one of them is stale
pkt = to_station(shop, fresh(shop("sup.wes"), pkt["id"]))
last = all_rows(
shop.settings, "SELECT coalesce(max(id), 0) FROM packet_transitions WHERE packet_id = %s", (pkt["id"],)
)
wes, andy = shop.fresh("sup.wes"), shop.fresh("weld.andy")
a, b = together([lambda: step(wes, pkt, "unassign"), lambda: step(andy, pkt, "start")])
assert sorted([a.status_code, b.status_code]) == [200, 409], (a.text, b.text)
loser = a if a.status_code == 409 else b
assert loser.json()["code"] == "stale_step" and loser.json()["detail"] == STALE
n = all_rows(
shop.settings,
"SELECT count(*) FROM packet_transitions WHERE packet_id = %s AND id > %s",
(pkt["id"], last[0][0]),
)[0][0]
assert n == 1
def test_filed_in_the_meantime_is_refused(shop: Shop) -> None:
pkt = in_progress(shop)
pkt = issue_co(shop, pkt, ["03"])
seen = fresh(shop("sup.wes"), pkt["id"])
run(shop.settings, "UPDATE packets SET status = 'filed', version = version + 1 WHERE id = %s", (pkt["id"],))
msg = "This packet is filed and locked. IT can reopen it if a correction is needed."
refused(verify(shop, seen, 1, "03", "sup.wes"), 403, "filed", msg)
refused(
shop("clerk.clara").patch(f"/api/packets/{pkt['id']}", json={"set": {"drawing": "D-9"}, "base": {}}),
403,
"filed",
msg,
)
refused(verify(shop, seen, 1, "03", "admin.ann", it_reason="Ticket 5502: verify after filing"), 403, "filed", msg)
# a shop edit is not open on a filed packet either
r = shop("weld.andy").post(
f"/api/packets/{pkt['id']}/welds", json={"page": 1, "fx": 0.5, "fy": 0.5, "items": [1], "type": "Fillet"}
)
assert r.status_code == 403 and r.json()["code"] in ("filed", "wrong_status"), r.text
r = step(shop("weld.andy"), seen, "submit", sign=SIGN)
assert r.status_code == 409 and r.json()["code"] == "stale_step", r.text