Files
Shane MaynardandClaude Fable 5 a341d67467
Test and deploy / test-and-deploy (push) Successful in 1m0s
Add roaster (machine) management, mobile header-tools redesign, and profile pictures
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]>
2026-08-09 08:17:10 -04:00

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,
);
});