Fix report pagination and rebuild saved plan navigation
Test and deploy / test-and-deploy (push) Successful in 1m43s
Test and deploy / test-and-deploy (push) Successful in 1m43s
This commit is contained in:
1 parent
824a964f9d
commit
5f3a015ea2
20 files changed
+1480
-809
No files matched your search
+1
-1
@@ -35,7 +35,7 @@ export async function setup(env = {}, appOptions = {}) {
|
||||
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
|
||||
CREATE TABLE cupping_sessions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE roasters(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,model text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',is_default boolean NOT NULL DEFAULT false,overrides jsonb NOT NULL DEFAULT '{}',created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE actual_roasts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,after jsonb NOT NULL DEFAULT '{}',evaluation jsonb,evaluation_status text NOT NULL DEFAULT 'pending',evaluation_error text,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE actual_roasts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,observations jsonb NOT NULL DEFAULT '{}',after jsonb NOT NULL DEFAULT '{}',evaluation jsonb,evaluation_status text NOT NULL DEFAULT 'pending',evaluation_error text,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE roasted_beans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,roaster text NOT NULL DEFAULT '',origin text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',variety text NOT NULL DEFAULT '',roast_level text NOT NULL DEFAULT '',roast_date date,initial_weight_g numeric,url text NOT NULL DEFAULT '',tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE brews(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,bean_id uuid REFERENCES roasted_beans(id) ON DELETE SET NULL,method text NOT NULL,dose_g numeric,water_g numeric,yield_g numeric,grinder text NOT NULL DEFAULT '',grind_setting text NOT NULL DEFAULT '',water_temp_c numeric,brew_time_s integer,bloom_time_s integer,rating numeric,recipe text NOT NULL DEFAULT '',tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',brewed_at timestamptz DEFAULT now(),created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE api_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),last_used_at timestamptz);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import request from "supertest";
|
||||
import { setup, signup } from "./helpers.js";
|
||||
import { blankPlan } from "../shared/fields.js";
|
||||
test("plans table page and single-plan route require authentication and enforce ownership", async () => {
|
||||
const { app, agent } = await setup();
|
||||
assert.equal((await agent.get("/plans")).status, 302);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const other = request.agent(app);
|
||||
await signup(other, "[email protected]");
|
||||
const made = await agent
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ name: "Owned plan", plan: blankPlan() });
|
||||
const id = made.body.plan.id;
|
||||
assert.equal((await agent.get("/plans")).status, 200);
|
||||
assert.equal((await agent.get("/plans.html")).status, 302);
|
||||
assert.equal(
|
||||
(await agent.get("/api/plans/" + id)).body.plan.name,
|
||||
"Owned plan",
|
||||
);
|
||||
assert.equal((await other.get("/api/plans/" + id)).status, 404);
|
||||
assert.equal((await request(app).get("/api/plans/" + id)).status, 401);
|
||||
assert.equal((await agent.get("/api/plans/not-a-uuid")).status, 404);
|
||||
});
|
||||
test("summary search, filters and pagination exclude full telemetry and other accounts", async () => {
|
||||
const { app, agent, db } = await setup();
|
||||
await signup(agent, "[email protected]");
|
||||
const other = request.agent(app);
|
||||
const { csrf } = await signup(other, "[email protected]");
|
||||
await other
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ name: "Hidden Ethiopia", plan: blankPlan() });
|
||||
const owner = (
|
||||
await db.query("SELECT id FROM users WHERE email=$1", ["[email protected]"])
|
||||
).rows[0].id;
|
||||
for (let i = 0; i < 62; i++) {
|
||||
const p = blankPlan();
|
||||
Object.assign(p.fields, {
|
||||
0.1: "Coffee " + i,
|
||||
1.3: i % 2 ? "Ethiopia" : "Burundi",
|
||||
3.1: i % 2 ? "natural" : "washed",
|
||||
4.1: i % 2 ? "light" : "medium",
|
||||
});
|
||||
p.reference = { curve: Array(30).fill({ t: 1, bt: 180 }) };
|
||||
await db.query(
|
||||
"INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3)",
|
||||
[owner, p, "Plan " + String(i).padStart(2, "0")],
|
||||
);
|
||||
}
|
||||
const first = (await agent.get("/api/plans?view=summary")).body;
|
||||
assert.equal(
|
||||
(await agent.get("/api/plans?view=summary&sort=constructor")).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await agent.get("/api/plans?view=summary&page=9007199254740991")).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(first.total, 62);
|
||||
assert.equal(first.plans.length, 25);
|
||||
assert.ok(!("plan" in first.plans[0]));
|
||||
const second = (await agent.get("/api/plans?view=summary&page=2")).body;
|
||||
assert.equal(second.plans.length, 25);
|
||||
assert.equal(
|
||||
first.plans.some((a) => second.plans.some((b) => a.id === b.id)),
|
||||
false,
|
||||
);
|
||||
const filtered = (
|
||||
await agent.get(
|
||||
"/api/plans?view=summary&q=ethiopia&process=natural&level=light",
|
||||
)
|
||||
).body;
|
||||
assert.equal(filtered.total, 31);
|
||||
assert.ok(filtered.plans.every((p) => p.origin === "Ethiopia"));
|
||||
assert.equal(
|
||||
(await agent.get("/api/plans?view=summary&level=dark")).body.total,
|
||||
0,
|
||||
);
|
||||
assert.equal((await agent.get("/api/plans?view=summary&page=0")).status, 400);
|
||||
assert.equal(
|
||||
(await agent.get("/api/plans?view=summary&limit=999")).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await agent.get("/api/plans?view=summary&q=%25")).body.total,
|
||||
0,
|
||||
);
|
||||
});
|
||||
+251
-87
@@ -3,93 +3,257 @@ import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { setup } from "./helpers.js";
|
||||
import { blankPlan } from "../shared/fields.js";
|
||||
import { renderReportHtml } from "../shared/report.js";
|
||||
|
||||
const browserOptions = { skip: process.env.RUN_BROWSER_TESTS !== "1" };
|
||||
async function session(t) {
|
||||
const { chromium } = await import("playwright");
|
||||
const { app, db } = await setup();
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(() => new Promise((r) => server.close(r)));
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1440, height: 1100 },
|
||||
});
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const res = await page.request.post(base + "/api/auth/signup", {
|
||||
data: {
|
||||
email: "[email protected]",
|
||||
password: "browser regression password",
|
||||
},
|
||||
});
|
||||
assert.equal(res.status(), 201);
|
||||
const { csrfToken } = await res.json();
|
||||
const create = async (plan, name = "") =>
|
||||
(
|
||||
await (
|
||||
await page.request.post(base + "/api/plans", {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: { plan, name },
|
||||
})
|
||||
).json()
|
||||
).plan;
|
||||
return { page, browser, base, create, errors, db, csrfToken };
|
||||
}
|
||||
function warningPlan() {
|
||||
const p = blankPlan();
|
||||
Object.assign(p.fields, {
|
||||
0.1: "Burundi Kayanza Masha — Anaerobic Natural Bourbon",
|
||||
0.4: "500",
|
||||
1.1: "Bourbon",
|
||||
1.4: "8:45",
|
||||
3.1: "anaerobic",
|
||||
4.1: "light",
|
||||
4.2: "12",
|
||||
4.3: "1:38",
|
||||
3.2: "-0:30",
|
||||
1.7: "-0:05",
|
||||
5.2: "76.14",
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
test(
|
||||
"browser: unit toggles preserve milestone inputs; HTML download and print share the report",
|
||||
{ skip: process.env.RUN_BROWSER_TESTS !== "1" },
|
||||
async (t) => {
|
||||
const { chromium } = await import("playwright");
|
||||
const { app } = await setup();
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(() => new Promise((r) => server.close(r)));
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1440, height: 1100 },
|
||||
});
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const signup = await page.request.post(base + "/api/auth/signup", {
|
||||
data: {
|
||||
email: "[email protected]",
|
||||
password: "browser regression password",
|
||||
},
|
||||
});
|
||||
assert.equal(signup.status(), 201);
|
||||
await page.goto(base + "/app");
|
||||
for (const [k, v] of Object.entries({
|
||||
0.1: "Burundi Kayanza Masha",
|
||||
0.4: "500",
|
||||
1.1: "Bourbon",
|
||||
1.4: "8:45",
|
||||
4.2: "12",
|
||||
4.3: "1:38",
|
||||
4.4: "1:15",
|
||||
5.2: "761.4",
|
||||
"preheat.tempC": "350",
|
||||
"preheat.minutes": "20",
|
||||
"temps.fc.tempC": "392",
|
||||
}))
|
||||
await page.locator(`#plan-form input[name="${k}"]`).fill(v);
|
||||
const temp = page.locator('#plan-form input[name="temps.fc.tempC"]');
|
||||
for (const [unit, value] of [
|
||||
["C", "200"],
|
||||
["F", "392"],
|
||||
]) {
|
||||
await page.evaluate((unit) => {
|
||||
const input = document.querySelector(
|
||||
`#temp-unit-toggle input[value="${unit}"]`,
|
||||
);
|
||||
input.checked = true;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, unit);
|
||||
assert.equal(await temp.inputValue(), value);
|
||||
assert.equal(
|
||||
await page.locator('#sec-machine input[name$=".tempC"]').count(),
|
||||
6,
|
||||
);
|
||||
}
|
||||
await page
|
||||
.getByRole("button", { name: "Preview report", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.frameLocator('iframe[title="Printable roast report"]')
|
||||
.getByRole("heading", { name: "Milestone plan" })
|
||||
.waitFor();
|
||||
await page.getByRole("button", { name: "Close preview" }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download HTML" }).click();
|
||||
const download = await pending;
|
||||
assert.match(download.suggestedFilename(), /Burundi.*\.html$/);
|
||||
const html = await readFile(await download.path(), "utf8");
|
||||
assert.ok(html.includes("440 g"));
|
||||
assert.ok(html.includes("350°F"));
|
||||
assert.ok(html.includes("Development · 1:15"));
|
||||
assert.ok(!html.includes("°C"));
|
||||
await page.emulateMedia({ media: "print" });
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("beforeprint")));
|
||||
assert.equal(await page.locator("#report-print").isVisible(), true);
|
||||
assert.equal(await page.locator("#print-sheet").isVisible(), false);
|
||||
assert.equal(await page.locator("#report-print .sheet").count(), 2);
|
||||
assert.equal(await page.locator("#report-print svg").count(), 1);
|
||||
await page.emulateMedia({ media: "screen" });
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("afterprint")));
|
||||
await page.goto(base + "/roasters");
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
"browser: warning-rich reports print on two actual PDF pages without a footer-only page",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { PDFDocument } = await import("pdf-lib");
|
||||
const { page, base, create, errors } = await session(t);
|
||||
const plan = warningPlan();
|
||||
const saved = await create(plan);
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page
|
||||
.getByRole("button", { name: "Preview report", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.locator("#sec-machine").count(), 0);
|
||||
assert.equal(await page.locator("#sec-roastlog").count(), 0);
|
||||
assert.equal(
|
||||
await page.locator('#plan-form input[name^="planActual."]').count(),
|
||||
0,
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Preview report", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.frameLocator('iframe[title="Printable roast report"]')
|
||||
.getByRole("heading", { name: "Milestone plan" })
|
||||
.waitFor();
|
||||
await page.getByRole("button", { name: "Close preview" }).click();
|
||||
const downloading = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download HTML" }).click();
|
||||
const download = await downloading;
|
||||
const html = await readFile(await download.path(), "utf8");
|
||||
assert.ok(html.includes("440 g"));
|
||||
assert.ok(html.includes("761.4"));
|
||||
assert.ok(html.includes("Development · 1:15"));
|
||||
assert.ok(!html.includes("°C"));
|
||||
let pdf = await page.pdf({
|
||||
preferCSSPageSize: true,
|
||||
printBackground: true,
|
||||
});
|
||||
assert.equal(
|
||||
(await PDFDocument.load(pdf)).getPageCount(),
|
||||
2,
|
||||
"in-app print must not orphan the footer",
|
||||
);
|
||||
for (const [label, content, options] of [
|
||||
["downloaded HTML", html, { preferCSSPageSize: true }],
|
||||
[
|
||||
"110% print scaling",
|
||||
renderReportHtml({
|
||||
...plan,
|
||||
fields: { ...plan.fields, 5.2: "761.4", 4.4: "1:15" },
|
||||
}),
|
||||
{ preferCSSPageSize: true, scale: 1.1 },
|
||||
],
|
||||
["A4 paper", html, { format: "A4", preferCSSPageSize: false }],
|
||||
]) {
|
||||
await page.setContent(content);
|
||||
pdf = await page.pdf({ ...options, printBackground: true });
|
||||
assert.equal((await PDFDocument.load(pdf)).getPageCount(), 2, label);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"browser: searchable plan table opens the selected plan and preserves edits across switches",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, errors, db } = await session(t);
|
||||
const first = warningPlan();
|
||||
const a = await create(first, "Fruit plan");
|
||||
const b = await create(
|
||||
{
|
||||
...blankPlan(),
|
||||
fields: {
|
||||
...blankPlan().fields,
|
||||
0.1: "Espresso coffee",
|
||||
0.4: "300",
|
||||
1.4: "8:00",
|
||||
4.3: "2:00",
|
||||
},
|
||||
},
|
||||
"Espresso plan",
|
||||
);
|
||||
await page.goto(base + "/app?plan=" + a.id);
|
||||
await page.locator('#plan-form input[name="0.4"]').fill("520");
|
||||
await page.getByRole("link", { name: "▤ Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
await page.locator("#plans-body .plan-title-link").first().waitFor();
|
||||
await page.locator('#plan-filters input[name="q"]').fill("Espresso");
|
||||
await page
|
||||
.getByRole("link", { name: "Espresso plan", exact: true })
|
||||
.click();
|
||||
await page.waitForURL("**/app?plan=" + b.id);
|
||||
await page.locator('#plan-form input[name="0.1"]').waitFor();
|
||||
assert.equal(
|
||||
await page.locator('#plan-form input[name="0.1"]').inputValue(),
|
||||
"Espresso coffee",
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator("#header-coffee-name").textContent(),
|
||||
"Espresso plan",
|
||||
);
|
||||
await page.getByRole("link", { name: "▤ Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
await page.getByRole("link", { name: "Fruit plan", exact: true }).click();
|
||||
await page.waitForURL("**/app?plan=" + a.id);
|
||||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||||
assert.equal(
|
||||
await page.locator('#plan-form input[name="0.4"]').inputValue(),
|
||||
"520",
|
||||
);
|
||||
const owner = (
|
||||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [a.id])
|
||||
).rows[0].user_id;
|
||||
for (let i = 0; i < 28; i++)
|
||||
await db.query(
|
||||
"INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3)",
|
||||
[owner, blankPlan(), "Library " + i],
|
||||
);
|
||||
await page.goto(base + "/plans");
|
||||
await page
|
||||
.locator("#plans-status")
|
||||
.filter({ hasText: "of 30 plans" })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.locator("#plans-body .plan-title-link").count(),
|
||||
25,
|
||||
);
|
||||
await page.getByRole("button", { name: "Next", exact: true }).click();
|
||||
await page.locator("#plans-status").filter({ hasText: "26–30" }).waitFor();
|
||||
assert.equal(await page.locator("#plans-body .plan-title-link").count(), 5);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"browser: Plan vs Actual is editable on finished roasts and survives reload",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db, errors } = await session(t);
|
||||
const plan = await create(warningPlan(), "Fruit plan");
|
||||
const owner = (
|
||||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [plan.id])
|
||||
).rows[0].user_id;
|
||||
const parsed = {
|
||||
roast: { title: "Finished fruit roast" },
|
||||
curve: [
|
||||
{ t: 0, bt: 200 },
|
||||
{ t: 60, bt: 90 },
|
||||
{ t: 450, bt: 190 },
|
||||
{ t: 525, bt: 200 },
|
||||
],
|
||||
milestones: [
|
||||
{ key: "fc", label: "First crack", timeS: 450, tempC: 190 },
|
||||
{ key: "drop", label: "Drop", timeS: 525, tempC: 200 },
|
||||
],
|
||||
derived: { firstCrackS: 450, dropS: 525, developmentS: 75 },
|
||||
};
|
||||
const row = (
|
||||
await db.query(
|
||||
"INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,evaluation_status) VALUES($1,$2,'fruit.alog','{}',$3,'failed') RETURNING id",
|
||||
[owner, plan.id, parsed],
|
||||
)
|
||||
).rows[0];
|
||||
await page.goto(base + "/roasts?roast=" + row.id);
|
||||
await page
|
||||
.getByRole("heading", { name: "Plan vs Actual", exact: true })
|
||||
.waitFor();
|
||||
const time = page.getByRole("textbox", {
|
||||
name: "First crack actual time",
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(await time.inputValue(), "7:30");
|
||||
await time.fill("7:40");
|
||||
await page
|
||||
.getByRole("textbox", { name: "First crack notes", exact: true })
|
||||
.fill("Audible");
|
||||
await page.getByRole("button", { name: "Save observations" }).click();
|
||||
await page
|
||||
.locator("#comparison-status")
|
||||
.filter({ hasText: "Observations saved" })
|
||||
.waitFor();
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole("textbox", { name: "First crack actual time", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await time.inputValue(), "7:40");
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("textbox", { name: "First crack notes", exact: true })
|
||||
.inputValue(),
|
||||
"Audible",
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import request from "supertest";
|
||||
import { setup, signup } from "./helpers.js";
|
||||
import { buildComparison } from "../shared/roast-comparison.js";
|
||||
test("comparison uses per-roast observations without changing planned or uploaded readings", () => {
|
||||
const detail = {
|
||||
plan: {
|
||||
fields: { 1.4: "8:00", 4.3: "1:30" },
|
||||
planActual: { fc: { note: "Legacy note" } },
|
||||
},
|
||||
parsed: { milestones: [{ key: "fc", timeS: 450, tempC: 190 }] },
|
||||
observations: { fc: { timeS: 460, tempC: 191, note: "Heard clearly" } },
|
||||
};
|
||||
const fc = buildComparison(detail).find((r) => r.key === "fc");
|
||||
assert.equal(fc.target.timeS, 480);
|
||||
assert.equal(fc.timeS, 460);
|
||||
assert.equal(fc.deltaS, -20);
|
||||
assert.equal(fc.note, "Heard clearly");
|
||||
assert.equal(detail.parsed.milestones[0].timeS, 450);
|
||||
assert.equal(
|
||||
buildComparison({ ...detail, observations: {} }).find((r) => r.key === "fc")
|
||||
.note,
|
||||
"Legacy note",
|
||||
);
|
||||
});
|
||||
test("roast observations persist per owner and reject invalid values", async () => {
|
||||
const { app, agent, db } = await setup();
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const other = request.agent(app);
|
||||
const o = await signup(other, "[email protected]");
|
||||
const owner = (
|
||||
await db.query("SELECT id FROM users WHERE email=$1", [
|
||||
"[email protected]",
|
||||
])
|
||||
).rows[0].id;
|
||||
const row = (
|
||||
await db.query(
|
||||
"INSERT INTO actual_roasts(user_id,filename,original_content,parsed) VALUES($1,'sample.alog','{}',$2) RETURNING id",
|
||||
[owner, { milestones: [{ key: "fc", timeS: 450, tempC: 190 }] }],
|
||||
)
|
||||
).rows[0];
|
||||
const observations = {
|
||||
fc: { timeS: 460, tempC: 191, note: "Audible first crack" },
|
||||
};
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.put("/api/roasts/" + row.id)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ observations })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
const saved = (await agent.get("/api/roasts/" + row.id)).body.roast;
|
||||
assert.deepEqual(saved.observations, observations);
|
||||
assert.equal(saved.parsed.milestones[0].timeS, 450);
|
||||
assert.equal(
|
||||
(
|
||||
await other
|
||||
.put("/api/roasts/" + row.id)
|
||||
.set("x-csrf-token", o.csrf)
|
||||
.send({ observations: {} })
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.put("/api/roasts/" + row.id)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ observations: { fc: { timeS: -1 } } })
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user