247 lines
10 KiB
Python
247 lines
10 KiB
Python
"""Helpers for the packet API tests: projects and units by SQL, hand drafts, PDFs, transitions, the happy path."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import io
|
||
|
|
import secrets
|
||
|
|
from collections.abc import Iterator
|
||
|
|
from contextlib import contextmanager
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
|
||
|
|
from psycopg.types.json import Jsonb
|
||
|
|
|
||
|
|
from packetstation.domain import checklists
|
||
|
|
from packetstation.settings import Settings
|
||
|
|
|
||
|
|
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
|
||
|
|
USERS = ("admin.ann", "clerk.clara", "wh.wade", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes", "dup.dave")
|
||
|
|
|
||
|
|
|
||
|
|
@contextmanager
|
||
|
|
def phase2_client(settings: Settings, fake_dir: FakeDirectory) -> Iterator[TestClient]:
|
||
|
|
from packetstation.main import create_app
|
||
|
|
|
||
|
|
app = create_app(settings, directory=fake_dir) # main.py registers every phase 2 router
|
||
|
|
with TestClient(app, headers=ORIGIN_HEADERS) as client:
|
||
|
|
yield client
|
||
|
|
|
||
|
|
|
||
|
|
def seed_users(client: TestClient, users: tuple[str, ...] = USERS) -> None:
|
||
|
|
"""Sign each test user in once so their users rows exist."""
|
||
|
|
for sam in users:
|
||
|
|
assert sign_in(client, sam).status_code == 200, sam
|
||
|
|
|
||
|
|
|
||
|
|
def as_user(client: TestClient, sam: str) -> None:
|
||
|
|
r = sign_in(client, sam)
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
|
||
|
|
|
||
|
|
def user_id(conn: Any, sam: str) -> str:
|
||
|
|
row = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()
|
||
|
|
assert row is not None, sam
|
||
|
|
return str(row[0])
|
||
|
|
|
||
|
|
|
||
|
|
def station_id(conn: Any, dept: str = "03", name: str = "Stall 1") -> int:
|
||
|
|
row = conn.execute("SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name)).fetchone()
|
||
|
|
assert row is not None
|
||
|
|
return int(row[0])
|
||
|
|
|
||
|
|
|
||
|
|
def make_unit(conn: Any, *, number: str | None = None, lot: str = "001", status: str = "open") -> tuple[int, int]:
|
||
|
|
"""A project and one unit by SQL. Returns (project_id, unit_id)."""
|
||
|
|
number = number or "L9" + secrets.token_hex(3).upper()
|
||
|
|
pid = conn.execute(
|
||
|
|
"INSERT INTO projects (number, name, customer, po) VALUES (%s, %s, %s, %s) RETURNING id",
|
||
|
|
(number, "Test project", "Acme Refining", "PO-77"),
|
||
|
|
).fetchone()[0]
|
||
|
|
uid = conn.execute(
|
||
|
|
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, %s, %s::project_status) RETURNING id",
|
||
|
|
(pid, lot, "Unit one", status),
|
||
|
|
).fetchone()[0]
|
||
|
|
return int(pid), int(uid)
|
||
|
|
|
||
|
|
|
||
|
|
def add_unit(conn: Any, project_id: int, lot: str, status: str = "open") -> int:
|
||
|
|
return int(
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO units (project_id, lot, status) VALUES (%s, %s, %s::project_status) RETURNING id",
|
||
|
|
(project_id, lot, status),
|
||
|
|
).fetchone()[0]
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def draft_body(unit_id: int, **over: Any) -> dict[str, Any]:
|
||
|
|
body: dict[str, Any] = {
|
||
|
|
"unit_id": unit_id,
|
||
|
|
"dept_code": "03",
|
||
|
|
"op": "WELD-PART",
|
||
|
|
"title": "Spool 4 inlet",
|
||
|
|
"customer": "Acme Refining",
|
||
|
|
"drawing": "D-1001",
|
||
|
|
"rev": "B",
|
||
|
|
"wps": "WPS-11",
|
||
|
|
"work_orders": [
|
||
|
|
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 1.5},
|
||
|
|
],
|
||
|
|
"bom": [],
|
||
|
|
}
|
||
|
|
body.update(over)
|
||
|
|
return body
|
||
|
|
|
||
|
|
|
||
|
|
def make_draft(client: TestClient, unit_id: int, **over: Any) -> dict[str, Any]:
|
||
|
|
"""A hand draft through the API as whoever is signed in (Clerical). Returns the read model."""
|
||
|
|
r = client.post("/api/packets", json=draft_body(unit_id, **over))
|
||
|
|
assert r.status_code == 201, r.text
|
||
|
|
out: dict[str, Any] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def tiny_pdf(pages: int = 1) -> bytes:
|
||
|
|
from pypdf import PdfWriter
|
||
|
|
|
||
|
|
w = PdfWriter()
|
||
|
|
for _ in range(pages):
|
||
|
|
w.add_blank_page(width=612, height=792)
|
||
|
|
buf = io.BytesIO()
|
||
|
|
w.write(buf)
|
||
|
|
return buf.getvalue()
|
||
|
|
|
||
|
|
|
||
|
|
def upload(client: TestClient, data: bytes, name: str = "drawing.pdf", mime: str = "application/pdf") -> Any:
|
||
|
|
return client.post("/api/assets", files={"file": (name, data, mime)})
|
||
|
|
|
||
|
|
|
||
|
|
def attach_drawing(client: TestClient, packet_id: int, pages: int = 1) -> dict[str, Any]:
|
||
|
|
a = upload(client, tiny_pdf(pages))
|
||
|
|
assert a.status_code == 201, a.text
|
||
|
|
r = client.post(
|
||
|
|
f"/api/packets/{packet_id}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
|
||
|
|
)
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
out: dict[str, Any] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def set_header(client: TestClient, packet_id: int, **fields: Any) -> Any:
|
||
|
|
return client.patch(f"/api/packets/{packet_id}", json={"set": fields, "base": {}})
|
||
|
|
|
||
|
|
|
||
|
|
def ready_draft(client: TestClient, unit_id: int, **over: Any) -> dict[str, Any]:
|
||
|
|
"""A hand draft with the heat map answered and a drawing attached: it can leave Clerical."""
|
||
|
|
pkt = make_draft(client, unit_id, **over)
|
||
|
|
assert set_header(client, pkt["id"], heat_map=True).status_code == 200
|
||
|
|
return attach_drawing(client, pkt["id"])
|
||
|
|
|
||
|
|
|
||
|
|
def step(client: TestClient, pkt: dict[str, Any], action: str, **fields: Any) -> Any:
|
||
|
|
"""POST a transition with the expect taken from the packet answer."""
|
||
|
|
body = {"action": action, "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}, **fields}
|
||
|
|
return client.post(f"/api/packets/{pkt['id']}/transitions", json=body)
|
||
|
|
|
||
|
|
|
||
|
|
def ok_step(client: TestClient, pkt: dict[str, Any], action: str, **fields: Any) -> dict[str, Any]:
|
||
|
|
r = step(client, pkt, action, **fields)
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
out: dict[str, Any] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def get(client: TestClient, packet_id: int) -> dict[str, Any]:
|
||
|
|
r = client.get(f"/api/packets/{packet_id}")
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
out: dict[str, Any] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def finish_stage_by_sql(settings: Settings, packet_id: int) -> None:
|
||
|
|
"""The checklist, filler and one weld, which the child endpoints (built later) would write."""
|
||
|
|
keys = checklists.checklist_keys(True)
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE packet_stages SET checklist = %s, filler = 'ER70S-6' WHERE packet_id = %s AND closed_at IS NULL",
|
||
|
|
(Jsonb({k: {"done": True, "stamp": "A7"} for k in keys}), packet_id),
|
||
|
|
)
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO welds (packet_id, weld_no, items, type, size) VALUES (%s, 1, '{1}', 'Fillet', '1/4')",
|
||
|
|
(packet_id,),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def finish_qc_by_sql(settings: Settings, packet_id: int) -> None:
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE qc_inspections SET checks = %s, weld_exam = %s, inspector = 'Quinn QC'"
|
||
|
|
" WHERE packet_id = %s AND closed_at IS NULL",
|
||
|
|
(Jsonb({k: {"done": True} for k in checklists.qc_keys()}), Jsonb({"1": {"result": "accept"}}), packet_id),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def happy_path(client: TestClient, settings: Settings, unit_id: int) -> dict[str, Any]:
|
||
|
|
"""Draft -> warehouse -> ready -> assigned -> in progress -> review -> Q.C. -> complete (data book) -> filed."""
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
stall = station_id(conn)
|
||
|
|
andy = user_id(conn, "weld.andy")
|
||
|
|
as_user(client, "clerk.clara")
|
||
|
|
pkt = ready_draft(client, unit_id)
|
||
|
|
pkt = ok_step(client, pkt, "send_to_warehouse")
|
||
|
|
assert pkt["status"] == "warehouse" and pkt["wh"]["stage"] == "incoming"
|
||
|
|
|
||
|
|
as_user(client, "wh.wade")
|
||
|
|
pkt = ok_step(client, pkt, "wh_release")
|
||
|
|
assert pkt["status"] == "ready" and pkt["stage"]["dept_code"] == "03" and pkt["wh"] is None
|
||
|
|
|
||
|
|
as_user(client, "lead.lou")
|
||
|
|
r = step(client, pkt, "return_to_clerical", reason="Wrong department for this one")
|
||
|
|
assert r.status_code == 403 and r.json()["code"] == "wrong_status", r.text
|
||
|
|
|
||
|
|
as_user(client, "sup.wes")
|
||
|
|
pkt = ok_step(client, pkt, "assign", station_id=stall, welder_id=andy)
|
||
|
|
assert pkt["status"] == "assigned" and pkt["station"]["name"] == "Stall 1"
|
||
|
|
assert pkt["welder"]["id"] == andy and pkt["stage"]["welder"]["id"] == andy
|
||
|
|
|
||
|
|
as_user(client, "dup.dave")
|
||
|
|
r = step(client, pkt, "start")
|
||
|
|
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
|
||
|
|
|
||
|
|
as_user(client, "weld.andy")
|
||
|
|
pkt = ok_step(client, pkt, "start")
|
||
|
|
assert pkt["status"] == "in_progress" and pkt["stage"]["clock"]["running_since"] is not None
|
||
|
|
r = step(client, pkt, "submit", sign=SIGN)
|
||
|
|
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||
|
|
finish_stage_by_sql(settings, pkt["id"])
|
||
|
|
r = step(client, pkt, "submit")
|
||
|
|
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
||
|
|
pkt = ok_step(client, pkt, "submit", sign=SIGN)
|
||
|
|
assert pkt["status"] == "review" and pkt["stage"]["clock"]["running_since"] is None
|
||
|
|
|
||
|
|
as_user(client, "sup.wes")
|
||
|
|
r = step(client, pkt, "send_to_qc", book=True, spool=True, sign=SIGN)
|
||
|
|
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||
|
|
pkt = ok_step(client, pkt, "send_to_qc", book=True, spool=True, location="Bay 3 rack B", sign=SIGN)
|
||
|
|
assert pkt["status"] == "qc" and pkt["qc"]["location"] == "Bay 3 rack B" and pkt["stage"] is not None
|
||
|
|
|
||
|
|
as_user(client, "qc.quinn")
|
||
|
|
r = step(client, pkt, "qc_accept", routed_to="databook", sign=SIGN)
|
||
|
|
assert r.status_code == 403 and r.json()["code"] == "precondition", r.text
|
||
|
|
assert "Q.C. still needs" in r.json()["detail"]
|
||
|
|
finish_qc_by_sql(settings, pkt["id"])
|
||
|
|
pkt = ok_step(client, pkt, "qc_accept", routed_to="databook", sign=SIGN)
|
||
|
|
assert pkt["status"] == "complete" and pkt["routed_to"] == "databook" and pkt["qc"] is None
|
||
|
|
assert pkt["stage"] is None and len(pkt["stages"]) == 1 and pkt["qc_history"][0]["result"] == "accepted"
|
||
|
|
|
||
|
|
as_user(client, "clerk.clara")
|
||
|
|
r = step(client, pkt, "file", sign=SIGN)
|
||
|
|
assert r.status_code == 403 and r.json()["code"] == "precondition"
|
||
|
|
assert r.json()["detail"] == "Close the job in VISUAL first."
|
||
|
|
r = client.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
|
||
|
|
assert r.status_code == 200 and r.json()["vclosed"]["by"]["display_name"] == "Clara Clerk", r.text
|
||
|
|
pkt = ok_step(client, r.json(), "file", sign=SIGN)
|
||
|
|
assert pkt["status"] == "filed" and pkt["seal"]["broken_at"] is None and len(pkt["seal"]["key_id"]) == 8
|
||
|
|
return pkt
|