Test and deploy / test-and-deploy (push) Successful in 56s
One system: post-roast observations (weights, colour, DTR, cup notes, 'one change next batch', disproof) now live on the uploaded roast (actual_roasts.after, migration 011), edited on the roast detail view with weights/DTR prefilled from the .alog itself. The planner's screen section is removed (the print worksheet keeps its hand-fill copy), and the lot 'last refine' suggestion reads the note from both the new home and legacy plans, newest wins. Every roast now downloads two ways: the untouched original .alog, and an updated supplemental copy with the app's data written back as valid Artisan fields (weight, beans, roastingnotes incl. the LLM review, cuppingnotes incl. linked cupping score/flavors) — serialized as a Python literal so Artisan's ast.literal_eval re-imports it. Co-Authored-By: Claude Fable 5 <[email protected]>
329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
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, "[email protected]");
|
|
|
|
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, "[email protected]");
|
|
|
|
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, "[email protected]");
|
|
|
|
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, "[email protected]");
|
|
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, "[email protected]");
|
|
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, "[email protected]");
|
|
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, "[email protected]");
|
|
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, "[email protected]");
|
|
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);
|
|
});
|