Test and deploy / test-and-deploy (push) Successful in 1m0s
Machines: - roasters table + actual_roasts.roaster_id (migrations 009): per-user machines with a default; uploads attach to the default roaster and are reassignable from the roast detail view - Learning is now per machine: /api/roaster-profile scopes to a roaster (default when unspecified) and applies the user's override tweaks (milestone temps, TP time, pace factor) on top of learned medians - /roasters page: machine list + analysis report showing learned vs applied values with editable tweaks, typical RoR, and a plain-language list of the adjustments applied to plans; planner honors a manual pace override; evaluations use the roast's own machine profile Mobile header redesign: - The planner's header tools collapse behind a single 'Tools ▾' disclosure (dropdown card) at ≤720px, keeping the fixed three-row mobile header with no wrapped or scrolling button rows; desktop renders inline via display:contents (same DOM, same handlers) Profile pictures: - users.avatar (migration 010) + PUT/GET/DELETE /api/account/avatar; client-side centre-crop resize to 128px JPEG; avatar shows in the nav chip on every page; included in full backups Co-Authored-By: Claude Fable 5 <[email protected]>
174 lines
6.3 KiB
JavaScript
174 lines
6.3 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import request from "supertest";
|
|
import { setup, signup } from "./helpers.js";
|
|
|
|
function makeAlog(title, fcIdx = 14) {
|
|
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, fcIdx, 0, 0, 0, 20, 0] });
|
|
}
|
|
|
|
const stubEval = async () => ({ summary: "ok", grade: "good", highlights: [], concerns: [], suggestions: [], planComparison: null });
|
|
|
|
test("profile picture: set, serve, remove, validate", async () => {
|
|
const { agent } = await setup();
|
|
const { csrf } = await signup(agent, "[email protected]");
|
|
assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, false);
|
|
assert.equal((await agent.get("/api/account/avatar")).status, 404);
|
|
|
|
// 1x1 PNG
|
|
const png =
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
|
assert.equal(
|
|
(await agent.put("/api/account/avatar").set("x-csrf-token", csrf).send({ dataUrl: png })).status,
|
|
200,
|
|
);
|
|
assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, true);
|
|
const served = await agent.get("/api/account/avatar");
|
|
assert.equal(served.status, 200);
|
|
assert.equal(served.headers["content-type"], "image/png");
|
|
|
|
// Junk rejected
|
|
for (const dataUrl of ["not-an-image", "data:image/svg+xml;base64,PHN2Zz4=", 5])
|
|
assert.equal(
|
|
(await agent.put("/api/account/avatar").set("x-csrf-token", csrf).send({ dataUrl })).status,
|
|
400,
|
|
);
|
|
|
|
assert.equal(
|
|
(await agent.delete("/api/account/avatar").set("x-csrf-token", csrf)).status,
|
|
200,
|
|
);
|
|
assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, false);
|
|
assert.equal((await agent.get("/api/account/avatar")).status, 404);
|
|
});
|
|
|
|
test("roasters: CRUD, default handling, uploads attach and reassign, per-roaster profiles", async () => {
|
|
const { app, agent } = await setup({}, { evaluateRoast: stubEval });
|
|
const { csrf } = await signup(agent, "[email protected]");
|
|
|
|
// First roaster auto-defaults; second doesn't
|
|
const hottop = (
|
|
await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "Hottop", model: "KN-8828B-2K+" })
|
|
).body.roaster;
|
|
assert.equal(hottop.isDefault, true);
|
|
const aillio = (
|
|
await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "Aillio" })
|
|
).body.roaster;
|
|
assert.equal(aillio.isDefault, false);
|
|
assert.equal(
|
|
(await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "" })).status,
|
|
400,
|
|
);
|
|
|
|
// Uploads attach to the default roaster unless told otherwise
|
|
const upDefault = await agent
|
|
.post("/api/roasts")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ filename: "d.alog", content: makeAlog("On default") });
|
|
assert.equal(upDefault.body.roast.roasterId, hottop.id);
|
|
const upExplicit = await agent
|
|
.post("/api/roasts")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ filename: "e.alog", content: makeAlog("On aillio", 15), roasterId: aillio.id });
|
|
assert.equal(upExplicit.body.roast.roasterId, aillio.id);
|
|
const list = await agent.get("/api/roasts");
|
|
assert.equal(list.body.roasts.find((r) => r.filename === "d.alog").roasterName, "Hottop");
|
|
|
|
// Roaster list counts its roasts
|
|
const roasters = (await agent.get("/api/roasters")).body.roasters;
|
|
assert.equal(roasters.find((r) => r.id === hottop.id).roastCount, 1);
|
|
assert.equal(roasters.find((r) => r.id === aillio.id).roastCount, 1);
|
|
|
|
// Per-roaster profile only sees that machine's roasts; default profile = default roaster
|
|
const hottopProfile = (
|
|
await agent.get(`/api/roaster-profile?roaster=${hottop.id}`)
|
|
).body.profile;
|
|
assert.equal(hottopProfile.n, 1);
|
|
assert.equal(hottopProfile.roaster.name, "Hottop");
|
|
const defaultProfile = (await agent.get("/api/roaster-profile")).body.profile;
|
|
assert.equal(defaultProfile.roaster.id, hottop.id);
|
|
assert.equal(defaultProfile.n, 1);
|
|
|
|
// Overrides: applied on top of learned medians; bad values rejected
|
|
const put = await agent
|
|
.put(`/api/roasters/${hottop.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ overrides: { firstCrackTempC: 196, paceFactor: 1.1 } });
|
|
assert.equal(put.status, 200);
|
|
const tweaked = (
|
|
await agent.get(`/api/roaster-profile?roaster=${hottop.id}`)
|
|
).body.profile;
|
|
assert.equal(tweaked.medians.firstCrackTempC, 196);
|
|
assert.notEqual(tweaked.learnedMedians.firstCrackTempC, 196);
|
|
assert.equal(tweaked.paceFactorOverride, 1.1);
|
|
assert.equal(
|
|
(
|
|
await agent
|
|
.put(`/api/roasters/${hottop.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ overrides: { paceFactor: 9 } })
|
|
).status,
|
|
400,
|
|
);
|
|
|
|
// Reassign a roast between machines
|
|
const reassign = await agent
|
|
.put(`/api/roasts/${upDefault.body.roast.id}`)
|
|
.set("x-csrf-token", csrf)
|
|
.send({ roasterId: aillio.id });
|
|
assert.equal(reassign.status, 200);
|
|
assert.equal(
|
|
(await agent.get(`/api/roaster-profile?roaster=${aillio.id}`)).body.profile.n,
|
|
2,
|
|
);
|
|
|
|
// Default transfer + delete keeps roasts (detached)
|
|
await agent.put(`/api/roasters/${aillio.id}`).set("x-csrf-token", csrf).send({ isDefault: true });
|
|
const after = (await agent.get("/api/roasters")).body.roasters;
|
|
assert.equal(after.find((r) => r.id === hottop.id).isDefault, false);
|
|
assert.equal(after.find((r) => r.id === aillio.id).isDefault, true);
|
|
assert.equal(
|
|
(await agent.delete(`/api/roasters/${aillio.id}`).set("x-csrf-token", csrf)).status,
|
|
200,
|
|
);
|
|
const survivors = await agent.get("/api/roasts");
|
|
assert.equal(survivors.body.roasts.length, 2);
|
|
assert.equal(
|
|
survivors.body.roasts.every((r) => r.roasterId === null || r.roasterId === hottop.id),
|
|
true,
|
|
);
|
|
|
|
// Ownership: a stranger sees nothing and can't touch anything
|
|
const stranger = request.agent(app);
|
|
const { csrf: strangerCsrf } = await signup(stranger, "[email protected]");
|
|
assert.equal((await stranger.get("/api/roasters")).body.roasters.length, 0);
|
|
assert.equal(
|
|
(await stranger.get(`/api/roaster-profile?roaster=${hottop.id}`)).status,
|
|
404,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await stranger
|
|
.put(`/api/roasters/${hottop.id}`)
|
|
.set("x-csrf-token", strangerCsrf)
|
|
.send({ name: "hijack" })
|
|
).status,
|
|
404,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await stranger
|
|
.post("/api/roasts")
|
|
.set("x-csrf-token", strangerCsrf)
|
|
.send({ filename: "x.alog", content: makeAlog("x"), roasterId: hottop.id })
|
|
).status,
|
|
404,
|
|
);
|
|
});
|