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, "owner@example.com"); 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, "one@example.com"); await signup(second, "two@example.com"); 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, "owner@example.com"); const { csrf: attackerCsrf } = await signup(attacker, "attacker@example.com"); 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, "taster@example.com"); 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, "taster@example.com"); 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, "taster@example.com"); 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, "taster@example.com"); 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, "late-link@example.com"); const { csrf: attackerCsrf } = await signup(attacker, "other@example.com"); 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); });