Files
roast_command_center/test/brewing.test.js
T
Shane MaynardandClaude Fable 5 e62b9601a2
Test and deploy / test-and-deploy (push) Successful in 59s
Add Equipment page: owned brewers and grinders filter the Brews UI
- user_gear table (migration 008) + GET/PUT /api/gear (brewer keys
  validated against the taxonomy, grinders deduped/trimmed)
- /gear page: silhouette multi-select for owned brewers (autosaves) and
  a grinder list; nav gains Brewing → Equipment
- Brews page: with gear configured, the picker shows only owned brewers
  (plus the edited brew's method) with a show-all toggle; grinder field
  suggests your grinders; empty-gear state links to /gear
- Included in full backups, per-user export, and the OpenAPI spec

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-09 08:08:32 -04:00

445 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
recipe: "inverted · fill to 230 g · stir 3× · steep 2:00 · press 30 s",
tastingNotes: "sweet, cocoa, round body",
});
assert.equal(created.status, 201);
assert.equal(created.body.brew.rating, 8);
assert.equal(created.body.brew.recipe, "inverted · fill to 230 g · stir 3× · steep 2:00 · press 30 s");
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");
assert.equal(updated.body.brew.recipe.startsWith("inverted"), true);
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("gear: owned brewers/grinders round-trip, validate, and stay private", async () => {
const { app, agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
// Defaults to empty
const empty = await agent.get("/api/gear");
assert.equal(empty.status, 200);
assert.deepEqual(empty.body.gear, { brewers: [], grinders: [] });
// Save + read back (upsert twice to cover the ON CONFLICT path)
const first = await agent
.put("/api/gear")
.set("x-csrf-token", csrf)
.send({ brewers: ["v60", "aeropress"], grinders: ["Comandante C40"] });
assert.equal(first.status, 200);
const second = await agent
.put("/api/gear")
.set("x-csrf-token", csrf)
.send({ brewers: ["v60", "hario-switch", "v60"], grinders: ["Comandante C40", "DF64", " "] });
assert.equal(second.status, 200);
assert.deepEqual(second.body.gear.brewers, ["v60", "hario-switch"]); // deduped
assert.deepEqual(second.body.gear.grinders, ["Comandante C40", "DF64"]); // blank dropped
assert.deepEqual((await agent.get("/api/gear")).body.gear.brewers, ["v60", "hario-switch"]);
// Unknown brewer keys and non-array bodies are rejected
assert.equal(
(
await agent
.put("/api/gear")
.set("x-csrf-token", csrf)
.send({ brewers: ["teapot"], grinders: [] })
).status,
400,
);
assert.equal(
(await agent.put("/api/gear").set("x-csrf-token", csrf).send({ brewers: "v60", grinders: [] })).status,
400,
);
// Private per user
const request = (await import("supertest")).default;
const stranger = request.agent(app);
await signup(stranger, "[email protected]");
assert.deepEqual((await stranger.get("/api/gear")).body.gear.brewers, []);
});
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);
});