394 lines
20 KiB
Python
394 lines
20 KiB
Python
"""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
|