Files
PacketStation/tests/server/test_projects_api.py
T

394 lines
20 KiB
Python
Raw Normal View History

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