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