Academy v4: 3 research-backed lessons (30 slides + audio); new bean logo; custom plan/lot names
Test and deploy / test-and-deploy (push) Successful in 59s
Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ec078116b4
commit
8b80c3fa6f
+2
-2
@@ -24,13 +24,13 @@ export async function setup(env = {}, appOptions = {}) {
|
||||
await db.query(
|
||||
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz,avatar text);
|
||||
CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now(),user_agent text,ip text,last_seen_at timestamptz);
|
||||
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL);
|
||||
INSERT INTO app_settings VALUES('signup_enabled','true');
|
||||
INSERT INTO app_settings VALUES('llm_model','');
|
||||
CREATE TABLE password_reset_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now());
|
||||
CREATE TABLE audit_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,action text NOT NULL,target text,created_at timestamptz DEFAULT now());
|
||||
CREATE TABLE green_bean_lots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,origin text NOT NULL,variety text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',producer text NOT NULL DEFAULT '',purchase_date date,initial_weight_g numeric NOT NULL,remaining_weight_g numeric NOT NULL,cost_total numeric,moisture_pct numeric,density_g_l numeric,notes text NOT NULL DEFAULT '',archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE green_bean_lots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,origin text NOT NULL,name text NOT NULL DEFAULT '',variety text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',producer text NOT NULL DEFAULT '',purchase_date date,initial_weight_g numeric NOT NULL,remaining_weight_g numeric NOT NULL,cost_total numeric,moisture_pct numeric,density_g_l numeric,notes text NOT NULL DEFAULT '',archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE bean_consumption(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,weight_g numeric NOT NULL CHECK (weight_g > 0),created_at timestamptz DEFAULT now());
|
||||
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());
|
||||
|
||||
@@ -134,6 +134,61 @@ test("inventory: consume decrements remaining, allows negative, is idempotent pe
|
||||
assert.equal(withLog.body.log[1].planTitle, "Test coffee");
|
||||
});
|
||||
|
||||
test("inventory: custom name is created, updated, listed, and falls back cleanly when unset", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
|
||||
// No name given: defaults to "" so the client's `lot.name || lot.origin` fallback applies.
|
||||
const unnamed = await agent
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ origin: "Huila, Colombia", initialWeightG: 500 });
|
||||
assert.equal(unnamed.status, 201);
|
||||
assert.equal(unnamed.body.lot.name, "");
|
||||
assert.equal(unnamed.body.lot.origin, "Huila, Colombia");
|
||||
|
||||
// Named on create.
|
||||
const named = await agent
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ origin: "Yirgacheffe", name: " Spring Ethiopia ", initialWeightG: 300 });
|
||||
assert.equal(named.status, 201);
|
||||
assert.equal(named.body.lot.name, "Spring Ethiopia", "name is trimmed");
|
||||
|
||||
// Name is returned in both the list and the detail endpoints.
|
||||
const list = await agent.get("/api/inventory");
|
||||
const listedNamed = list.body.lots.find((l) => l.id === named.body.lot.id);
|
||||
assert.equal(listedNamed.name, "Spring Ethiopia");
|
||||
const listedUnnamed = list.body.lots.find((l) => l.id === unnamed.body.lot.id);
|
||||
assert.equal(listedUnnamed.name, "");
|
||||
const detail = await agent.get(`/api/inventory/${named.body.lot.id}`);
|
||||
assert.equal(detail.body.lot.name, "Spring Ethiopia");
|
||||
|
||||
// Rename via PUT; other fields are left untouched (partial-update pattern).
|
||||
const renamed = await agent
|
||||
.put(`/api/inventory/${unnamed.body.lot.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ name: "Reserve lot" });
|
||||
assert.equal(renamed.status, 200);
|
||||
assert.equal(renamed.body.lot.name, "Reserve lot");
|
||||
assert.equal(renamed.body.lot.origin, "Huila, Colombia");
|
||||
|
||||
// A PUT that omits `name` leaves the existing name unchanged.
|
||||
const untouched = await agent
|
||||
.put(`/api/inventory/${unnamed.body.lot.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ notes: "recheck moisture" });
|
||||
assert.equal(untouched.body.lot.name, "Reserve lot");
|
||||
|
||||
// Clearing the name back out is honored explicitly.
|
||||
const cleared = await agent
|
||||
.put(`/api/inventory/${unnamed.body.lot.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ name: "" });
|
||||
assert.equal(cleared.body.lot.name, "");
|
||||
});
|
||||
|
||||
test("inventory: consuming against another user's plan is refused", async () => {
|
||||
const { app } = await setup();
|
||||
const owner = request.agent(app);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import request from "supertest";
|
||||
import { setup, signup, password } from "./helpers.js";
|
||||
|
||||
async function bootstrapAdmin(agent) {
|
||||
const response = await agent.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
return response.body.csrfToken;
|
||||
}
|
||||
|
||||
test("plans: custom name is created, listed, and defaults to blank", async () => {
|
||||
const { agent } = await setup();
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
|
||||
const unnamed = await agent
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: { fields: { 0.1: "House blend" } } });
|
||||
assert.equal(unnamed.status, 201);
|
||||
assert.equal(unnamed.body.plan.name, "");
|
||||
|
||||
const named = await agent
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: { fields: { 0.1: "House blend" } }, name: " Winter batch " });
|
||||
assert.equal(named.status, 201);
|
||||
assert.equal(named.body.plan.name, "Winter batch", "name is trimmed");
|
||||
|
||||
const list = (await agent.get("/api/plans")).body.plans;
|
||||
assert.equal(list.find((p) => p.id === named.body.plan.id).name, "Winter batch");
|
||||
assert.equal(list.find((p) => p.id === unnamed.body.plan.id).name, "");
|
||||
});
|
||||
|
||||
test("plans: PUT name-only preserves the plan JSONB, and PUT plan-only preserves the name", async () => {
|
||||
const { agent } = await setup();
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
|
||||
const created = await agent
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: { fields: { 0.1: "Original title", "0.4": "300" } }, name: "First name" });
|
||||
const planId = created.body.plan.id;
|
||||
|
||||
// Name-only update: the plan JSONB is untouched.
|
||||
const renamed = await agent
|
||||
.put(`/api/plans/${planId}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ name: "Second name" });
|
||||
assert.equal(renamed.status, 200);
|
||||
assert.equal(renamed.body.plan.name, "Second name");
|
||||
assert.equal(renamed.body.plan.plan.fields["0.1"], "Original title");
|
||||
assert.equal(renamed.body.plan.plan.fields["0.4"], "300");
|
||||
|
||||
// Plan-only update: the name is untouched.
|
||||
const replanned = await agent
|
||||
.put(`/api/plans/${planId}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: { fields: { 0.1: "New title", "0.4": "350" } } });
|
||||
assert.equal(replanned.status, 200);
|
||||
assert.equal(replanned.body.plan.name, "Second name");
|
||||
assert.equal(replanned.body.plan.plan.fields["0.1"], "New title");
|
||||
|
||||
// Both at once.
|
||||
const both = await agent
|
||||
.put(`/api/plans/${planId}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: { fields: { 0.1: "Third title" } }, name: "Third name" });
|
||||
assert.equal(both.body.plan.name, "Third name");
|
||||
assert.equal(both.body.plan.plan.fields["0.1"], "Third title");
|
||||
|
||||
// Neither field present: bad request, same as the pre-existing plan-required behavior.
|
||||
assert.equal(
|
||||
(await agent.put(`/api/plans/${planId}`).set("x-csrf-token", csrf).send({})).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test("plans: ownership — a stranger cannot rename another user's plan", async () => {
|
||||
const { app } = await setup();
|
||||
const owner = request.agent(app);
|
||||
const stranger = request.agent(app);
|
||||
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
|
||||
const { csrf: strangerCsrf } = await signup(stranger, "[email protected]");
|
||||
|
||||
const created = await owner
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", ownerCsrf)
|
||||
.send({ plan: { fields: { 0.1: "Owner's plan" } } });
|
||||
const planId = created.body.plan.id;
|
||||
|
||||
const hijack = await stranger
|
||||
.put(`/api/plans/${planId}`)
|
||||
.set("x-csrf-token", strangerCsrf)
|
||||
.send({ name: "Hijacked" });
|
||||
assert.equal(hijack.status, 404);
|
||||
|
||||
// The plan is unaffected.
|
||||
const stillOwners = (await owner.get("/api/plans")).body.plans;
|
||||
assert.equal(stillOwners.find((p) => p.id === planId).name, "");
|
||||
});
|
||||
|
||||
test("plans: admin plan list title falls back to worksheet field 0.1 when no custom name is set", async () => {
|
||||
const { app } = await setup();
|
||||
const admin = request.agent(app);
|
||||
await bootstrapAdmin(admin);
|
||||
|
||||
const user = request.agent(app);
|
||||
const { csrf: userCsrf } = await signup(user, "[email protected]");
|
||||
|
||||
const named = await user
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", userCsrf)
|
||||
.send({ plan: { fields: { 0.1: "Worksheet title" } }, name: "Custom title" });
|
||||
const unnamed = await user
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", userCsrf)
|
||||
.send({ plan: { fields: { 0.1: "Worksheet title only" } } });
|
||||
|
||||
const list = (await admin.get("/api/admin/plans")).body.plans;
|
||||
assert.equal(list.find((p) => p.id === named.body.plan.id).title, "Custom title");
|
||||
assert.equal(
|
||||
list.find((p) => p.id === unnamed.body.plan.id).title,
|
||||
"Worksheet title only",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user