Files
roast_command_center/test/brewing.test.js
T
Shane MaynardandClaude Fable 5 16d5af83f7
Test and deploy / test-and-deploy (push) Successful in 53s
Add Hario Switch to the brewer picker (immersion)
Also test that every brew method ships a silhouette so the picker can
never render an empty tile.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-08 23:18:25 -04:00

396 lines
13 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup, password } from "./helpers.js";
async function bootstrapAdmin(agent) {
const response = await agent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
assert.equal(response.status, 201);
return response.body.csrfToken;
}
test("beans: CRUD, computed remaining weight, ownership", async () => {
const { app, agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const created = await agent.post("/api/beans").set("x-csrf-token", csrf).send({
name: "Kenya AA — own roast",
roaster: "Home",
origin: "Kenya",
process: "washed",
roastLevel: "light",
roastDate: "2026-08-01",
initialWeightG: 210,
tastingNotes: "blackcurrant, tomato",
});
assert.equal(created.status, 201);
const bean = created.body.bean;
assert.equal(bean.remainingWeightG, 210);
// Missing name rejected
assert.equal(
(await agent.post("/api/beans").set("x-csrf-token", csrf).send({ roaster: "X" })).status,
400,
);
// Logging brews reduces computed remaining
for (const dose of [18, 15]) {
const brew = await agent.post("/api/brews").set("x-csrf-token", csrf).send({
beanId: bean.id,
method: "v60",
doseG: dose,
waterG: dose * 16,
});
assert.equal(brew.status, 201);
}
const list = await agent.get("/api/beans");
assert.equal(list.body.beans[0].remainingWeightG, 210 - 33);
assert.equal(list.body.beans[0].brewCount, 2);
// Update
const updated = await agent
.put(`/api/beans/${bean.id}`)
.set("x-csrf-token", csrf)
.send({ name: "Kenya AA (rested)", archived: true });
assert.equal(updated.status, 200);
assert.equal(updated.body.bean.name, "Kenya AA (rested)");
assert.equal(updated.body.bean.archived, true);
assert.equal(updated.body.bean.origin, "Kenya"); // untouched fields survive
// Ownership
const stranger = request.agent(app);
const { csrf: strangerCsrf } = await signup(stranger, "[email protected]");
assert.equal((await stranger.get("/api/beans")).body.beans.length, 0);
assert.equal(
(
await stranger
.put(`/api/beans/${bean.id}`)
.set("x-csrf-token", strangerCsrf)
.send({ name: "hijack" })
).status,
404,
);
assert.equal(
(
await stranger
.post("/api/brews")
.set("x-csrf-token", strangerCsrf)
.send({ beanId: bean.id, method: "v60" })
).status,
404,
);
// Delete bean: brews keep existing with bean_id nulled
assert.equal(
(await agent.delete(`/api/beans/${bean.id}`).set("x-csrf-token", csrf)).status,
200,
);
const brews = await agent.get("/api/brews");
assert.equal(brews.body.brews.length, 2);
assert.equal(brews.body.brews[0].beanId, null);
});
test("brews: validation, update, filter by bean, method taxonomy", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const methods = await agent.get("/api/brew-methods");
assert.equal(methods.status, 200);
assert.equal(methods.body.categories.length, 3);
assert.equal(methods.body.methods.some((m) => m.key === "moka"), true);
const harioSwitch = methods.body.methods.find((m) => m.key === "hario-switch");
assert.equal(harioSwitch?.category, "immersion");
// Every method must have a silhouette so the picker never renders an empty tile
const { BREW_METHODS, BREW_SILHOUETTES } = await import("../shared/brew-data.js");
for (const method of BREW_METHODS)
assert.equal(
BREW_SILHOUETTES[method.key]?.length > 0,
true,
`missing silhouette for ${method.key}`,
);
// Unknown method rejected
assert.equal(
(await agent.post("/api/brews").set("x-csrf-token", csrf).send({ method: "teapot" })).status,
400,
);
// Rating out of range rejected
assert.equal(
(
await agent
.post("/api/brews")
.set("x-csrf-token", csrf)
.send({ method: "v60", rating: 11 })
).status,
400,
);
// Non-integer time rejected
assert.equal(
(
await agent
.post("/api/brews")
.set("x-csrf-token", csrf)
.send({ method: "v60", brewTimeS: 2.5 })
).status,
400,
);
const created = await agent.post("/api/brews").set("x-csrf-token", csrf).send({
method: "aeropress",
doseG: 15,
waterG: 230,
waterTempC: 92,
brewTimeS: 150,
grinder: "Comandante",
grindSetting: "22 clicks",
rating: 8,
tastingNotes: "sweet, cocoa, round body",
});
assert.equal(created.status, 201);
assert.equal(created.body.brew.rating, 8);
const updated = await agent
.put(`/api/brews/${created.body.brew.id}`)
.set("x-csrf-token", csrf)
.send({ rating: 6, notes: "slightly over-extracted" });
assert.equal(updated.status, 200);
assert.equal(updated.body.brew.rating, 6);
assert.equal(updated.body.brew.method, "aeropress"); // untouched fields survive
assert.equal(updated.body.brew.tastingNotes, "sweet, cocoa, round body");
const bean = (
await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "B" })
).body.bean;
await agent
.post("/api/brews")
.set("x-csrf-token", csrf)
.send({ method: "espresso", beanId: bean.id, doseG: 18, yieldG: 36 });
const filtered = await agent.get(`/api/brews?bean=${bean.id}`);
assert.equal(filtered.body.brews.length, 1);
assert.equal(filtered.body.brews[0].method, "espresso");
assert.equal(filtered.body.brews[0].beanName, "B");
assert.equal(
(
await agent
.delete(`/api/brews/${created.body.brew.id}`)
.set("x-csrf-token", csrf)
).status,
200,
);
assert.equal((await agent.get("/api/brews")).body.brews.length, 1);
});
test("api tokens: bearer auth works, skips CSRF, revocation kills access", async () => {
const { app, agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const created = await agent
.post("/api/tokens")
.set("x-csrf-token", csrf)
.send({ name: "cli" });
assert.equal(created.status, 201);
assert.match(created.body.token, /^rpt_/);
// Bearer client: no cookies, no CSRF header — reads and writes both work
const bearer = created.body.token;
const anonymous = request(app);
const me = await anonymous.get("/api/auth/me").set("authorization", `Bearer ${bearer}`);
assert.equal(me.status, 200);
assert.equal(me.body.user.email, "[email protected]");
const write = await anonymous
.post("/api/beans")
.set("authorization", `Bearer ${bearer}`)
.send({ name: "Token bean" });
assert.equal(write.status, 201);
// Wrong token fails; listing shows metadata only
assert.equal(
(await anonymous.get("/api/auth/me").set("authorization", "Bearer rpt_nope")).status,
401,
);
const list = await agent.get("/api/tokens");
assert.equal(list.body.tokens.length, 1);
assert.equal(list.body.tokens[0].name, "cli");
assert.equal(list.body.tokens[0].id, created.body.id);
assert.equal(String(list.body.tokens[0]).includes("rpt_"), false);
// Revoke → immediate 401
assert.equal(
(await agent.delete(`/api/tokens/${created.body.id}`).set("x-csrf-token", csrf)).status,
200,
);
assert.equal(
(await anonymous.get("/api/auth/me").set("authorization", `Bearer ${bearer}`)).status,
401,
);
});
test("backup: export → import round-trips data and keeps the admin session", async () => {
const { app, agent } = await setup();
const adminCsrf = await bootstrapAdmin(agent);
// Seed data across features as a second user
const user = request.agent(app);
const { csrf: userCsrf } = await signup(user, "[email protected]");
await user.post("/api/plans").set("x-csrf-token", userCsrf).send({ plan: { fields: { "0.1": "Backup plan" } } });
const bean = (
await user.post("/api/beans").set("x-csrf-token", userCsrf).send({ name: "Backup bean", initialWeightG: 200 })
).body.bean;
await user.post("/api/brews").set("x-csrf-token", userCsrf).send({ method: "chemex", beanId: bean.id, doseG: 30, waterG: 500, rating: 9 });
await user.post("/api/inventory").set("x-csrf-token", userCsrf).send({ origin: "Colombia", initialWeightG: 1000 });
const exported = await agent.get("/api/admin/backup");
assert.equal(exported.status, 200);
assert.match(exported.headers["content-disposition"], /attachment/);
const backup = exported.body;
assert.equal(backup.format, "roast-planner-backup");
assert.equal(backup.tables.users.length, 2);
assert.equal(backup.tables.roasted_beans.length, 1);
assert.equal(backup.tables.brews.length, 1);
assert.equal(backup.tables.green_bean_lots.length, 1);
// Non-admin cannot export or import
assert.equal((await user.get("/api/admin/backup")).status, 403);
// Import replaces everything; the importing admin's session survives
const imported = await agent
.post("/api/admin/backup/import")
.set("x-csrf-token", adminCsrf)
.send(backup);
assert.equal(imported.status, 200);
assert.equal(imported.body.sessionKept, true);
assert.equal(imported.body.counts.users, 2);
assert.equal((await agent.get("/api/auth/me")).status, 200);
// Data round-tripped: the user logs back in (their session was not preserved) and finds it
const userAgain = request.agent(app);
const login = await userAgain
.post("/api/auth/login")
.send({ email: "[email protected]", password });
assert.equal(login.status, 200);
assert.equal((await userAgain.get("/api/beans")).body.beans.length, 1);
assert.equal((await userAgain.get("/api/beans")).body.beans[0].remainingWeightG, 170);
assert.equal((await userAgain.get("/api/brews")).body.brews.length, 1);
assert.equal((await userAgain.get("/api/inventory")).body.lots.length, 1);
// A backup with no active admin is refused outright
const noAdmin = structuredClone(backup);
noAdmin.tables.users = noAdmin.tables.users.filter((u) => u.role !== "admin");
assert.equal(
(
await agent
.post("/api/admin/backup/import")
.set("x-csrf-token", adminCsrf)
.send(noAdmin)
).status,
400,
);
// Garbage is refused
assert.equal(
(
await agent
.post("/api/admin/backup/import")
.set("x-csrf-token", adminCsrf)
.send({ format: "nope" })
).status,
400,
);
});
function makeAlog(title = "Roast") {
const timex = [], temp1 = [], temp2 = [];
for (let i = 0; i <= 20; i++) {
timex.push(i * 30);
temp1.push(200 + i);
temp2.push(i < 3 ? 180 - i * 30 : 90 + (i - 3) * 7);
}
return JSON.stringify({ title, mode: "C", weight: [250, 212, "g"], timex, temp1, temp2, timeindex: [1, 8, 14, 0, 0, 0, 20, 0] });
}
test("roaster profile aggregates uploaded roasts; plan chat is grounded in it", async () => {
const chatCalls = [];
const { agent } = await setup(
{},
{
evaluateRoast: async () => ({ summary: "ok", grade: "good", highlights: [], concerns: [], suggestions: [], planComparison: null }),
runPlanChat: async (args) => {
chatCalls.push(args);
return { reply: "Drop 20 seconds earlier.", model: "test" };
},
},
);
const { csrf } = await signup(agent, "[email protected]");
// Empty profile before any uploads
const empty = await agent.get("/api/roaster-profile");
assert.equal(empty.status, 200);
assert.equal(empty.body.profile.n, 0);
// Upload two roasts → profile aggregates them
for (const title of ["r1", "r2"]) {
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: `${title}.alog`, content: makeAlog(title) });
assert.equal(up.status, 201);
}
const profile = (await agent.get("/api/roaster-profile")).body.profile;
assert.equal(profile.n, 2);
assert.equal(Number.isFinite(profile.medians.turningPointS), true);
assert.equal(Number.isFinite(profile.medians.firstCrackTempC), true);
assert.equal(Number.isFinite(profile.rorCPerMin.maillard), true);
// Chat receives the plan, the coerced messages, and both learned profiles
const chat = await agent.post("/api/plan-chat").set("x-csrf-token", csrf).send({
plan: { fields: { "0.1": "Chat plan", "1.4": "8:30" } },
messages: [{ role: "user", content: "Why is drop so late?" }],
});
assert.equal(chat.status, 200);
assert.equal(chat.body.reply, "Drop 20 seconds earlier.");
assert.equal(chatCalls[0].plan.fields["0.1"], "Chat plan");
assert.equal(chatCalls[0].roasterProfile.n, 2);
assert.equal(Array.isArray(chatCalls[0].messages), true);
// Bad chat bodies are rejected before any model call
assert.equal(
(await agent.post("/api/plan-chat").set("x-csrf-token", csrf).send({ plan: {}, messages: [] })).status,
400,
);
assert.equal(
(
await agent
.post("/api/plan-chat")
.set("x-csrf-token", csrf)
.send({ plan: {}, messages: [{ role: "assistant", content: "hi" }] })
).status,
400,
);
assert.equal(chatCalls.length, 1);
});
test("per-user export and openapi spec", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "Mine" });
const exported = await agent.get("/api/account/export");
assert.equal(exported.status, 200);
assert.equal(exported.body.format, "roast-planner-user-export");
assert.equal(exported.body.tables.roasted_beans.length, 1);
assert.equal(exported.body.tables.users, undefined);
assert.equal(exported.body.tables.api_tokens, undefined);
const spec = await agent.get("/api/openapi.json");
assert.equal(spec.status, 200);
assert.equal(spec.body.openapi, "3.0.3");
assert.equal(!!spec.body.paths["/api/brews"], true);
assert.equal(!!spec.body.paths["/api/admin/backup/import"], true);
assert.equal(!!spec.body.components.securitySchemes.bearerAuth, true);
});