"""Projects and units API: numbers, lots, aliases, renumbering, field CAS, soft delete, counts and access.""" from __future__ import annotations from collections.abc import Iterator from typing import Any import pytest from fakedir import FakeDirectory from fastapi.testclient import TestClient from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in from packetstation.api import projects, units from packetstation.domain.projects import normalize_lot from packetstation.settings import Settings pytestmark = needs_pg IT = "Ticket 4411: fixing a typo for Clerical" @pytest.fixture def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]: from packetstation.main import create_app app = create_app(pg_settings, directory=fake_dir) app.include_router(projects.router) app.include_router(units.router) # the static catch-all must stay last, or it answers the GET routes added here app.router.routes.sort(key=lambda r: getattr(r, "path", "") == "/{path:path}") with TestClient(app, headers=ORIGIN_HEADERS) as c: yield c def as_user(client: TestClient, sam: str) -> None: client.post("/api/auth/signout") r = sign_in(client, sam) assert r.status_code == 200, r.text def audit_rows(settings: Settings, prefix: str) -> list[dict[str, Any]]: from psycopg.rows import dict_row with app_conn(settings) as conn, conn.cursor(row_factory=dict_row) as cur: return cur.execute( "SELECT action, subject_type, subject_id, it_reason, detail FROM audit_events WHERE action LIKE %s" " ORDER BY id", (prefix + "%",), ).fetchall() def add_packet(settings: Settings, unit_id: int, status: str = "draft") -> int: with app_conn(settings) as conn: return int( conn.execute( "INSERT INTO packets (unit_id, dept_code, status) VALUES (%s, '03', %s) RETURNING id", (unit_id, status) ).fetchone()[0] ) def make_project(client: TestClient, number: str = "L670", **extra: Any) -> dict[str, Any]: r = client.post("/api/projects", json={"number": number, "name": "Separator skids", **extra}) assert r.status_code == 201, r.text out: dict[str, Any] = r.json() return out def add_units(client: TestClient, project_id: int, **body: Any) -> list[dict[str, Any]]: r = client.post(f"/api/projects/{project_id}/units", json=body) assert r.status_code == 201, r.text out: list[dict[str, Any]] = r.json() return out def test_normalize_lot() -> None: assert normalize_lot("7") == "007" assert normalize_lot("7a") == "7A" assert normalize_lot(" 12 ") == "012" assert normalize_lot("A") == "A" def test_create_project_rules(client: TestClient, pg_settings: Settings) -> None: as_user(client, "clerk.clara") r = client.post( "/api/projects", json={"number": " l670 ", "name": "Separator skids", "customer": "Acme", "po": "PO-1", "due": "2026-12-01"}, ) assert r.status_code == 201, r.text p = r.json() assert p["number"] == "L670" and p["status"] == "open" and p["due"] == "2026-12-01" and p["version"] == 1 assert p["units"] == 0 and p["packets"]["total"] == 0 and p["source"] == "hand" rows = audit_rows(pg_settings, "project.") assert rows[-1]["action"] == "project.created" and rows[-1]["subject_type"] == "project" assert rows[-1]["subject_id"] == str(p["id"]) and rows[-1]["it_reason"] is None r = client.post("/api/projects", json={"number": "L670"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" r = client.post("/api/projects", json={"number": "L6 70!"}) assert r.status_code == 422 and r.json()["code"] == "invalid_number" r = client.post("/api/projects", json={"number": "L680", "due": "12/01/2026"}) assert r.status_code == 422 and r.json()["code"] == "invalid_date" as_user(client, "wh.wade") r = client.post("/api/projects", json={"number": "L680"}) assert r.status_code == 403 and r.json()["code"] == "forbidden" as_user(client, "admin.ann") r = client.post("/api/projects", json={"number": "L680"}) assert r.status_code == 400 and r.json()["code"] == "it_reason_required" r = client.post("/api/projects", json={"number": "L680", "it_reason": "short"}) assert r.status_code == 400 and r.json()["code"] == "it_reason_required" r = client.post("/api/projects", json={"number": "L680", "it_reason": IT}) assert r.status_code == 201, r.text last = audit_rows(pg_settings, "project.")[-1] assert last["action"] == "project.created" and last["it_reason"] == IT def test_add_units(client: TestClient, pg_settings: Settings) -> None: as_user(client, "clerk.clara") p = make_project(client) one = add_units(client, p["id"], lot="1", name="Skid A") assert [(u["lot"], u["number"], u["name"]) for u in one] == [("001", "L670001", "Skid A")] several = add_units(client, p["id"], count=3, start="005", name="Skid B") assert [u["number"] for u in several] == ["L670005", "L670006", "L670007"] assert add_units(client, p["id"], lot="7a")[0]["lot"] == "7A" # a full unit number is cut to its lot; with nothing given the next free lot is used assert add_units(client, p["id"], lot="L670012")[0]["lot"] == "012" assert add_units(client, p["id"])[0]["lot"] == "013" assert [u["lot"] for u in add_units(client, p["id"], lots=["20", "21"])] == ["020", "021"] r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "0001"}) assert r.status_code == 422 and r.json()["code"] == "invalid_lot" r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "001"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" r = client.post(f"/api/projects/{p['id']}/units", json={"count": 3, "start": "004"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" r = client.post(f"/api/projects/{p['id']}/units", json={"count": 3, "start": "998"}) assert r.status_code == 422 r = client.post(f"/api/projects/{p['id']}/units", json={"count": 0, "start": "100"}) assert r.status_code == 422 and r.json()["code"] == "invalid_count" created = [r for r in audit_rows(pg_settings, "unit.") if r["action"] == "unit.created"] assert len(created) == 9 and created[0]["subject_type"] == "unit" and created[0]["detail"]["number"] == "L670001" detail = client.get(f"/api/projects/{p['id']}").json() assert detail["next_lot"] == "022" and len(detail["units"]) == 9 def test_list_counts_and_search(client: TestClient, pg_settings: Settings) -> None: as_user(client, "clerk.clara") p = make_project(client, customer="Acme Gas", po="PO-77") make_project(client, "L700", name="Heater treaters") u1, u2 = add_units(client, p["id"], count=2, start="001") for status in ("draft", "ready", "in_progress", "qc", "filed"): add_packet(pg_settings, u1["id"], status) add_packet(pg_settings, u2["id"], "warehouse") gone = add_packet(pg_settings, u2["id"], "complete") with app_conn(pg_settings) as conn: conn.execute("UPDATE packets SET removed_at = now() WHERE id = %s", (gone,)) listing = {x["number"]: x for x in client.get("/api/projects").json()} assert set(listing) == {"L670", "L700"} assert listing["L670"]["units"] == 2 assert listing["L670"]["packets"] == { "draft": 1, "warehouse": 1, "shop": 2, "qc": 1, "complete": 0, "filed": 1, "total": 6, } detail = client.get(f"/api/projects/{p['id']}").json() by_lot = {u["lot"]: u for u in detail["units"]} assert by_lot["001"]["packets"]["total"] == 5 and by_lot["002"]["packets"]["total"] == 1 assert detail["aliases"] == [] def numbers(**params: str) -> list[str]: return [x["number"] for x in client.get("/api/projects", params=params).json()] assert numbers(q="separator") == ["L670"] assert numbers(q="acme po-77") == ["L670"] assert numbers(q="heater") == ["L700"] assert numbers(q="acme heater") == [] assert client.get("/api/projects", params={"status": "bogus"}).status_code == 422 picker = client.get("/api/units", params={"project_id": p["id"]}).json() assert [u["number"] for u in picker] == ["L670001", "L670002"] assert [u["number"] for u in client.get("/api/units", params={"q": "L670002"}).json()] == ["L670002"] one = client.get(f"/api/units/{u1['id']}").json() assert one["project"]["number"] == "L670" and one["packets"]["shop"] == 2 and one["due_effective"] is None def test_patch_field_cas(client: TestClient, pg_settings: Settings) -> None: as_user(client, "clerk.clara") p = make_project(client, customer="Acme") r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Gas"}, "base": {"customer": "Acme"}}) assert r.status_code == 200, r.text assert r.json()["customer"] == "Acme Gas" and r.json()["version"] == 2 # a stale base conflicts and returns the current value r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Oil"}, "base": {"customer": "Acme"}}) assert r.status_code == 409 and r.json()["code"] == "field_conflict" assert r.json()["current"] == {"customer": "Acme Gas"} # no base: mine wins; a value equal to the current one is not a conflict r = client.patch(f"/api/projects/{p['id']}", json={"set": {"due": "2027-01-15", "status": "hold"}}) assert r.status_code == 200 and r.json()["due"] == "2027-01-15" and r.json()["status"] == "hold" r = client.patch(f"/api/projects/{p['id']}", json={"set": {"customer": "Acme Gas"}, "base": {"customer": "x"}}) assert r.status_code == 200 and r.json()["version"] == 3 r = client.patch(f"/api/projects/{p['id']}", json={"set": {"number": "L999"}}) assert r.status_code == 422 and r.json()["code"] == "invalid_field" r = client.patch(f"/api/projects/{p['id']}", json={"set": {"status": "done"}}) assert r.status_code == 422 and r.json()["code"] == "invalid_status" updated = [x for x in audit_rows(pg_settings, "project.") if x["action"] == "project.updated"] assert len(updated) == 2 and updated[0]["detail"]["fields"]["customer"] == {"from": "Acme", "to": "Acme Gas"} (u,) = add_units(client, p["id"], lot="1") r = client.patch( f"/api/units/{u['id']}", json={"set": {"name": "Skid A", "due": "2027-02-01"}, "base": {"name": None}} ) assert r.status_code == 200 and r.json()["name"] == "Skid A" and r.json()["due_effective"] == "2027-02-01" r = client.patch(f"/api/units/{u['id']}", json={"set": {"name": "Skid Z"}, "base": {"name": "Skid Q"}}) assert r.status_code == 409 and r.json()["current"] == {"name": "Skid A"} assert audit_rows(pg_settings, "unit.updated")[-1]["subject_id"] == str(u["id"]) def test_renumber_project(client: TestClient, pg_settings: Settings) -> None: as_user(client, "clerk.clara") p = make_project(client) u1, u2 = add_units(client, p["id"], count=2, start="001") pk = add_packet(pg_settings, u1["id"]) r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671", "reason": "Wrong number"}) assert r.status_code == 403 and r.json()["code"] == "forbidden" as_user(client, "clerk.leo") r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671"}) assert r.status_code == 400 and r.json()["code"] == "reason_required" r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "l671", "reason": "Wrong number"}) assert r.status_code == 200, r.text assert r.json()["number"] == "L671" and r.json()["version"] == 2 def look(n: str) -> dict[str, Any]: res = client.get("/api/lookup/number", params={"n": n}) assert res.status_code == 200, (n, res.text) out: dict[str, Any] = res.json() return out assert look("L670") == {"kind": "project", "id": p["id"], "project_id": p["id"], "number": "L671", "alias": True} assert look("L671")["alias"] is False and look("L671")["id"] == p["id"] assert look("l670002") == { "kind": "unit", "id": u2["id"], "project_id": p["id"], "number": "L671002", "alias": True, } assert look("L671001")["id"] == u1["id"] and look("L671001")["alias"] is False assert client.get("/api/lookup/number", params={"n": "L999"}).status_code == 404 with app_conn(pg_settings) as conn: rows = conn.execute( "SELECT unit_id, old_number, new_number, reason FROM project_renumbers ORDER BY id" ).fetchall() assert rows == [ (None, "L670", "L671", "Wrong number"), (u1["id"], "L670001", "L671001", "Wrong number"), (u2["id"], "L670002", "L671002", "Wrong number"), ] # the packet still points at its unit assert conn.execute("SELECT unit_id FROM packets WHERE id = %s", (pk,)).fetchone()[0] == u1["id"] detail = client.get(f"/api/projects/{p['id']}").json() assert [u["number"] for u in detail["units"]] == ["L671001", "L671002"] assert {a["alias"] for a in detail["aliases"]} == {"L670", "L670001", "L670002"} assert [x["number"] for x in client.get("/api/projects", params={"q": "L670"}).json()] == ["L671"] last = audit_rows(pg_settings, "project.renumbered")[-1] assert last["detail"] == {"from": "L670", "to": "L671", "reason": "Wrong number", "units": 2} # old numbers stay taken; renumbering back to its own old number is allowed r = client.post("/api/projects", json={"number": "L670"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" r = client.post("/api/projects", json={"number": "L670001"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" make_project(client, "L800") r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L800", "reason": "Wrong number"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L671", "reason": "Same"}) assert r.status_code == 400 and r.json()["code"] == "same_number" r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L670", "reason": "Back again"}) assert r.status_code == 200 and r.json()["number"] == "L670" assert look("L671")["alias"] is True and look("L670")["alias"] is False as_user(client, "admin.ann") r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L672", "reason": "Fix"}) assert r.status_code == 400 and r.json()["code"] == "it_reason_required" r = client.post(f"/api/projects/{p['id']}/renumber", json={"number": "L672", "reason": "Fix", "it_reason": IT}) assert r.status_code == 200 and audit_rows(pg_settings, "project.renumbered")[-1]["it_reason"] == IT def test_renumber_unit(client: TestClient, pg_settings: Settings) -> None: as_user(client, "clerk.clara") p = make_project(client) u1, _u2 = add_units(client, p["id"], count=2, start="001") r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "10", "reason": "Lot was wrong"}) assert r.status_code == 403 as_user(client, "clerk.leo") r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "2", "reason": "Lot was wrong"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "10", "reason": "Lot was wrong"}) assert r.status_code == 200, r.text assert r.json()["lot"] == "010" and r.json()["number"] == "L670010" and r.json()["version"] == 2 found = client.get("/api/lookup/number", params={"n": "L670001"}).json() assert found == {"kind": "unit", "id": u1["id"], "project_id": p["id"], "number": "L670010", "alias": True} aliases = client.get(f"/api/units/{u1['id']}").json()["aliases"] assert [(a["alias"], a["kind"]) for a in aliases] == [("L670001", "unit_renumber")] # the old lot is taken for a new unit, but the unit itself may go back to it r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "001"}) assert r.status_code == 409 and r.json()["code"] == "number_taken" r = client.post(f"/api/units/{u1['id']}/renumber", json={"lot": "001", "reason": "Back again"}) assert r.status_code == 200 and r.json()["number"] == "L670001" assert audit_rows(pg_settings, "unit.renumbered")[0]["detail"]["to"] == "L670010" def test_delete_units_and_projects(client: TestClient, pg_settings: Settings) -> None: as_user(client, "clerk.clara") p = make_project(client) u1, u2 = add_units(client, p["id"], count=2, start="001") add_packet(pg_settings, u1["id"], "filed") r = client.request("DELETE", f"/api/units/{u2['id']}", json={"reason": "Made by mistake"}) assert r.status_code == 403 as_user(client, "clerk.leo") r = client.request("DELETE", f"/api/units/{u2['id']}", json={}) assert r.status_code == 400 and r.json()["code"] == "reason_required" r = client.request("DELETE", f"/api/units/{u2['id']}", json={"reason": "Made by mistake"}) assert r.status_code == 200, r.text assert r.json()["removed"]["reason"] == "Made by mistake" assert client.get(f"/api/units/{u2['id']}").status_code == 404 r = client.request("DELETE", f"/api/units/{u1['id']}", json={"reason": "Made by mistake"}) assert r.status_code == 409 and r.json()["code"] == "has_packets" and r.json()["packets"] == 1 r = client.request("DELETE", f"/api/projects/{p['id']}", json={"reason": "Made by mistake"}) assert r.status_code == 409 and r.json()["code"] == "has_packets" # the removed unit's lot is free again assert add_units(client, p["id"], lot="2")[0]["number"] == "L670002" q = make_project(client, "L700") v1, v2 = add_units(client, q["id"], count=2, start="001") r = client.request("DELETE", f"/api/projects/{q['id']}", json={"reason": "Duplicate project"}) assert r.status_code == 200 and r.json()["removed"]["reason"] == "Duplicate project" assert client.get(f"/api/projects/{q['id']}").status_code == 404 assert client.get(f"/api/units/{v1['id']}").status_code == 404 assert "L700" not in [x["number"] for x in client.get("/api/projects", params={"status": "all"}).json()] with app_conn(pg_settings) as conn: removed = conn.execute( "SELECT count(*) FROM units WHERE project_id = %s AND removed_at IS NOT NULL", (q["id"],) ).fetchone()[0] assert removed == 2 assert audit_rows(pg_settings, "project.removed")[-1]["detail"]["units"] == [v1["id"], v2["id"]] assert [x["action"] for x in audit_rows(pg_settings, "unit.removed")] == ["unit.removed"] # the number can be used again make_project(client, "L700") def test_closed_project(client: TestClient) -> None: as_user(client, "clerk.clara") p = make_project(client, due="2026-12-01") (u,) = add_units(client, p["id"], lot="1") r = client.patch(f"/api/projects/{p['id']}", json={"set": {"status": "closed"}, "base": {"status": "open"}}) assert r.status_code == 200 r = client.post(f"/api/projects/{p['id']}/units", json={"lot": "2"}) assert r.status_code == 409 and r.json()["code"] == "project_closed" one = client.get(f"/api/units/{u['id']}").json() assert one["status"] == "open" and one["status_effective"] == "closed" and one["due_effective"] == "2026-12-01" assert client.get("/api/units", params={"project_id": p["id"]}).json() == [] closed = client.get("/api/units", params={"project_id": p["id"], "status": "closed"}).json() assert [x["number"] for x in closed] == ["L670001"] assert client.get("/api/projects").json() == [] assert [x["number"] for x in client.get("/api/projects", params={"status": "closed"}).json()] == ["L670"] def test_pm_reads_only(client: TestClient) -> None: as_user(client, "clerk.clara") p = make_project(client) (u,) = add_units(client, p["id"], lot="1") as_user(client, "pm.paula") assert client.get("/api/projects").status_code == 200 assert client.get(f"/api/projects/{p['id']}").status_code == 200 assert client.get(f"/api/units/{u['id']}").status_code == 200 assert client.get("/api/units", params={"project_id": p["id"]}).status_code == 200 assert client.get("/api/lookup/number", params={"n": "L670001"}).status_code == 200 assert client.post("/api/projects", json={"number": "L900"}).status_code == 403 assert client.patch(f"/api/projects/{p['id']}", json={"set": {"name": "x"}}).status_code == 403 assert client.post(f"/api/projects/{p['id']}/units", json={"lot": "2"}).status_code == 403 assert client.patch(f"/api/units/{u['id']}", json={"set": {"name": "x"}}).status_code == 403