import test from "node:test"; import assert from "node:assert/strict"; import { setup, signup } from "./helpers.js"; // Minimal but realistic Artisan .alog: 21 samples at 30s intervals, milestones marked via // timeindex (charge, dry end, first crack, drop). function makeAlog(title = "Test Roast") { const timex = []; const temp1 = []; const temp2 = []; for (let i = 0; i <= 20; i++) { const t = i * 30; timex.push(t); temp1.push(200 + i * 2); // BT: drop to a turning point then climb temp2.push(i < 3 ? 180 - i * 30 : 90 + (i - 3) * 7); } return JSON.stringify({ title, roastdate: "08.08.2026", roastertype: "Hottop KN-8828B-2K+", mode: "C", weight: [250, 212, "g"], timex, temp1, temp2, timeindex: [1, 8, 14, 0, 0, 0, 20, 0], }); } async function pollStatus(agent, id, want, tries = 100) { for (let i = 0; i < tries; i++) { const res = await agent.get(`/api/roasts/${id}`); if (res.body.roast?.evaluationStatus === want) return res.body.roast; await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error(`evaluation never reached status "${want}"`); } function evaluationStub() { const calls = []; let impl = async () => ({ summary: "Clean roast, slightly long drying.", grade: "good", highlights: ["DTR 20% in band"], concerns: [], suggestions: ["Charge 5C hotter"], planComparison: "Tracked the plan within 20s.", }); const stub = (parsed, plan) => { calls.push({ parsed, plan }); return impl(parsed, plan); }; stub.calls = calls; stub.setImpl = (fn) => { impl = fn; }; return stub; } test("upload, evaluate, list, detail, download, delete", async () => { const stub = evaluationStub(); const { agent } = await setup({}, { evaluateRoast: stub }); const { csrf } = await signup(agent, "roaster@example.com"); const plan = await agent .post("/api/plans") .set("x-csrf-token", csrf) .send({ plan: { fields: { 0.1: "Kenya AA" } } }); assert.equal(plan.status, 201); const planId = plan.body.plan.id; // Upload attached to the plan const up1 = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ roastPlanId: planId, filename: "roast-1.alog", content: makeAlog("Roast one") }); assert.equal(up1.status, 201); assert.equal(up1.body.roast.evaluationStatus, "pending"); assert.equal(up1.body.roast.roast.title, "Roast one"); assert.equal(up1.body.roast.derived.dtrPct > 0, true); // The async deep evaluation lands on the row const done = await pollStatus(agent, up1.body.roast.id, "done"); assert.equal(done.evaluation.grade, "good"); assert.equal(done.evaluation.summary, "Clean roast, slightly long drying."); // The stub was handed the linked plan for plan-vs-actual comparison assert.equal(stub.calls[0].plan.fields["0.1"], "Kenya AA"); // A second upload against the same plan is allowed (multiple actuals per plan) const up2 = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ roastPlanId: planId, filename: "roast-2.alog", content: makeAlog("Roast two") }); assert.equal(up2.status, 201); await pollStatus(agent, up2.body.roast.id, "done"); // List shows both, newest first, with plan title but no heavy payloads const list = await agent.get("/api/roasts"); assert.equal(list.body.roasts.length, 2); assert.equal(list.body.roasts[0].planTitle, "Kenya AA"); assert.equal(list.body.roasts[0].parsed, undefined); const filtered = await agent.get(`/api/roasts?plan=${planId}`); assert.equal(filtered.body.roasts.length, 2); // Detail includes full parsed curve, evaluation, and the linked plan const detail = await agent.get(`/api/roasts/${up1.body.roast.id}`); assert.equal(detail.status, 200); assert.equal(detail.body.roast.parsed.curve.length > 0, true); assert.equal(detail.body.roast.parsed.milestones.some((m) => m.key === "fc"), true); assert.equal(detail.body.roast.plan.fields["0.1"], "Kenya AA"); // Download returns the original bytes as an attachment const download = await agent.get(`/api/roasts/${up1.body.roast.id}/download`); assert.equal(download.status, 200); assert.match(download.headers["content-disposition"], /attachment; filename="roast-1.alog"/); assert.equal(download.body.toString("utf8"), makeAlog("Roast one")); // Delete const del = await agent .delete(`/api/roasts/${up1.body.roast.id}`) .set("x-csrf-token", csrf); assert.equal(del.status, 200); assert.equal((await agent.get("/api/roasts")).body.roasts.length, 1); }); test("upload without a plan, garbage rejected, csrf required", async () => { const stub = evaluationStub(); const { agent } = await setup({}, { evaluateRoast: stub }); const { csrf } = await signup(agent, "solo@example.com"); const up = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ filename: "standalone.alog", content: makeAlog("No plan") }); assert.equal(up.status, 201); assert.equal(up.body.roast.roastPlanId, null); const done = await pollStatus(agent, up.body.roast.id, "done"); assert.equal(done.plan, null); assert.equal(stub.calls[0].plan, null); const bad = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ filename: "junk.alog", content: "definitely not an alog {{{" }); assert.equal(bad.status, 422); assert.equal(bad.body.code, "unparseable_alog"); const empty = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ filename: "x.alog" }); assert.equal(empty.status, 400); const noCsrf = await agent .post("/api/roasts") .send({ filename: "x.alog", content: makeAlog() }); assert.equal(noCsrf.status, 403); }); test("failed evaluation records the error and can be retried", async () => { const stub = evaluationStub(); stub.setImpl(async () => { const err = new Error("No model available from ~/.pi/agent config."); err.code = "no_model"; throw err; }); const { agent } = await setup({}, { evaluateRoast: stub }); const { csrf } = await signup(agent, "retry@example.com"); const up = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ filename: "r.alog", content: makeAlog() }); assert.equal(up.status, 201); const failed = await pollStatus(agent, up.body.roast.id, "failed"); assert.match(failed.evaluationError, /no_model/); // Model becomes available; retry succeeds stub.setImpl(async () => ({ summary: "Recovered.", grade: "fair", highlights: [], concerns: [], suggestions: [], planComparison: null, })); const retry = await agent .post(`/api/roasts/${up.body.roast.id}/evaluate`) .set("x-csrf-token", csrf); assert.equal(retry.status, 200); const done = await pollStatus(agent, up.body.roast.id, "done"); assert.equal(done.evaluation.summary, "Recovered."); assert.equal(done.evaluationError, null); }); test("/roasts shell page requires a session and is never served by filename", async () => { const { agent } = await setup({}, { evaluateRoast: evaluationStub() }); const anonymous = await agent.get("/roasts"); assert.equal(anonymous.status, 302); assert.equal(anonymous.headers.location, "/login"); assert.equal((await agent.get("/roasts.html")).status, 302); await signup(agent, "shell@example.com"); const page = await agent.get("/roasts"); assert.equal(page.status, 200); assert.match(page.text, /Roast history/); }); test("after-the-roast lives on the actual roast and flows into the updated .alog", async () => { const stub = evaluationStub(); const { agent } = await setup({}, { evaluateRoast: stub }); const { csrf } = await signup(agent, "after@example.com"); const plan = await agent .post("/api/plans") .set("x-csrf-token", csrf) .send({ plan: { fields: { "0.1": "After test coffee" }, inventory: {} } }); const up = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ roastPlanId: plan.body.plan.id, filename: "after.alog", content: makeAlog("After roast") }); assert.equal(up.status, 201); const id = up.body.roast.id; // Save observations; junk body rejected const put = await agent.put(`/api/roasts/${id}`).set("x-csrf-token", csrf).send({ after: { greenIn: "250", out: "212", colour: "62/78", oneChange: "first crack +0:30", cupNotes: "papery, thin", method: "V60 1:16", nonsense: "dropped", }, }); assert.equal(put.status, 200); assert.equal(put.body.after.oneChange, "first crack +0:30"); assert.equal(put.body.after.nonsense, undefined); assert.equal( (await agent.put(`/api/roasts/${id}`).set("x-csrf-token", csrf).send({ after: "x" })).status, 400, ); const detail = (await agent.get(`/api/roasts/${id}`)).body.roast; assert.equal(detail.after.colour, "62/78"); // Original download unchanged; updated download is a Python literal with the app's data const original = await agent.get(`/api/roasts/${id}/download`); assert.equal(original.body.toString("utf8"), makeAlog("After roast")); const updated = await agent.get(`/api/roasts/${id}/download?variant=updated`); assert.equal(updated.status, 200); assert.match(updated.headers["content-disposition"], /after-updated\.alog/); const text = updated.body.toString("utf8"); assert.match(text, /"weight": \[250, 212, "g"\]/); assert.match(text, /One change next batch: first crack \+0:30/); assert.match(text, /papery, thin/); assert.match(text, /"beans": "After test coffee"/); // Round-trips through our own Artisan parser (same tolerant path Artisan's literal_eval uses) const { parseAlog } = await import("../server/alog.js"); const reparsed = parseAlog(text, "after-updated.alog"); assert.equal(reparsed.roast.weightInG, 250); assert.equal(reparsed.roast.title, "After roast"); // No JSON booleans/nulls leak into the Python literal assert.equal(/\b(true|false|null)\b/.test(text), false); }); test("last-refine reads the one-change note from actual roasts (new home) and legacy plans", async () => { const stub = evaluationStub(); const { agent } = await setup({}, { evaluateRoast: stub }); const { csrf } = await signup(agent, "refine2@example.com"); const lot = ( await agent .post("/api/inventory") .set("x-csrf-token", csrf) .send({ origin: "Kenya", initialWeightG: 1000 }) ).body.lot; const plan = ( await agent .post("/api/plans") .set("x-csrf-token", csrf) .send({ plan: { fields: { "0.1": "Lot plan" }, inventory: { lotId: lot.id } } }) ).body.plan; // Note stored on the actual roast — the new home const up = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ roastPlanId: plan.id, filename: "r.alog", content: makeAlog("R") }); await agent .put(`/api/roasts/${up.body.roast.id}`) .set("x-csrf-token", csrf) .send({ after: { oneChange: "development +0:15" } }); const refine = await agent.get(`/api/inventory/${lot.id}/last-refine`); assert.equal(refine.status, 200); assert.equal(refine.body.refine.oneChange, "development +0:15"); assert.equal(refine.body.refine.planTitle, "Lot plan"); }); test("roasts are private to their owner", async () => { const stub = evaluationStub(); const { app, agent } = await setup({}, { evaluateRoast: stub }); const { csrf } = await signup(agent, "owner@example.com"); const up = await agent .post("/api/roasts") .set("x-csrf-token", csrf) .send({ filename: "mine.alog", content: makeAlog("Private") }); assert.equal(up.status, 201); const id = up.body.roast.id; const request = (await import("supertest")).default; const stranger = request.agent(app); const { csrf: strangerCsrf } = await signup(stranger, "stranger@example.com"); assert.equal((await stranger.get("/api/roasts")).body.roasts.length, 0); assert.equal((await stranger.get(`/api/roasts/${id}`)).status, 404); assert.equal((await stranger.get(`/api/roasts/${id}/download`)).status, 404); assert.equal( (await stranger.delete(`/api/roasts/${id}`).set("x-csrf-token", strangerCsrf)).status, 404, ); // A stranger's plan id can't be attached to my upload either const plan = await agent .post("/api/plans") .set("x-csrf-token", csrf) .send({ plan: { fields: {} } }); const crossPlan = await stranger .post("/api/roasts") .set("x-csrf-token", strangerCsrf) .send({ roastPlanId: plan.body.plan.id, filename: "x.alog", content: makeAlog() }); assert.equal(crossPlan.status, 404); }); test("reference-curve parse endpoint accepts a real-sized (multi-MB) .alog", async () => { const { agent } = await setup({}, { evaluateRoast: evaluationStub() }); const { csrf } = await signup(agent, "bigfile@example.com"); // Real Artisan logs carry full telemetry arrays and legitimately exceed 1 MB — // /api/alog must share the enlarged body cap that /api/roasts already has. const doc = JSON.parse(makeAlog("Big log")); doc.padding = "x".repeat(2 * 1024 * 1024); const res = await agent .post("/api/alog") .set("x-csrf-token", csrf) .send({ filename: "big.alog", content: JSON.stringify(doc) }); assert.equal(res.status, 200); assert.equal(res.body.ok, true); assert.equal(res.body.roast.title, "Big log"); });