Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s
Test and deploy / test-and-deploy (push) Successful in 1m21s
This commit is contained in:
1 parent
1fd53091ac
commit
8c21750356
28 files changed
+3848
-384
No files matched your search
+3
-1
@@ -25,6 +25,7 @@ export async function setup(env = {}, appOptions = {}) {
|
||||
`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,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE plan_revisions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan_id uuid NOT NULL REFERENCES roast_plans(id) ON DELETE CASCADE,plan jsonb NOT NULL,name text NOT NULL DEFAULT '',created_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','');
|
||||
@@ -35,12 +36,13 @@ 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,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 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,manual_key text,plan_snapshot jsonb,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);
|
||||
CREATE TABLE user_gear(user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,brewers jsonb NOT NULL DEFAULT '[]',grinders jsonb NOT NULL DEFAULT '[]',updated_at timestamptz DEFAULT now())`,
|
||||
);
|
||||
await db.query("CREATE UNIQUE INDEX actual_roasts_manual_once ON actual_roasts(user_id,manual_key)");
|
||||
const app = createApp({
|
||||
db,
|
||||
root,
|
||||
|
||||
+459
-42
@@ -7,9 +7,9 @@ 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) {
|
||||
async function session(t, appOptions = {}) {
|
||||
const { chromium } = await import("playwright");
|
||||
const { app, db } = await setup();
|
||||
const { app, db } = await setup({}, appOptions);
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(() => new Promise((r) => server.close(r)));
|
||||
@@ -104,6 +104,7 @@ test(
|
||||
2,
|
||||
"in-app print must not orphan the footer",
|
||||
);
|
||||
const standalone = await page.context().browser().newPage();
|
||||
for (const [label, content, options] of [
|
||||
["downloaded HTML", html, { preferCSSPageSize: true }],
|
||||
[
|
||||
@@ -146,7 +147,7 @@ test(
|
||||
);
|
||||
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.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");
|
||||
@@ -163,7 +164,7 @@ test(
|
||||
await page.locator("#header-coffee-name").textContent(),
|
||||
"Espresso plan",
|
||||
);
|
||||
await page.getByRole("link", { name: "▤ Your plans", exact: true }).click();
|
||||
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);
|
||||
@@ -258,45 +259,461 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test('browser: Your Plans recovers a deleted remote draft without losing edits', browserOptions, async t => {
|
||||
const {page,base,create,db,errors}=await session(t);
|
||||
const saved=await create(warningPlan(),'Recovered fruit');
|
||||
await page.goto(base+'/app?plan='+saved.id);
|
||||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||||
await db.query('DELETE FROM roast_plans WHERE id=$1',[saved.id]);
|
||||
await page.locator('#plan-form input[name="0.4"]').fill('530');
|
||||
await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();
|
||||
await page.waitForURL('**/plans*');
|
||||
await page.getByRole('link',{name:'Recovered fruit',exact:true}).waitFor();
|
||||
const rows=(await db.query('SELECT id,plan FROM roast_plans')).rows;
|
||||
assert.equal(rows.length,1);assert.notEqual(rows[0].id,saved.id);assert.equal(rows[0].plan.fields['0.4'],'530');assert.deepEqual(errors,[]);
|
||||
});
|
||||
test(
|
||||
"browser: Your Plans recovers a deleted remote draft without losing edits",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db, errors } = await session(t);
|
||||
const saved = await create(warningPlan(), "Recovered fruit");
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||||
await db.query("DELETE FROM roast_plans WHERE id=$1", [saved.id]);
|
||||
await page.locator('#plan-form input[name="0.4"]').fill("530");
|
||||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
await page
|
||||
.getByRole("link", { name: "Recovered fruit", exact: true })
|
||||
.waitFor();
|
||||
const rows = (await db.query("SELECT id,plan FROM roast_plans")).rows;
|
||||
assert.equal(rows.length, 1);
|
||||
assert.notEqual(rows[0].id, saved.id);
|
||||
assert.equal(rows[0].plan.fields["0.4"], "530");
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
|
||||
test('browser: Your Plans saves to server when browser draft storage is full', browserOptions, async t => {
|
||||
const {page,base,create,db,errors}=await session(t);const saved=await create(warningPlan());
|
||||
await page.goto(base+'/app?plan='+saved.id);await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||||
await page.evaluate(()=>{const original=Storage.prototype.setItem;Storage.prototype.setItem=function(key,value){if(key.startsWith('roastPlannerPlan.v2:'))throw new DOMException('Storage full','QuotaExceededError');return original.call(this,key,value);};});
|
||||
await page.locator('#plan-form input[name="0.4"]').fill('540');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.waitForURL('**/plans*');
|
||||
assert.equal((await db.query('SELECT plan FROM roast_plans WHERE id=$1',[saved.id])).rows[0].plan.fields['0.4'],'540');assert.deepEqual(errors,[]);
|
||||
});
|
||||
test(
|
||||
"browser: Your Plans saves to server when browser draft storage is full",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db, errors } = await session(t);
|
||||
const saved = await create(warningPlan());
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||||
await page.evaluate(() => {
|
||||
const original = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (key, value) {
|
||||
if (key.startsWith("roastPlannerPlan.v2:"))
|
||||
throw new DOMException("Storage full", "QuotaExceededError");
|
||||
return original.call(this, key, value);
|
||||
};
|
||||
});
|
||||
await page.locator('#plan-form input[name="0.4"]').fill("540");
|
||||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
assert.equal(
|
||||
(await db.query("SELECT plan FROM roast_plans WHERE id=$1", [saved.id]))
|
||||
.rows[0].plan.fields["0.4"],
|
||||
"540",
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
|
||||
test('browser: rejected sync shows the cause and keeps the edited plan open', browserOptions, async t => {
|
||||
const {page,base,create,db}=await session(t);const saved=await create(warningPlan());await page.goto(base+'/app?plan='+saved.id);await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||||
await page.route('**/api/plans/'+saved.id,async route=>route.request().method()==='PUT'?route.fulfill({status:403,contentType:'application/json',body:JSON.stringify({ok:false,code:'csrf_failed'})}):route.continue());
|
||||
await page.locator('#plan-form input[name="0.4"]').fill('550');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.getByText(/Your sign-in session needs refreshing/).waitFor();assert.ok(page.url().includes('/app?plan='+saved.id));assert.equal((await db.query('SELECT COUNT(*) AS n FROM roast_plans')).rows[0].n,1);
|
||||
const draft=await page.evaluate(()=>JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>k.startsWith('roastPlannerPlan.v2:')&&!k.endsWith(':last-user')))));assert.equal(draft.plan.fields['0.4'],'550');
|
||||
});
|
||||
test(
|
||||
"browser: rejected sync shows the cause and keeps the edited plan open",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db } = await session(t);
|
||||
const saved = await create(warningPlan());
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||||
await page.route("**/api/plans/" + saved.id, async (route) =>
|
||||
route.request().method() === "PUT"
|
||||
? route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: false, code: "csrf_failed" }),
|
||||
})
|
||||
: route.continue(),
|
||||
);
|
||||
await page.locator('#plan-form input[name="0.4"]').fill("550");
|
||||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||||
await page.getByText(/Your sign-in session needs refreshing/).waitFor();
|
||||
assert.ok(page.url().includes("/app?plan=" + saved.id));
|
||||
assert.equal(
|
||||
(await db.query("SELECT COUNT(*) AS n FROM roast_plans")).rows[0].n,
|
||||
1,
|
||||
);
|
||||
const draft = await page.evaluate(() =>
|
||||
JSON.parse(
|
||||
localStorage.getItem(
|
||||
Object.keys(localStorage).find(
|
||||
(k) =>
|
||||
k.startsWith("roastPlannerPlan.v2:") && !k.endsWith(":last-user"),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.equal(draft.plan.fields["0.4"], "550");
|
||||
},
|
||||
);
|
||||
|
||||
test('browser: Clone opens an independent plan whose roast level can change', browserOptions, async t => {
|
||||
const {page,base,create,db,errors}=await session(t);const original=await create(warningPlan(),'Fruit original');await page.goto(base+'/plans');await page.getByRole('button',{name:'Clone Fruit original',exact:true}).click();await page.waitForURL('**/app?plan=*');await page.locator('#header-coffee-name').filter({hasText:'Fruit original (copy)'}).waitFor();
|
||||
const id=new URL(page.url()).searchParams.get('plan');assert.notEqual(id,original.id);await page.locator('#plan-form label').filter({has:page.locator('input[name="4.1"][value="medium"]')}).click();await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.waitForURL('**/plans*');await page.getByRole('link',{name:'Fruit original (copy)',exact:true}).waitFor();
|
||||
const rows=(await db.query('SELECT id,plan FROM roast_plans')).rows;assert.equal(rows.length,2);assert.equal(rows.find(r=>r.id===original.id).plan.fields['4.1'],'light');assert.equal(rows.find(r=>r.id===id).plan.fields['4.1'],'medium');assert.deepEqual(errors,[]);
|
||||
});
|
||||
test(
|
||||
"browser: Clone opens an independent plan whose roast level can change",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db, errors } = await session(t);
|
||||
const original = await create(warningPlan(), "Fruit original");
|
||||
await page.goto(base + "/plans");
|
||||
await page
|
||||
.getByRole("button", { name: "Clone Fruit original", exact: true })
|
||||
.click();
|
||||
await page.waitForURL("**/app?plan=*");
|
||||
await page
|
||||
.locator("#header-coffee-name")
|
||||
.filter({ hasText: "Fruit original (copy)" })
|
||||
.waitFor();
|
||||
const id = new URL(page.url()).searchParams.get("plan");
|
||||
assert.notEqual(id, original.id);
|
||||
await page
|
||||
.locator("#plan-form label")
|
||||
.filter({ has: page.locator('input[name="4.1"][value="medium"]') })
|
||||
.click();
|
||||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
await page
|
||||
.getByRole("link", { name: "Fruit original (copy)", exact: true })
|
||||
.waitFor();
|
||||
const rows = (await db.query("SELECT id,plan FROM roast_plans")).rows;
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(
|
||||
rows.find((r) => r.id === original.id).plan.fields["4.1"],
|
||||
"light",
|
||||
);
|
||||
assert.equal(rows.find((r) => r.id === id).plan.fields["4.1"], "medium");
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
|
||||
test('browser: source URL persists from manual entry and prefill, with undo and cloning', browserOptions, async t => {
|
||||
const {page,base,create,db,errors}=await session(t);const original=await create(warningPlan(),'Source test');await page.goto(base+'/app?plan='+original.id);
|
||||
await page.locator('#header-coffee-name').filter({hasText:'Source test'}).waitFor();const source=page.locator('#plan-form input[name="sourceUrl"]');await source.fill('https://example.com/manual-coffee');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.waitForURL('**/plans*');await page.getByRole('link',{name:'Source test',exact:true}).click();await page.locator('#header-coffee-name').filter({hasText:'Source test'}).waitFor();await source.waitFor();assert.equal(await source.inputValue(),'https://example.com/manual-coffee');
|
||||
await page.route('**/api/prefill',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({ok:true,fields:{},source:{finalUrl:'https://example.com/new-coffee'},warnings:[]})}));
|
||||
await page.locator('#nav-prefill').click();await page.locator('#prefill-url').fill('https://example.com/new-coffee');await page.locator('#prefill-go').click();await page.locator('#prefill-result').filter({hasText:'Applied 0 fields'}).waitFor();assert.equal(await source.inputValue(),'https://example.com/new-coffee');await page.locator('#prefill-undo').click();assert.equal(await source.inputValue(),'https://example.com/manual-coffee');await page.locator('#prefill-go').click();await page.locator('#prefill-result').filter({hasText:'Applied 0 fields'}).waitFor();await page.keyboard.press('Escape');
|
||||
await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.waitForURL('**/plans*');assert.equal((await db.query('SELECT plan FROM roast_plans WHERE id=$1',[original.id])).rows[0].plan.sourceUrl,'https://example.com/new-coffee');await page.getByRole('button',{name:'Clone Source test',exact:true}).click();await page.waitForURL('**/app?plan=*');await page.locator('#header-coffee-name').filter({hasText:'Source test (copy)'}).waitFor();await source.waitFor();assert.equal(await source.inputValue(),'https://example.com/new-coffee');assert.deepEqual(errors,[]);
|
||||
});
|
||||
test(
|
||||
"browser: source URL persists from manual entry and prefill, with undo and cloning",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db, errors } = await session(t);
|
||||
const original = await create(warningPlan(), "Source test");
|
||||
await page.goto(base + "/app?plan=" + original.id);
|
||||
await page
|
||||
.locator("#header-coffee-name")
|
||||
.filter({ hasText: "Source test" })
|
||||
.waitFor();
|
||||
const source = page.locator('#plan-form input[name="sourceUrl"]');
|
||||
await source.fill("https://example.com/manual-coffee");
|
||||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
await page.getByRole("link", { name: "Source test", exact: true }).click();
|
||||
await page
|
||||
.locator("#header-coffee-name")
|
||||
.filter({ hasText: "Source test" })
|
||||
.waitFor();
|
||||
await source.waitFor();
|
||||
assert.equal(
|
||||
await source.inputValue(),
|
||||
"https://example.com/manual-coffee",
|
||||
);
|
||||
await page.route("**/api/prefill", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
fields: {},
|
||||
source: { finalUrl: "https://example.com/new-coffee" },
|
||||
warnings: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.locator("#nav-prefill").click();
|
||||
await page.locator("#prefill-url").fill("https://example.com/new-coffee");
|
||||
await page.locator("#prefill-go").click();
|
||||
await page
|
||||
.locator("#prefill-result")
|
||||
.filter({ hasText: "Applied 0 fields" })
|
||||
.waitFor();
|
||||
assert.equal(await source.inputValue(), "https://example.com/new-coffee");
|
||||
await page.locator("#prefill-undo").click();
|
||||
assert.equal(
|
||||
await source.inputValue(),
|
||||
"https://example.com/manual-coffee",
|
||||
);
|
||||
await page.locator("#prefill-go").click();
|
||||
await page
|
||||
.locator("#prefill-result")
|
||||
.filter({ hasText: "Applied 0 fields" })
|
||||
.waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
assert.equal(
|
||||
(
|
||||
await db.query("SELECT plan FROM roast_plans WHERE id=$1", [
|
||||
original.id,
|
||||
])
|
||||
).rows[0].plan.sourceUrl,
|
||||
"https://example.com/new-coffee",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Clone Source test", exact: true })
|
||||
.click();
|
||||
await page.waitForURL("**/app?plan=*");
|
||||
await page
|
||||
.locator("#header-coffee-name")
|
||||
.filter({ hasText: "Source test (copy)" })
|
||||
.waitFor();
|
||||
await source.waitFor();
|
||||
assert.equal(await source.inputValue(), "https://example.com/new-coffee");
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"browser: Bean Bro conversation persists; proposed changes require review and can be undone or cloned",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db, errors } = await session(t, {
|
||||
runPlanChat: async () => ({
|
||||
reply: "Try ten extra seconds and compare sweetness.",
|
||||
model: "test:coach",
|
||||
suggestions: [
|
||||
{
|
||||
fields: { 4.4: "1:25" },
|
||||
reason: "Test development.",
|
||||
expected: "More sweetness.",
|
||||
evidenceRoastIds: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const saved = await create(warningPlan(), "Bro experiment");
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page
|
||||
.getByRole("button", { name: "Review this plan", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: "Apply changes", exact: true })
|
||||
.waitFor();
|
||||
const override = page.locator('#plan-form [name="4.4"]');
|
||||
assert.equal(await override.inputValue(), "");
|
||||
await page
|
||||
.getByRole("button", { name: "Apply changes", exact: true })
|
||||
.click();
|
||||
assert.equal(await override.inputValue(), "1:25");
|
||||
await page
|
||||
.getByRole("button", { name: "Undo changes", exact: true })
|
||||
.click();
|
||||
assert.equal(await override.inputValue(), "");
|
||||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||||
await page.waitForURL("**/plans*");
|
||||
await page
|
||||
.getByRole("link", { name: "Bro experiment", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.locator("#chat-thread")
|
||||
.filter({ hasText: "Try ten extra seconds" })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole("button", { name: "Create a variation", exact: true })
|
||||
.click();
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.pathname === "/app" && url.searchParams.get("plan") !== saved.id,
|
||||
);
|
||||
await page
|
||||
.locator("#header-coffee-name")
|
||||
.filter({ hasText: "(copy)" })
|
||||
.waitFor();
|
||||
assert.equal(await override.inputValue(), "1:25");
|
||||
const original = (
|
||||
await db.query("SELECT plan FROM roast_plans WHERE id=$1", [saved.id])
|
||||
).rows[0].plan;
|
||||
assert.equal(original.fields["4.4"], "");
|
||||
assert.equal(original.assistant.messages.length, 2);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"browser: Source URL has a full row and mobile Bean Bro is a dedicated tab",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, errors } = await session(t);
|
||||
const saved = await create(warningPlan());
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page.locator(".planner-layout").waitFor();
|
||||
const source = page.locator('#plan-form [name="sourceUrl"]'),
|
||||
coffee = page.locator('#plan-form [name="0.1"]');
|
||||
const desktop = await source.boundingBox(),
|
||||
coffeeBox = await coffee.boundingBox();
|
||||
assert.ok(desktop.width > 400);
|
||||
assert.ok(coffeeBox.width > 500);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(350);
|
||||
await page
|
||||
.getByRole("button", { name: "Ask Bean Bro", exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
assert.equal(await page.locator("#panel-chat").isVisible(), true);
|
||||
assert.equal(await page.locator(".planner-main").isVisible(), false);
|
||||
await page.getByRole("button", { name: "Plan", exact: true }).click();
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal(await page.locator(".planner-main").isVisible(), true);
|
||||
assert.ok((await source.boundingBox()).width > 280);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.body.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"browser: roast-day events survive an offline reload",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, errors } = await session(t);
|
||||
const saved = await create(warningPlan());
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page
|
||||
.getByRole("button", { name: "Roast day", exact: true })
|
||||
.waitFor();
|
||||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||||
await page.waitForFunction(
|
||||
() => navigator.serviceWorker.controller !== null,
|
||||
);
|
||||
await page.context().setOffline(true);
|
||||
await page.getByRole("button", { name: "Roast day", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Start roast", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("spinbutton", {
|
||||
name: "Charge actual Fahrenheit",
|
||||
exact: true,
|
||||
})
|
||||
.fill("345");
|
||||
await page
|
||||
.locator(".roast-event")
|
||||
.first()
|
||||
.getByRole("button", { name: "Mark event", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: "Close roast day", exact: true })
|
||||
.click();
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Roast day", exact: true }).click();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("spinbutton", {
|
||||
name: "Charge actual Fahrenheit",
|
||||
exact: true,
|
||||
})
|
||||
.inputValue(),
|
||||
"345",
|
||||
);
|
||||
await page.context().setOffline(false);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"browser: coffee history compares measured curves and quick tasting persists",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, db, errors } = await session(t);
|
||||
const saved = await create(warningPlan());
|
||||
const owner = (
|
||||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [saved.id])
|
||||
).rows[0].user_id;
|
||||
let first;
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const r = (
|
||||
await db.query(
|
||||
"INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,evaluation_status) VALUES($1,$2,$3,'{}',$4,'failed') RETURNING id",
|
||||
[
|
||||
owner,
|
||||
saved.id,
|
||||
`test-${i}.alog`,
|
||||
{
|
||||
roast: { title: `Batch ${i}` },
|
||||
curve: [
|
||||
{ timeS: 0, tempC: 200 },
|
||||
{ timeS: 60, tempC: 90 },
|
||||
{ timeS: 500, tempC: 190 },
|
||||
],
|
||||
derived: { firstCrackS: 450, developmentS: 50, dropS: 500 },
|
||||
},
|
||||
],
|
||||
)
|
||||
).rows[0];
|
||||
first ??= r.id;
|
||||
}
|
||||
await page.goto(base + "/roasts?roast=" + first);
|
||||
await page
|
||||
.getByRole("heading", { name: "Quick taste", exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.locator('#after-form [name="tasteBalance"]')
|
||||
.selectOption("Balanced");
|
||||
await page.locator('#after-form [name="sweetness"]').selectOption("4");
|
||||
await page.locator('#after-form [name="favorite"]').selectOption("yes");
|
||||
await page
|
||||
.getByRole("button", { name: "Save after-roast", exact: true })
|
||||
.click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#after-save").disabled === false,
|
||||
);
|
||||
await page.goto(
|
||||
base +
|
||||
"/coffees?coffee=" +
|
||||
encodeURIComponent(
|
||||
"coffee:" + warningPlan().fields["0.1"].toLowerCase(),
|
||||
),
|
||||
);
|
||||
await page.getByRole("heading", { name: "Your favorite batch" }).waitFor();
|
||||
await page
|
||||
.getByRole("checkbox", { name: "Compare Batch 1", exact: true })
|
||||
.check();
|
||||
await page
|
||||
.getByRole("checkbox", { name: "Compare Batch 2", exact: true })
|
||||
.check();
|
||||
await page
|
||||
.getByRole("button", { name: "Compare selected batches" })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("img", {
|
||||
name: "Measured roast curves, Fahrenheit versus time",
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(await page.locator(".comparison-chart polyline").count(), 2);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"browser: finishing manual roast day opens a tasting record",
|
||||
browserOptions,
|
||||
async (t) => {
|
||||
const { page, base, create, errors } = await session(t);
|
||||
const p = warningPlan();
|
||||
p.roastDay = {
|
||||
startedAt: Date.now() - 600000,
|
||||
events: {
|
||||
charge: { timeS: 0, tempF: "345" },
|
||||
fc: { timeS: 500, tempF: "360" },
|
||||
drop: { timeS: 580, tempF: "375" },
|
||||
},
|
||||
};
|
||||
const saved = await create(p, "Manual fruit batch");
|
||||
await page.goto(base + "/app?plan=" + saved.id);
|
||||
await page.getByRole("button", { name: "Roast day", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Finish & record tasting", exact: true })
|
||||
.click();
|
||||
await page.waitForURL("**/roasts?roast=*");
|
||||
await page
|
||||
.getByRole("heading", { name: "Quick taste", exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole("link", { name: "Download manual record" }).waitFor();
|
||||
assert.equal(
|
||||
await page.locator("#detail-download-updated").isVisible(),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,314 @@
|
||||
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";
|
||||
import { cleanSuggestions } from "../shared/workspace.js";
|
||||
test("workspace groups own coffee history and surfaces tasting, inventory and experiment actions", async () => {
|
||||
const { app, agent, db } = await setup();
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const other = request.agent(app);
|
||||
const { csrf: otherCsrf } = await signup(other, "[email protected]");
|
||||
await other
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", otherCsrf)
|
||||
.send({ plan: { ...blankPlan(), fields: { 0.1: "Private coffee" } } });
|
||||
const p = blankPlan();
|
||||
p.fields["0.1"] = "Burundi";
|
||||
p.experiment = { expected: "More sweetness" };
|
||||
const saved = (
|
||||
await agent.post("/api/plans").set("x-csrf-token", csrf).send({ plan: p })
|
||||
).body.plan;
|
||||
const owner = (
|
||||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [saved.id])
|
||||
).rows[0].user_id;
|
||||
await db.query(
|
||||
"INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,after,created_at) VALUES($1,$2,'batch.alog','{}',$3,$4,'2020-01-01')",
|
||||
[
|
||||
owner,
|
||||
saved.id,
|
||||
{ roast: { title: "Fruit batch" }, derived: { developmentS: 80 } },
|
||||
{ favorite: "yes", sweetness: "4" },
|
||||
],
|
||||
);
|
||||
const result = await agent.get("/api/workspace");
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.body.coffees.length, 1);
|
||||
assert.equal(result.body.coffees[0].roasts[0].after.favorite, "yes");
|
||||
assert.ok(result.body.actions.some((a) => a.kind === "taste"));
|
||||
assert.ok(result.body.actions.some((a) => a.kind === "experiment"));
|
||||
assert.equal((await request(app).get("/api/workspace")).status, 401);
|
||||
for (const route of ["/home", "/coffees"]) {
|
||||
assert.equal((await agent.get(route)).status, 200);
|
||||
assert.equal((await request(app).get(route)).status, 302);
|
||||
}
|
||||
assert.equal((await request(app).get("/workspace.html")).status, 302);
|
||||
});
|
||||
test("revision history is owned, restores planning inputs and retains current consumption and chat", async () => {
|
||||
const { app, agent } = await setup();
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const other = request.agent(app);
|
||||
const { csrf: otherCsrf } = await signup(other, "[email protected]");
|
||||
const p = blankPlan();
|
||||
p.fields["4.1"] = "light";
|
||||
const saved = (
|
||||
await agent
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: p, name: "Original" })
|
||||
).body.plan;
|
||||
p.fields["4.1"] = "medium";
|
||||
p.inventory.consumed = { weightG: 500 };
|
||||
p.assistant = { messages: [{ role: "user", content: "Remember this" }] };
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.put("/api/plans/" + saved.id)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: p, name: "Variation" })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
const path = "/api/plans/" + saved.id;
|
||||
const revisions = (await agent.get(path + "/revisions")).body.revisions;
|
||||
assert.equal(revisions.length, 1);
|
||||
assert.equal((await other.get(path + "/revisions")).status, 404);
|
||||
assert.equal((await request(app).get(path + "/revisions")).status, 401);
|
||||
assert.equal(
|
||||
(await agent.post(path + "/restore").send({ revisionId: revisions[0].id }))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await other
|
||||
.post(path + "/restore")
|
||||
.set("x-csrf-token", otherCsrf)
|
||||
.send({ revisionId: revisions[0].id })
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
const restored = await agent
|
||||
.post(path + "/restore")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ revisionId: revisions[0].id });
|
||||
assert.equal(restored.status, 200);
|
||||
assert.equal(restored.body.plan.plan.fields["4.1"], "light");
|
||||
assert.equal(restored.body.plan.plan.inventory.consumed.weightG, 500);
|
||||
assert.equal(
|
||||
restored.body.plan.plan.assistant.messages[0].content,
|
||||
"Remember this",
|
||||
);
|
||||
assert.equal((await agent.get(path + "/revisions")).body.revisions.length, 2);
|
||||
});
|
||||
test("Bean Bro uses only matching account evidence and filters proposed changes", async () => {
|
||||
let context;
|
||||
const { app, agent, db } = await setup(
|
||||
{},
|
||||
{
|
||||
runPlanChat: async (args) => {
|
||||
context = args;
|
||||
return {
|
||||
reply: "Try this",
|
||||
suggestions: [
|
||||
{
|
||||
fields: { 4.4: "1:25", 0.1: "Hijacked" },
|
||||
evidenceRoastIds: ["foreign-id"],
|
||||
},
|
||||
],
|
||||
model: "test:coach",
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const other = request.agent(app);
|
||||
const { csrf: otherCsrf } = await signup(other, "[email protected]");
|
||||
const p = blankPlan();
|
||||
p.fields["0.1"] = "Burundi";
|
||||
await other
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", otherCsrf)
|
||||
.send({ plan: p });
|
||||
const saved = (
|
||||
await agent.post("/api/plans").set("x-csrf-token", csrf).send({ plan: p })
|
||||
).body.plan;
|
||||
const owner = (
|
||||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [saved.id])
|
||||
).rows[0].user_id;
|
||||
await db.query(
|
||||
"INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,after) VALUES($1,$2,'owned.alog','{}',$3,$4)",
|
||||
[
|
||||
owner,
|
||||
saved.id,
|
||||
{ roast: { title: "Owned batch" } },
|
||||
{ cupNotes: "Juicy", sweetness: "4" },
|
||||
],
|
||||
);
|
||||
const result = await agent
|
||||
.post("/api/plan-chat")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({
|
||||
plan: p,
|
||||
messages: [{ role: "user", content: "Suggest an experiment" }],
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(context.evidence.roasts.length, 1);
|
||||
assert.equal(context.evidence.roasts[0].after.cupNotes, "Juicy");
|
||||
assert.deepEqual(result.body.suggestions[0].fields, { 4.4: "1:25" });
|
||||
assert.deepEqual(result.body.suggestions[0].evidenceRoastIds, []);
|
||||
assert.deepEqual(
|
||||
cleanSuggestions([
|
||||
{ fields: { 4.4: "nonsense", 4.1: "invalid", 4.2: "999" } },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
test("Bean Bro connection test is admin-only and uses the selected model", async () => {
|
||||
let called = false;
|
||||
const { app, agent } = await setup(
|
||||
{},
|
||||
{
|
||||
runPlanChat: async () => {
|
||||
called = true;
|
||||
return { reply: "Connected", model: "test:coach" };
|
||||
},
|
||||
},
|
||||
);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
assert.equal(
|
||||
(await agent.post("/api/admin/llm/test").set("x-csrf-token", csrf)).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
const admin = request.agent(app);
|
||||
const result = await admin.post("/api/auth/bootstrap").send({
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
email: "[email protected]",
|
||||
password: "a very secure admin password",
|
||||
});
|
||||
const token = result.body.csrfToken;
|
||||
assert.equal((await admin.post("/api/admin/llm/test")).status, 403);
|
||||
const checked = await admin
|
||||
.post("/api/admin/llm/test")
|
||||
.set("x-csrf-token", token);
|
||||
assert.equal(checked.status, 200);
|
||||
assert.equal(checked.body.model, "test:coach");
|
||||
});
|
||||
test("manual roast completion validates milestones, stays private and is idempotent", async () => {
|
||||
const { app, agent, db } = await setup();
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const other = request.agent(app);
|
||||
const { csrf: otherCsrf } = await signup(other, "[email protected]");
|
||||
const p = blankPlan();
|
||||
p.roastDay = {
|
||||
startedAt: Date.now() - 600000,
|
||||
events: {
|
||||
charge: { timeS: 0, tempF: "345" },
|
||||
fc: { timeS: 500, tempF: "360" },
|
||||
drop: { timeS: 580, tempF: "375" },
|
||||
},
|
||||
};
|
||||
const saved = (
|
||||
await agent.post("/api/plans").set("x-csrf-token", csrf).send({ plan: p })
|
||||
).body.plan;
|
||||
const route = "/api/roasts/manual";
|
||||
assert.equal(
|
||||
(await request(app).post(route).send({ planId: saved.id })).status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(await agent.post(route).send({ planId: saved.id })).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await other
|
||||
.post(route)
|
||||
.set("x-csrf-token", otherCsrf)
|
||||
.send({ planId: saved.id })
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
const result = await agent
|
||||
.post(route)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ planId: saved.id });
|
||||
assert.equal(result.status, 201);
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.post(route)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ planId: saved.id })
|
||||
).body.roast.id,
|
||||
result.body.roast.id,
|
||||
);
|
||||
const detail = (await agent.get("/api/roasts/" + result.body.roast.id)).body
|
||||
.roast;
|
||||
assert.equal(detail.roast.manual, true);
|
||||
assert.equal(detail.parsed.derived.developmentS, 80);
|
||||
assert.equal(
|
||||
(await other.get("/api/roasts/" + result.body.roast.id)).status,
|
||||
404,
|
||||
);
|
||||
const download = await agent.get(
|
||||
"/api/roasts/" + result.body.roast.id + "/download",
|
||||
);
|
||||
assert.equal(download.body.format, "roast-planner-manual-record");
|
||||
assert.equal(
|
||||
(
|
||||
await agent.get(
|
||||
"/api/roasts/" + result.body.roast.id + "/download?variant=updated",
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
p.roastDay.events.drop.timeS = 400;
|
||||
await agent
|
||||
.put("/api/plans/" + saved.id)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: p });
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.post(route)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ planId: saved.id })
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
test("manual roast targets remain captured when its source plan changes", async () => {
|
||||
const { agent } = await setup();
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const p = blankPlan();
|
||||
p.fields["4.1"] = "light";
|
||||
p.experiment = { expected: "More fruit" };
|
||||
p.roastDay = {
|
||||
startedAt: Date.now() - 600000,
|
||||
events: {
|
||||
fc: { timeS: 480, tempF: "360" },
|
||||
drop: { timeS: 560, tempF: "375" },
|
||||
},
|
||||
};
|
||||
const saved = (
|
||||
await agent.post("/api/plans").set("x-csrf-token", csrf).send({ plan: p })
|
||||
).body.plan;
|
||||
const r = (
|
||||
await agent
|
||||
.post("/api/roasts/manual")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ planId: saved.id })
|
||||
).body.roast;
|
||||
p.fields["4.1"] = "dark";
|
||||
p.experiment.expected = "More body";
|
||||
await agent
|
||||
.put("/api/plans/" + saved.id)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: p });
|
||||
const detail = (await agent.get("/api/roasts/" + r.id)).body.roast;
|
||||
assert.equal(detail.hasPlanSnapshot, true);
|
||||
assert.equal(detail.plan.fields["4.1"], "light");
|
||||
assert.equal(detail.plan.experiment.expected, "More fruit");
|
||||
});
|
||||
Reference in new issue
Block a user