Files
Shane MaynardandClaude Fable 5 64c1b3605b
Test and deploy / test-and-deploy (push) Successful in 53s
Add the Academy: narrated, animated roasting & brewing lessons; cupping link on roast detail
Academy (/academy, 'Learn' nav group):
- 10 lessons / 45 slides across two tracks. Roasting: seed anatomy and
  composition, drying & the turning point, Maillard, first crack &
  development (DTR), curve/RoR reading (crash, flick, stall), defects →
  one-change discipline. Brewing: extraction physics (dissolving order,
  yield, grind, temp/time/ratio), then technique per brewer family —
  immersion (French press, AeroPress, Clever/Hario Switch, cold brew,
  cupping), percolation (bloom, V60, Chemex/Kalita, batch, percolator),
  espresso & pressure (puck prep, shot reading, dialing in, moka)
- One cohesive scene system: 25 parameterized animated SVG scenes drawn
  from the app's palette (beans, curves, phase bars, brewers reusing the
  silhouette library), CSS keyframe animations, reduced-motion support
- Narration: Azure TTS (en-US-Andrew HD, eastus) pre-generated to 45
  committed MP3s by scripts/generate-academy-audio.mjs; slides speak
  phonetic respellings (my-YARD, KEM-ex, MOH-kah…) while captions show
  normal spelling; hands-free autoplay advances after each clip
- Player: slide dots, keyboard arrows/space, per-slide captions

Roast detail: 'Open cupping session' button next to cup notes (opens or
creates the linked plan's session) — its score and flavors flow into
the updated .alog download.

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

468 lines
16 KiB
JavaScript
Raw Permalink 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("academy shell page requires a session and lesson audio ids all exist", async () => {
const { agent } = await setup();
const anonymous = await agent.get("/academy");
assert.equal(anonymous.status, 302);
assert.equal(anonymous.headers.location, "/login");
await signup(agent, "[email protected]");
const page = await agent.get("/academy");
assert.equal(page.status, 200);
assert.match(page.text, /Academy/);
// Every slide must have committed narration audio — a missing file would silently break
// the hands-free autoplay flow.
const { ACADEMY_TRACKS } = await import("../public/js/academy-content.js");
const { existsSync } = await import("node:fs");
for (const track of ACADEMY_TRACKS)
for (const lesson of track.lessons)
for (const slide of lesson.slides)
assert.equal(
existsSync(new URL(`../public/academy-audio/${slide.id}.mp3`, import.meta.url).pathname),
true,
`missing audio for ${slide.id}`,
);
});
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);
});