Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
254 lines
9.0 KiB
JavaScript
254 lines
9.0 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import request from "supertest";
|
|
import { setup, signup } from "./helpers.js";
|
|
|
|
async function bootstrapPlan(agent, csrf) {
|
|
const r = await agent
|
|
.post("/api/plans")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ plan: { fields: { 0.1: "Test coffee" } } });
|
|
return r.body.plan.id;
|
|
}
|
|
|
|
test("inventory: auth and CSRF are required on every route", async () => {
|
|
const { app } = await setup();
|
|
const anon = request.agent(app);
|
|
assert.equal((await anon.get("/api/inventory")).status, 401);
|
|
assert.equal((await anon.post("/api/inventory").send({})).status, 401);
|
|
const { agent, csrf } = await (async () => {
|
|
const a = request.agent(app);
|
|
const { csrf: c } = await signup(a, "[email protected]");
|
|
return { agent: a, csrf: c };
|
|
})();
|
|
assert.equal(
|
|
(await agent.post("/api/inventory").send({ origin: "X", initialWeightG: 100 })).status,
|
|
403,
|
|
);
|
|
const created = await agent
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ origin: "Huila", initialWeightG: 1000 });
|
|
assert.equal(created.status, 201);
|
|
assert.equal(
|
|
(await agent.put(`/api/inventory/${created.body.lot.id}`).send({ origin: "Y" })).status,
|
|
403,
|
|
);
|
|
assert.equal(
|
|
(await agent.delete(`/api/inventory/${created.body.lot.id}`)).status,
|
|
403,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await agent
|
|
.post(`/api/inventory/${created.body.lot.id}/consume`)
|
|
.send({ weightG: 10 })
|
|
).status,
|
|
403,
|
|
);
|
|
});
|
|
|
|
test("inventory: create, list, and ownership isolation", async () => {
|
|
const { app } = await setup();
|
|
const first = request.agent(app);
|
|
const second = request.agent(app);
|
|
const { csrf: firstCsrf } = await signup(first, "[email protected]");
|
|
await signup(second, "[email protected]");
|
|
|
|
const bad = await first
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", firstCsrf)
|
|
.send({ origin: "", initialWeightG: 100 });
|
|
assert.equal(bad.status, 400);
|
|
const badWeight = await first
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", firstCsrf)
|
|
.send({ origin: "Huila", initialWeightG: -5 });
|
|
assert.equal(badWeight.status, 400);
|
|
|
|
const created = await first
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", firstCsrf)
|
|
.send({ origin: "Huila, Colombia", variety: "Caturra", initialWeightG: 2000 });
|
|
assert.equal(created.status, 201);
|
|
assert.equal(created.body.lot.remainingWeightG, 2000);
|
|
|
|
assert.equal((await first.get("/api/inventory")).body.lots.length, 1);
|
|
assert.equal((await second.get("/api/inventory")).body.lots.length, 0);
|
|
assert.equal((await second.get(`/api/inventory/${created.body.lot.id}`)).status, 404);
|
|
});
|
|
|
|
test("inventory: consume decrements remaining, allows negative, is idempotent per plan, and logs", async () => {
|
|
const { app } = await setup();
|
|
const agent = request.agent(app);
|
|
const { csrf } = await signup(agent, "[email protected]");
|
|
const planId = await bootstrapPlan(agent, csrf);
|
|
const lot = (
|
|
await agent
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ origin: "Huila", initialWeightG: 300 })
|
|
).body.lot;
|
|
|
|
const first = await agent
|
|
.post(`/api/inventory/${lot.id}/consume`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ weightG: 250, roastPlanId: planId });
|
|
assert.equal(first.status, 201);
|
|
assert.equal(first.body.lot.remainingWeightG, 50);
|
|
|
|
// A second draw against the SAME plan is rejected — one draw-down per roast plan, ever.
|
|
const dupe = await agent
|
|
.post(`/api/inventory/${lot.id}/consume`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ weightG: 10, roastPlanId: planId });
|
|
assert.equal(dupe.status, 409);
|
|
assert.equal(dupe.body.code, "already_consumed");
|
|
assert.equal(
|
|
(await agent.get(`/api/inventory/${lot.id}`)).body.lot.remainingWeightG,
|
|
50,
|
|
"remaining must be unchanged after the rejected duplicate",
|
|
);
|
|
|
|
// A manual (plan-less) draw is unlimited and can push remaining negative — an honest
|
|
// signal of paperwork/shelf drift, not clamped.
|
|
const manual = await agent
|
|
.post(`/api/inventory/${lot.id}/consume`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ weightG: 100 });
|
|
assert.equal(manual.status, 201);
|
|
assert.equal(manual.body.lot.remainingWeightG, -50);
|
|
|
|
const zero = await agent
|
|
.post(`/api/inventory/${lot.id}/consume`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ weightG: 0 });
|
|
assert.equal(zero.status, 400);
|
|
|
|
const withLog = await agent.get(`/api/inventory/${lot.id}`);
|
|
assert.equal(withLog.body.log.length, 2);
|
|
// Newest first: the manual (plan-less) draw has no plan title; the earlier draw does.
|
|
assert.equal(withLog.body.log[0].roastPlanId, null);
|
|
assert.equal(withLog.body.log[0].planTitle, null);
|
|
assert.equal(withLog.body.log[1].roastPlanId, planId);
|
|
assert.equal(withLog.body.log[1].planTitle, "Test coffee");
|
|
});
|
|
|
|
test("inventory: custom name is created, updated, listed, and falls back cleanly when unset", async () => {
|
|
const { app } = await setup();
|
|
const agent = request.agent(app);
|
|
const { csrf } = await signup(agent, "[email protected]");
|
|
|
|
// No name given: defaults to "" so the client's `lot.name || lot.origin` fallback applies.
|
|
const unnamed = await agent
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ origin: "Huila, Colombia", initialWeightG: 500 });
|
|
assert.equal(unnamed.status, 201);
|
|
assert.equal(unnamed.body.lot.name, "");
|
|
assert.equal(unnamed.body.lot.origin, "Huila, Colombia");
|
|
|
|
// Named on create.
|
|
const named = await agent
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ origin: "Yirgacheffe", name: " Spring Ethiopia ", initialWeightG: 300 });
|
|
assert.equal(named.status, 201);
|
|
assert.equal(named.body.lot.name, "Spring Ethiopia", "name is trimmed");
|
|
|
|
// Name is returned in both the list and the detail endpoints.
|
|
const list = await agent.get("/api/inventory");
|
|
const listedNamed = list.body.lots.find((l) => l.id === named.body.lot.id);
|
|
assert.equal(listedNamed.name, "Spring Ethiopia");
|
|
const listedUnnamed = list.body.lots.find((l) => l.id === unnamed.body.lot.id);
|
|
assert.equal(listedUnnamed.name, "");
|
|
const detail = await agent.get(`/api/inventory/${named.body.lot.id}`);
|
|
assert.equal(detail.body.lot.name, "Spring Ethiopia");
|
|
|
|
// Rename via PUT; other fields are left untouched (partial-update pattern).
|
|
const renamed = await agent
|
|
.put(`/api/inventory/${unnamed.body.lot.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ name: "Reserve lot" });
|
|
assert.equal(renamed.status, 200);
|
|
assert.equal(renamed.body.lot.name, "Reserve lot");
|
|
assert.equal(renamed.body.lot.origin, "Huila, Colombia");
|
|
|
|
// A PUT that omits `name` leaves the existing name unchanged.
|
|
const untouched = await agent
|
|
.put(`/api/inventory/${unnamed.body.lot.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ notes: "recheck moisture" });
|
|
assert.equal(untouched.body.lot.name, "Reserve lot");
|
|
|
|
// Clearing the name back out is honored explicitly.
|
|
const cleared = await agent
|
|
.put(`/api/inventory/${unnamed.body.lot.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ name: "" });
|
|
assert.equal(cleared.body.lot.name, "");
|
|
});
|
|
|
|
test("inventory: consuming against another user's plan is refused", async () => {
|
|
const { app } = await setup();
|
|
const owner = request.agent(app);
|
|
const attacker = request.agent(app);
|
|
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
|
|
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
|
|
const planId = await bootstrapPlan(owner, ownerCsrf);
|
|
const lot = (
|
|
await attacker
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", attackerCsrf)
|
|
.send({ origin: "Huila", initialWeightG: 500 })
|
|
).body.lot;
|
|
const consume = await attacker
|
|
.post(`/api/inventory/${lot.id}/consume`)
|
|
.set("x-csrf-token", attackerCsrf)
|
|
.send({ weightG: 50, roastPlanId: planId });
|
|
assert.equal(consume.status, 404);
|
|
});
|
|
|
|
test("inventory: edit never accepts remainingWeightG directly, but shifting initialWeightG shifts remaining by the delta", async () => {
|
|
const { app } = await setup();
|
|
const agent = request.agent(app);
|
|
const { csrf } = await signup(agent, "[email protected]");
|
|
const lot = (
|
|
await agent
|
|
.post("/api/inventory")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ origin: "Huila", initialWeightG: 1000 })
|
|
).body.lot;
|
|
await agent
|
|
.post(`/api/inventory/${lot.id}/consume`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ weightG: 400 });
|
|
|
|
// Attempting to set remainingWeightG directly is silently ignored.
|
|
const sneaky = await agent
|
|
.put(`/api/inventory/${lot.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ remainingWeightG: 999999 });
|
|
assert.equal(sneaky.body.lot.remainingWeightG, 600);
|
|
|
|
// Correcting the recorded initial weight (e.g. a scale error) shifts remaining by the delta.
|
|
const corrected = await agent
|
|
.put(`/api/inventory/${lot.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ initialWeightG: 1100 });
|
|
assert.equal(corrected.body.lot.initialWeightG, 1100);
|
|
assert.equal(corrected.body.lot.remainingWeightG, 700);
|
|
|
|
const archived = await agent
|
|
.put(`/api/inventory/${lot.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ archived: true });
|
|
assert.equal(archived.body.lot.archived, true);
|
|
|
|
assert.equal(
|
|
(await agent.delete(`/api/inventory/${lot.id}`).set("x-csrf-token", csrf)).status,
|
|
200,
|
|
);
|
|
assert.equal((await agent.get(`/api/inventory/${lot.id}`)).status, 404);
|
|
});
|