Test and deploy / test-and-deploy (push) Successful in 53s
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]>
468 lines
16 KiB
JavaScript
468 lines
16 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,
|
||
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);
|
||
});
|