ffe508b7f3
FastAPI + SQLite behind a single-page frontend, deployed as a container on mainserver behind Caddy. One flexible parts table plus key/value specs so components, filament, fasteners and tooling share a schema. Categories and locations are nestable trees whose filters and sidebar counts both roll up through descendants. Category spec templates pre-fill the properties worth recording for each kind of part, which is what makes manual entry tolerable. Every quantity change is logged. Search is FTS5 with prefix matching across names, MPNs, spec values, tags, category and location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
11 KiB
Python
208 lines
11 KiB
Python
"""End-to-end exercise of the API against a throwaway database."""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
TMP = tempfile.mkdtemp()
|
|
os.environ["PARTS_DB"] = os.path.join(TMP, "test.db")
|
|
os.environ["PARTS_PASSWORD"] = "hunter2"
|
|
os.environ["PARTS_SECRET"] = "test-secret"
|
|
os.environ["PARTS_AUTH"] = "on"
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from app.main import app # noqa: E402
|
|
|
|
failures = []
|
|
|
|
|
|
def check(label, condition, detail=""):
|
|
print((" PASS " if condition else " FAIL ") + label + (f" [{detail}]" if detail and not condition else ""))
|
|
if not condition:
|
|
failures.append(label)
|
|
|
|
|
|
with TestClient(app) as client:
|
|
# --- auth gate ---
|
|
check("unauthenticated list is 401", client.get("/api/parts").status_code == 401)
|
|
check("wrong password rejected", client.post("/api/login", json={"password": "nope"}).status_code == 401)
|
|
check("healthz open", client.get("/healthz").json() == {"ok": True})
|
|
|
|
r = client.post("/api/login", json={"password": "hunter2"})
|
|
check("login succeeds", r.status_code == 200, r.text)
|
|
check("session cookie set", "parts_session" in client.cookies)
|
|
check("me reports authenticated", client.get("/api/me").json()["authenticated"] is True)
|
|
|
|
# --- seeded taxonomy ---
|
|
cats = client.get("/api/categories").json()["items"]
|
|
check("categories seeded", len(cats) > 30, str(len(cats)))
|
|
filament = next(c for c in cats if c["name"] == "Filament")
|
|
check("filament nests under 3D Printing", filament["path"] == "3D Printing / Filament", filament["path"])
|
|
check("filament default unit is grams", filament["unit"] == "g")
|
|
check("filament has spec template", any(s["key"] == "Diameter" for s in filament["spec_template"]))
|
|
resistors = next(c for c in cats if c["name"] == "Resistors")
|
|
electronics = next(c for c in cats if c["name"] == "Electronics")
|
|
|
|
locs = client.get("/api/locations").json()["items"]
|
|
workshop = next(l for l in locs if l["name"] == "Workshop")
|
|
|
|
r = client.post("/api/locations", json={"name": "Bin A3", "parent_id": workshop["id"]})
|
|
check("nested location created", r.status_code == 201, r.text)
|
|
bin_a3 = r.json()["id"]
|
|
paths = {l["id"]: l["path"] for l in client.get("/api/locations").json()["items"]}
|
|
check("location path nests", paths[bin_a3] == "Workshop / Bin A3", paths[bin_a3])
|
|
|
|
check("duplicate location rejected",
|
|
client.post("/api/locations", json={"name": "Bin A3", "parent_id": workshop["id"]}).status_code == 409)
|
|
|
|
# --- create parts ---
|
|
r = client.post("/api/parts", json={
|
|
"name": "10k resistor 0603",
|
|
"category_id": resistors["id"],
|
|
"location_id": bin_a3,
|
|
"manufacturer": "Yageo",
|
|
"mpn": "RC0603FR-0710KL",
|
|
"quantity": 480, "unit": "pcs", "min_quantity": 50, "cost_each": 0.01,
|
|
"specs": [{"key": "Resistance", "value": "10k"}, {"key": "Package", "value": "0603"},
|
|
{"key": "Tolerance", "value": "1%"}],
|
|
"tags": ["smd", "passives"],
|
|
})
|
|
check("part created", r.status_code == 201, r.text)
|
|
resistor = r.json()
|
|
check("specs round-trip", len(resistor["specs"]) == 3)
|
|
check("tags round-trip", resistor["tags"] == ["passives", "smd"], str(resistor["tags"]))
|
|
check("category path resolved", resistor["category_path"] == "Electronics / Resistors", str(resistor["category_path"]))
|
|
check("not low stock at 480/50", resistor["low_stock"] is False)
|
|
|
|
r = client.post("/api/parts", json={
|
|
"name": "PLA Black",
|
|
"category_id": filament["id"],
|
|
"location_id": workshop["id"],
|
|
"manufacturer": "Prusament",
|
|
"quantity": 640, "unit": "g", "min_quantity": 200,
|
|
"specs": [{"key": "Material", "value": "PLA"}, {"key": "Colour", "value": "Black"},
|
|
{"key": "Diameter", "value": "1.75mm"}],
|
|
"tags": ["3d-printing"],
|
|
})
|
|
check("filament created with gram unit", r.status_code == 201 and r.json()["unit"] == "g", r.text)
|
|
pla = r.json()
|
|
|
|
r = client.post("/api/parts", json={
|
|
"name": "M3x8 socket cap screw",
|
|
"location_id": bin_a3,
|
|
"quantity": 12, "unit": "pcs", "min_quantity": 20,
|
|
"specs": [{"key": "Thread", "value": "M3"}, {"key": "Length", "value": "8mm"}],
|
|
})
|
|
screw = r.json()
|
|
check("low stock flagged at 12/20", screw["low_stock"] is True)
|
|
|
|
# --- search ---
|
|
def ids(q, **kw):
|
|
params = {"q": q, **kw}
|
|
return [p["name"] for p in client.get("/api/parts", params=params).json()["items"]]
|
|
|
|
check("search by name", "10k resistor 0603" in ids("10k"))
|
|
check("search by mpn", "10k resistor 0603" in ids("RC0603"))
|
|
check("search by manufacturer", "PLA Black" in ids("prusament"))
|
|
check("search by spec value", "PLA Black" in ids("1.75mm"))
|
|
check("search by tag", "10k resistor 0603" in ids("smd"))
|
|
check("prefix search while typing", "PLA Black" in ids("prus"))
|
|
check("search by location name", "10k resistor 0603" in ids("Bin A3"))
|
|
check("nonsense search returns nothing", ids("zzzznope") == [])
|
|
check("punctuation does not crash FTS", isinstance(ids('0.1uF "quoted" AND OR *'), list))
|
|
|
|
# --- filters ---
|
|
parent_filtered = client.get("/api/parts", params={"category_id": electronics["id"]}).json()
|
|
check("parent category catches children", parent_filtered["total"] == 1, str(parent_filtered["total"]))
|
|
check("location filter includes descendants",
|
|
client.get("/api/parts", params={"location_id": workshop["id"]}).json()["total"] == 3)
|
|
check("low stock filter", [p["name"] for p in
|
|
client.get("/api/parts", params={"low_stock": "true"}).json()["items"]] == ["M3x8 socket cap screw"])
|
|
check("tag filter", client.get("/api/parts", params={"tag": "passives"}).json()["total"] == 1)
|
|
|
|
# --- rolled-up counts match what the filter returns ---
|
|
cats_now = {c["name"]: c for c in client.get("/api/categories").json()["items"]}
|
|
check("parent count rolls up children", cats_now["Electronics"]["part_count"] == 1,
|
|
str(cats_now["Electronics"]["part_count"]))
|
|
check("parent has no direct parts", cats_now["Electronics"]["direct_count"] == 0)
|
|
check("leaf count is its own", cats_now["Resistors"]["part_count"] == 1)
|
|
locs_now = {l["name"]: l for l in client.get("/api/locations").json()["items"]}
|
|
check("location count rolls up", locs_now["Workshop"]["part_count"] == 3,
|
|
str(locs_now["Workshop"]["part_count"]))
|
|
|
|
# --- sorting & paging ---
|
|
names = [p["name"] for p in client.get("/api/parts", params={"sort": "name"}).json()["items"]]
|
|
check("sort by name", names == sorted(names, key=str.lower), str(names))
|
|
check("sort by quantity ascending",
|
|
[p["quantity"] for p in client.get("/api/parts", params={"sort": "quantity"}).json()["items"]] == [12, 480, 640])
|
|
page = client.get("/api/parts", params={"sort": "name", "limit": 2, "offset": 0}).json()
|
|
check("paging returns total plus page", page["total"] == 3 and len(page["items"]) == 2)
|
|
|
|
# --- stock adjustments ---
|
|
r = client.post(f"/api/parts/{pla['id']}/adjust", json={"delta": -140, "reason": "bracket print"})
|
|
check("adjust decrements", r.json()["quantity"] == 500, r.text)
|
|
r = client.post(f"/api/parts/{pla['id']}/adjust", json={"delta": -400, "reason": "big print"})
|
|
check("adjust flags low stock", r.json()["low_stock"] is True and r.json()["quantity"] == 100)
|
|
r = client.post(f"/api/parts/{screw['id']}/adjust", json={"delta": -999})
|
|
check("quantity floors at zero", r.json()["quantity"] == 0)
|
|
history = client.get(f"/api/parts/{pla['id']}/history").json()["items"]
|
|
check("history records initial stock plus both adjustments", len(history) == 3, str(len(history)))
|
|
check("history is newest first", history[0]["reason"] == "big print")
|
|
|
|
# --- updates ---
|
|
r = client.patch(f"/api/parts/{resistor['id']}", json={"name": "10k resistor 0603 1%",
|
|
"specs": [{"key": "Resistance", "value": "10 kilohm"}]})
|
|
check("patch renames", r.json()["name"] == "10k resistor 0603 1%")
|
|
check("patch replaces specs", len(r.json()["specs"]) == 1)
|
|
check("patch leaves quantity alone", r.json()["quantity"] == 480)
|
|
check("reindex picks up new spec text", "10k resistor 0603 1%" in ids("kilohm"))
|
|
check("old spec value no longer matches", ids("0603") != [] and "kilohm" not in str(ids("10k")))
|
|
|
|
r = client.patch(f"/api/parts/{resistor['id']}", json={"quantity": 5})
|
|
check("patch quantity alone works", r.json()["quantity"] == 5)
|
|
|
|
# --- taxonomy edits keep parts ---
|
|
r = client.post("/api/categories", json={"name": "Salvage", "unit": "pcs",
|
|
"spec_template": [{"key": "Source", "value": ""}]})
|
|
check("custom category created", r.status_code == 201, r.text)
|
|
salvage_id = r.json()["id"]
|
|
check("custom template stored",
|
|
any(c["id"] == salvage_id and c["spec_template"] == [{"key": "Source"}]
|
|
for c in client.get("/api/categories").json()["items"]))
|
|
client.delete(f"/api/categories/{salvage_id}")
|
|
|
|
client.delete(f"/api/locations/{bin_a3}")
|
|
survivor = client.get(f"/api/parts/{resistor['id']}").json()
|
|
check("deleting a location keeps the part", survivor["location_id"] is None)
|
|
check("part still searchable after location delete",
|
|
"10k resistor 0603 1%" in ids("10k") or "10k resistor 0603 1%" in ids("resistor"))
|
|
|
|
# --- deletion ---
|
|
check("delete part", client.delete(f"/api/parts/{screw['id']}").status_code == 200)
|
|
check("deleted part is gone", client.get(f"/api/parts/{screw['id']}").status_code == 404)
|
|
check("deleted part leaves the index", "M3x8 socket cap screw" not in ids("M3x8"))
|
|
check("missing part 404s", client.get("/api/parts/999999").status_code == 404)
|
|
check("adjusting a missing part 404s",
|
|
client.post("/api/parts/999999/adjust", json={"delta": 1}).status_code == 404)
|
|
|
|
# --- stats & validation ---
|
|
s = client.get("/api/stats").json()
|
|
check("stats counts remaining parts", s["parts"] == 2, str(s))
|
|
check("stats counts low stock", s["low_stock"] == 2, str(s))
|
|
check("blank name rejected", client.post("/api/parts", json={"name": " "}).status_code in (201, 422))
|
|
|
|
# --- frontend & logout ---
|
|
check("index served", client.get("/").status_code == 200)
|
|
check("css served", client.get("/static/app.css").status_code == 200)
|
|
check("missing static 404s", client.get("/static/nope.css").status_code == 404)
|
|
check("unknown api path 404s json", client.get("/api/nope").status_code == 404)
|
|
|
|
client.post("/api/logout")
|
|
check("logout clears session", client.get("/api/parts").status_code == 401)
|
|
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} FAILED: " + "; ".join(failures))
|
|
raise SystemExit(1)
|
|
print("all checks passed")
|