Test and deploy / test-and-deploy (push) Successful in 1m8s
The planner's Artisan .alog drawer now shows what's attached with a "Remove reference curve" control (re-rendered per drawer open), so adding a reference curve is no longer a one-way door; /api/alog shares the 8 MB body cap so real-sized logs parse instead of failing with "bad_request". Deep-evaluation fixes: editing a brew of an archived bean no longer silently detaches the bean; the roasts pending-review poll no longer wipes in-progress after-roast edits; roasters gain an Edit (rename/model) action; the gear page refuses to autosave over a failed load; duplicating a plan carries its custom name; cupping sessions can attach a plan after creation (ownership-checked PUT + selector); admin user deletion also refreshes plans/audit; cupping cup-count subtitle stays live; roasts error-row colspan corrected. Regression tests cover the new cupping PUT and the /api/alog body cap. Academy scenes drop the flat paper-cutout look: shared defs provide radial-gradient shading on every bean/half-bean/particle, flame gradients with radiant halos, soft ground shadows, and a warm-lit stage background; fill-shift animations now ride a partial-opacity tint overlay so shading survives the color change. Co-Authored-By: Claude Fable 5 <[email protected]>
263 lines
9.7 KiB
JavaScript
263 lines
9.7 KiB
JavaScript
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);
|
|
});
|
|
|
|
test("cupping: a plan can be attached after creation, with ownership enforced", async () => {
|
|
const { app } = await setup();
|
|
const owner = request.agent(app);
|
|
const attacker = request.agent(app);
|
|
const { csrf } = await signup(owner, "[email protected]");
|
|
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
|
|
const planId = await bootstrapPlan(owner, csrf);
|
|
const foreignPlanId = await bootstrapPlan(attacker, attackerCsrf);
|
|
|
|
const session = (
|
|
await owner.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 3 })
|
|
).body.session;
|
|
assert.equal(session.roastPlanId, null);
|
|
|
|
// A PUT without roastPlanId leaves the link untouched
|
|
const noTouch = await owner
|
|
.put(`/api/cupping/${session.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ data: session.data });
|
|
assert.equal(noTouch.status, 200);
|
|
assert.equal(noTouch.body.session.roastPlanId, null);
|
|
|
|
// Attaching my own plan works
|
|
const linked = await owner
|
|
.put(`/api/cupping/${session.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ data: session.data, roastPlanId: planId });
|
|
assert.equal(linked.status, 200);
|
|
assert.equal(linked.body.session.roastPlanId, planId);
|
|
|
|
// Someone else's plan is refused and the link stays intact
|
|
const cross = await owner
|
|
.put(`/api/cupping/${session.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ data: session.data, roastPlanId: foreignPlanId });
|
|
assert.equal(cross.status, 404);
|
|
assert.equal(
|
|
(await owner.get(`/api/cupping/${session.id}`)).body.session.roastPlanId,
|
|
planId,
|
|
);
|
|
|
|
// Explicit null detaches
|
|
const detached = await owner
|
|
.put(`/api/cupping/${session.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ data: session.data, roastPlanId: null });
|
|
assert.equal(detached.status, 200);
|
|
assert.equal(detached.body.session.roastPlanId, null);
|
|
});
|