Add green-bean inventory and cupping features
Ports inventory management and SCA-style cupping scoring from hope_roaster: lot tracking with audit-logged consumption, cupping sessions with server-authoritative scoring and a live radar chart, and links from the planner (draw-from-lot, open-cupping-session). Also fixes the Blend/Single-origin toggle layout, replaces tooltips with an in-context "why" teaching layer, and stages the planner UI into pre-roast vs. post-roast phases.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import request from "supertest";
|
||||
import { setup, signup } from "./helpers.js";
|
||||
import { computeTotalScore, SCORE_ATTRS, TICK_ATTRS } from "../shared/cupping.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("cupping: auth and CSRF are required on every route", async () => {
|
||||
const { app } = await setup();
|
||||
const anon = request.agent(app);
|
||||
assert.equal((await anon.get("/api/cupping")).status, 401);
|
||||
assert.equal((await anon.post("/api/cupping").send({})).status, 401);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
assert.equal((await agent.post("/api/cupping").send({})).status, 403);
|
||||
const created = await agent.post("/api/cupping").set("x-csrf-token", csrf).send({});
|
||||
assert.equal(created.status, 201);
|
||||
assert.equal(
|
||||
(await agent.put(`/api/cupping/${created.body.session.id}`).send({ data: {} })).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await agent.delete(`/api/cupping/${created.body.session.id}`)).status, 403);
|
||||
});
|
||||
|
||||
test("cupping: create (with and without a linked plan), 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 planId = await bootstrapPlan(first, firstCsrf);
|
||||
|
||||
const linked = await first
|
||||
.post("/api/cupping")
|
||||
.set("x-csrf-token", firstCsrf)
|
||||
.send({ roastPlanId: planId, cupCount: 4 });
|
||||
assert.equal(linked.status, 201);
|
||||
assert.equal(linked.body.session.roastPlanId, planId);
|
||||
assert.equal(linked.body.session.data.cup_count, 4);
|
||||
assert.equal(linked.body.session.totalScore, 0);
|
||||
|
||||
const unlinked = await first.post("/api/cupping").set("x-csrf-token", firstCsrf).send({});
|
||||
assert.equal(unlinked.status, 201);
|
||||
assert.equal(unlinked.body.session.roastPlanId, null);
|
||||
|
||||
assert.equal((await first.get("/api/cupping")).body.sessions.length, 2);
|
||||
assert.equal((await second.get("/api/cupping")).body.sessions.length, 0);
|
||||
assert.equal((await second.get(`/api/cupping/${linked.body.session.id}`)).status, 404);
|
||||
|
||||
const filtered = await first.get(`/api/cupping?plan=${planId}`);
|
||||
assert.equal(filtered.body.sessions.length, 1);
|
||||
assert.equal(filtered.body.sessions[0].id, linked.body.session.id);
|
||||
});
|
||||
|
||||
test("cupping: creating 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 attempt = await attacker
|
||||
.post("/api/cupping")
|
||||
.set("x-csrf-token", attackerCsrf)
|
||||
.send({ roastPlanId: planId });
|
||||
assert.equal(attempt.status, 404);
|
||||
});
|
||||
|
||||
test("cupping: server always recomputes the total score and ignores a client-supplied value", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const session = (
|
||||
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 5 })
|
||||
).body.session;
|
||||
|
||||
const scores = Object.fromEntries(SCORE_ATTRS.map((a) => [a, 8]));
|
||||
const ticks = Object.fromEntries(TICK_ATTRS.map((a) => [a, 5]));
|
||||
const expected = computeTotalScore(scores, ticks, 1, 1, 5);
|
||||
|
||||
const saved = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({
|
||||
data: {
|
||||
cup_count: 5,
|
||||
scores,
|
||||
ticks,
|
||||
taint_cups: 1,
|
||||
fault_cups: 1,
|
||||
flavor_tags: ["fruity.berry.blueberry"],
|
||||
notes: "Bright and clean.",
|
||||
total_score: 999999, // must be discarded — the server recomputes it
|
||||
},
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.session.totalScore, expected);
|
||||
assert.notEqual(expected, 999999);
|
||||
|
||||
const reloaded = await agent.get(`/api/cupping/${session.id}`);
|
||||
assert.equal(reloaded.body.session.totalScore, expected);
|
||||
assert.deepEqual(reloaded.body.session.data.flavor_tags, ["fruity.berry.blueberry"]);
|
||||
});
|
||||
|
||||
test("cupping: coerceSession validation errors surface as 400s", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const session = (await agent.post("/api/cupping").set("x-csrf-token", csrf).send({})).body
|
||||
.session;
|
||||
|
||||
const badFlavor = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { flavor_tags: ["Not A Valid Id!"] } });
|
||||
assert.equal(badFlavor.status, 400);
|
||||
assert.equal(badFlavor.body.code, "bad_session");
|
||||
|
||||
const dedupedTags = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { flavor_tags: Array.from({ length: 33 }, () => "other.chemical.rubber") } });
|
||||
// 33 identical tags dedupe to 1, so this specific input should NOT throw — verifies dedup
|
||||
// happens before the >32 check.
|
||||
assert.equal(dedupedTags.status, 200);
|
||||
assert.deepEqual(dedupedTags.body.session.data.flavor_tags, ["other.chemical.rubber"]);
|
||||
|
||||
// Build 33 genuinely distinct valid-shaped ids by walking the real taxonomy, to trip the
|
||||
// actual >32-after-dedup limit.
|
||||
const { FLAVOR_TAXONOMY } = await import("../shared/cupping.js");
|
||||
const manyValid = [];
|
||||
for (const [famId, fam] of Object.entries(FLAVOR_TAXONOMY)) {
|
||||
for (const [subId, descriptors] of Object.entries(fam.subgroups)) {
|
||||
for (const d of descriptors) manyValid.push(`${famId}.${subId}.${d}`);
|
||||
}
|
||||
}
|
||||
assert.ok(manyValid.length > 32);
|
||||
const overLimit = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { flavor_tags: manyValid } });
|
||||
assert.equal(overLimit.status, 400);
|
||||
|
||||
const badStage = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { stage_marks: [{ stage: "not_a_real_stage", elapsed_sec: 1 }] } });
|
||||
assert.equal(badStage.status, 400);
|
||||
|
||||
const notObject = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: "nope" });
|
||||
assert.equal(notObject.status, 400);
|
||||
});
|
||||
|
||||
test("cupping: scores clamp/snap and ticks clamp to cup count", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const session = (
|
||||
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 3 })
|
||||
).body.session;
|
||||
|
||||
const saved = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({
|
||||
data: {
|
||||
cup_count: 3,
|
||||
scores: { flavor: 11, acidity: 7.13, body: -1 },
|
||||
ticks: { uniformity: 99 },
|
||||
},
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.session.data.scores.flavor, 10);
|
||||
assert.equal(saved.body.session.data.scores.acidity, 7.25);
|
||||
assert.equal(saved.body.session.data.scores.body, 0);
|
||||
assert.equal(saved.body.session.data.ticks.uniformity, 3);
|
||||
});
|
||||
|
||||
test("cupping: delete removes the session, and deleting the linked plan nulls roastPlanId instead of deleting the session", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const planId = await bootstrapPlan(agent, csrf);
|
||||
const session = (
|
||||
await agent
|
||||
.post("/api/cupping")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ roastPlanId: planId })
|
||||
).body.session;
|
||||
|
||||
await agent.delete(`/api/plans/${planId}`).set("x-csrf-token", csrf);
|
||||
const afterPlanDelete = await agent.get(`/api/cupping/${session.id}`);
|
||||
assert.equal(afterPlanDelete.status, 200);
|
||||
assert.equal(afterPlanDelete.body.session.roastPlanId, null);
|
||||
|
||||
assert.equal(
|
||||
(await agent.delete(`/api/cupping/${session.id}`).set("x-csrf-token", csrf)).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await agent.get(`/api/cupping/${session.id}`)).status, 404);
|
||||
});
|
||||
Reference in New Issue
Block a user