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: "snowspeeder@gmail.com", 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, "beans@example.com"); 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, "other@example.com"); 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, "brews@example.com"); 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); // 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, "tokens@example.com"); 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, "tokens@example.com"); 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, "data@example.com"); 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: "data@example.com", 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, "chat@example.com"); // 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, "export@example.com"); 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); });