From b5b2430c9143682de8a594063d70143aa0027513 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 17 Jul 2026 22:49:29 +0200 Subject: [PATCH] feat(boringstack): explicit, secure, correct CRUD UI+API contract in refine-prompt (kill hollow list-only UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boringstack build kept shipping a hollow feature: the UI scaffold is a list-only page plus stub hooks, and reachability only checked route + i18n keys, so a feature with no real create/edit/delete wiring passed as "verified". This rewrites the per-resource refine prompt into an explicit contract. API layer: full user-scoped CRUD (list/get-one/create/update/delete), each route under requireAuth passing user.id. SECURITY: get/update/delete must filter by BOTH row id AND authenticated userId (and(eq(id), eq(userId))) — an id-only lookup is horizontal privilege escalation. Service extends the scaffold's listForUser+create convention (never rename them). UI layer: real mutations (create/update/delete) + a real list query, a list that renders records with Edit/Delete, a Zod-validated create/edit form, and a delete confirmation — wired end to end (create → appears → edit → delete). Wiring the mutations is how the i18n error/confirm keys become "used" — never delete keys. Error idiom (was wrong): the typed api-client resolves HTTP 4xx/5xx as a result object { data, error } and does NOT throw. Both queries and mutations must `const { data, error } = await apiClient.…; if (error) throw error; return data;` or React Query runs onSuccess on failures and queries silently return undefined. domainFields (persistence columns + form inputs) is the entity's **Fields** only; **Display** is a rendering hint (may be computed/relationship/read-only) and must not become columns or inputs. Slice-aware, with a behavior fallback so no instruction dangles a "Product Context above" reference on the no-slice path. Required tests force the behavior (prose alone fails): API service tests must include an ownership-isolation test (user B cannot read/update/delete user A's row); the UI vitest must drive create AND edit AND delete from the rendered list, not just create. Addresses the reviewer panel's fresh findings: wrong error idiom (3/4 agree), unverified security clause, create-only UI test, fieldSource conflating Fields+Display, untested slice branch. Drops an unrelated .gitignore change. --- .../2026-07-17-ui-completeness-scaffold.md | 137 ++++++++++++++++++ .../src/loop/boringstack/refine-prompt.ts | 36 ++++- .../tests/boringstack-refine-prompt.test.ts | 109 ++++++++++++++ 3 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-17-ui-completeness-scaffold.md diff --git a/docs/superpowers/specs/2026-07-17-ui-completeness-scaffold.md b/docs/superpowers/specs/2026-07-17-ui-completeness-scaffold.md new file mode 100644 index 0000000..fe5d9dd --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-ui-completeness-scaffold.md @@ -0,0 +1,137 @@ +# Hollow-UI Completeness Fix — Design Spec (task #50) + +**Status:** step (1) — the refine-prompt CRUD contract (incl. API full-CRUD +requirement + UI test siblings) — is IMPLEMENTED and reviewer-gated. Steps (2) +mutations-first scaffold and (3) semantic reachability remain, pending an empirical +build to measure whether the contract alone suffices. + +## Problem (verified) + +The harness builds a BoringStack CRUD feature with a **complete API** (working +list/create routes + service, forced by a working scaffold + generated tests) but +a **hollow UI**: a list-only page with no create/edit/delete form, no confirmation, +no error/success toasts. The feature passes the gate anyway, and the model deletes +the translations it wrote (the i18n guard now blocks that, but the root remains). + +### Root cause — API/UI scaffold asymmetry + +| | API (`apps/api/scripts/codegen/new-resource.ts`) | UI (`apps/ui/scripts/codegen/new-feature.ts`) | +|---|---|---| +| Scaffold emits | WORKING list + create routes, service methods | BARE page (loading+empty only); `useCreate` STUB (returns input); empty query; no update/delete | +| Model's job | extend (add columns/logic) | **invent the entire UI from scratch** | +| Tests force it | generated route tests must pass | only a "renders heading" test | +| Reachability check | route mounted + schema table | UI route registered + `features..{title,empty}` present — **passes a hollow page** | +| Prompt | "service must implement listForUser + create" | "complete React feature slice" (vague) | + +`reachability.ts:checkFeatureReachable` (tsforge) checks only: UI route registered, +API mounted, i18n title/empty present. `refine-prompt.ts:111-112` is vague. So a +page that renders "loading… / nothing here yet" satisfies everything. + +## REVISED design (after 3-lens reviewer critique — 2026-07-17) + +The full Option B (below) was reviewed and DESCOPED. Findings: +- **Gate-greenness:** a full List/Form/DeleteConfirm tree = ~24 files each needing + the 5-sibling folder structure + no-state-in-tsx + no-inline-JSX-fns + complexity + ≤ 20 + no-dead-i18n-keys + test siblings. Very high risk of a RED baseline; Form + is the worst. "Mirror auth exactly" is insufficient. +- **Scope:** the real gap is the STUB mutations (`useCreate` returns input; no + update/delete). A rigid skeleton also fights diverse entities (relations, enums). +- **Robustness (adversarial):** every enforcement layer is gameable by a determined + drive-to-green model (keep placeholder field; delete scaffold files via `run`; + vacuous tests; import-without-call). Same class as the i18n `run`-bypass. + +**Decision:** don't chase airtight enforcement (that was the i18n 6-round spiral). +The model is LAZY, not adversarial — make the correct path the easy path: + +1. **refine-prompt contract** (tsforge, pure prompt — build FIRST, lowest risk): + replace vague "complete React feature slice" with an explicit per-field contract: + "For EACH field under Display, add a form field; wire create + edit + delete with + a confirm; surface errors via `t(features..Error)`." Turns the passive + nudge (refine-prompt.ts:165) into a contract. Cheap, high-leverage for a lazy model. +2. **mutations-first scaffold** (boringstack PR): real `useCreate/useUpdate/useDelete` + (+ `.mutations.test.ts` sibling with `vi.mock` asserting the call). Shape-agnostic, + ~1 file + test, low red-baseline risk. Removes the "invent CRUD wiring" gap. +3. **semantic reachability backstop** (tsforge, AST not substring): the feature page + must CALL a mutation + render a form — version-gated on a scaffold marker so it + can't false-fail builds on the old scaffold. +4. **DEFER** the full List/Form/DeleteConfirm component tree — too rigid + red-baseline + risk. The contract + working mutations anchor the model to build them. + +Sequencing: (1) now; (2) boringstack PR, verify a fresh `new:feature` is gate-green; +(3) after (2) bakes, gated on scaffold version. Incremental, small rollback surface. + +--- + +## Original Option B (superseded by the revised design above; kept for reference) + +## Fix — mirror the API: pre-generate a WORKING CRUD UI skeleton (Option B) + +Make `new:feature` emit a gate-green, working CRUD UI the model EXTENDS (adds +domain fields) rather than invents — exactly how the API scaffold works. Pair with +generated UI tests that assert the mutations fire, so completeness is forced (as the +API's tests force it), and a reachability tightening as a backstop. + +### What the scaffold emits (all gate-green out of the box) + +Grounded in the starter's real pattern (`auth/LoginCredentialsForm`, +`MfaChallengeForm` — react-hook-form + zod). Per feature ``: + +1. **`.mutations.ts`** — real `useCreate`, `useUpdate`, + `useDelete` (currently only a `useCreate` stub). Each invalidates the list + query on success; `onError` surfaces the error i18n key. +2. **`Form.tsx`** — create/edit form: one generic field (`name`) the model + renames/extends, react-hook-form + zod, submit → `useCreate`/`useUpdate`, error + rendered via `t("features..Error")`. +3. **`List.tsx`** — renders query data in a table/list: shows the generic + field + an Edit button (opens form) + a Delete button (opens confirm). +4. **`DeleteConfirm.tsx`** — confirm dialog → `useDelete`, `t(confirmDelete)`. +5. **`Page.tsx`** — composes List + Form (create/edit) + DeleteConfirm with + view state (list | creating | editing) — not just loading/empty. +6. **i18n keys** (seed in every locale + `wire-resource.ts:addFeatureI18nKeys`): + `title, empty, create, edit, delete, save, cancel, confirmDelete, + createError, updateError, deleteError, createSuccess, updateSuccess, + deleteSuccess` — all REFERENCED by the emitted components (so no unused-key + churn; the i18n guard then protects them). +7. **Tests** — `Form.test.tsx` (renders fields, submit calls the create + mutation), `List.test.tsx` (renders rows, delete opens confirm). These + FORCE the model to keep the wiring real (they fail if it strips the form). + +### tsforge-side backstop (`reachability.ts`) + +Tighten `checkFeatureReachable` to also require the feature slice references the +CRUD mutations (`useCreate` / `useUpdate` / `useDelete`) and a +form — so a stripped-back page fails reachability, not just the i18n rule. + +### refine-prompt + +Replace the vague "complete React feature slice" with the explicit contract: the +skeleton exists; extend the Form fields to the real domain fields, wire each to a +mutation, keep the List/Delete/error-handling. (Prose reinforces; scaffold+tests +enforce.) + +## Key risks (for reviewers) + +1. **The scaffold skeleton MUST be gate-green as the baseline** (strict lint: no + `as`, complexity ≤ 20, i18n every string, component-per-file, hooks rules). If + the generated skeleton is red, every build starts red. This is the hardest part + — mirror `auth` exactly. +2. **Generic field vs domain fields:** the skeleton ships one placeholder field + (`name`); the model must extend to real fields. Does one generic field remove + enough of the "invent from scratch" gap, or does the model still ship thin? +3. **Two repos:** scaffold change is a BoringStack PR (`new-feature.ts` + + `wire-resource` i18n seeds); the reachability tightening + prompt are tsforge. + Must land coordinated (scaffold first, else reachability tightening false-fails). +4. **Test-forces-completeness vs flakiness:** generated UI tests that assert + mutation calls must be deterministic (mock the client) and not brittle. + +## Alternatives considered + +- **(A) Reachability static-scan only** (no scaffold change): brittle regex on + JSX, and the model still invents from scratch → likely still thin. Weaker. +- **(C) Prompt-only:** proven insufficient (the model shortcuts under drive-to-green). + +## Rollout + +1. BoringStack PR: enhance `new-feature.ts` + i18n seeds; verify a fresh + `new:feature` is gate-green (typecheck/lint/test/build). 2. tsforge PR: + reachability tightening + refine-prompt. 3. Live build to measure UI realness. diff --git a/packages/core/src/loop/boringstack/refine-prompt.ts b/packages/core/src/loop/boringstack/refine-prompt.ts index f0d1017..1738c8e 100644 --- a/packages/core/src/loop/boringstack/refine-prompt.ts +++ b/packages/core/src/loop/boringstack/refine-prompt.ts @@ -87,6 +87,16 @@ export function refinePrompt(feature: IFeature, slice?: ISlice): string { const productContext = slice ? `\n\n${productContextSection(slice)}\n` : ""; + // Where the model reads the resource's PERSISTED/EDITABLE domain fields from — the + // columns to store and the form inputs to render. This is the entity's **Fields** + // ONLY: **Display** is a rendering hint (may be computed values, relationship + // labels, or a read-only subset) and must NOT be turned into columns or inputs. + // With a slice the Product Context section is present; without one, fall back to + // the behavior so no instruction dangles a reference to a section that wasn't emitted. + const domainFields = slice + ? "the entity's **Fields** in Product Context above (the **Display** list is for rendering only — do NOT treat it as columns or form inputs)" + : `the fields implied by the behavior "${feature.desc}"`; + return `You are implementing the **${feature.id}** resource. **Behavior**: ${feature.desc}${priorFailure}${productContext} @@ -98,27 +108,37 @@ export function refinePrompt(feature: IFeature, slice?: ISlice): string { You MUST fill in these generated files for the **${feature.id}** resource: ### Persistence (apps/api/src/clients/postgres/schema/app.schema.ts) -- The \`${camel}\` Drizzle table is generated with only stub columns (\`id\`, \`userId\`, \`name\`, timestamps). **Add the real domain columns to it** — one column per field listed under Product Context above (choose the right Drizzle type: \`varchar\`/\`text\` for strings, \`boolean().notNull().default(false)\` for booleans, \`integer\`/\`numeric\` for numbers, \`timestamp\` for dates; make \`[optional]\` fields nullable). These columns are what actually persists — the service and types must read/write REAL columns, never in-memory-only fields. +- The \`${camel}\` Drizzle table is generated with only stub columns (\`id\`, \`userId\`, \`name\`, timestamps). **Add the real domain columns to it** — one column per ${domainFields} (choose the right Drizzle type: \`varchar\`/\`text\` for strings, \`boolean().notNull().default(false)\` for booleans, \`integer\`/\`numeric\` for numbers, \`timestamp\` for dates; make \`[optional]\` fields nullable). These columns are what actually persists — the service and types must read/write REAL columns, never in-memory-only fields. - **Import every column builder you use.** If you add a \`boolean\`/\`text\`/\`integer\`/\`numeric\`/\`jsonb\` column, that identifier MUST be added to the existing \`import { ... } from "drizzle-orm/pg-core"\` line at the top of the file. A missing import is a \`ReferenceError\` that crashes the API on boot — the #1 cause of a failed build here. - Edit ONLY the \`${camel}\` table. Do NOT touch any other table in this file. ### API Layer (apps/api/src/api/${camel}/) -- \`apps/api/src/api/${camel}/${camel}.schemas.ts\` — request/response validation schemas using **Elysia TypeBox** (\`import { t } from "elysia"\` → \`t.Object({ title: t.String(), … })\`). This is the API boundary — do NOT use Zod here; Zod is only for UI form/runtime validation. +The UI needs FULL CRUD, so the API must expose it. The scaffold ships only list+create — you MUST add get-one, update, and delete so the UI's edit/delete have real endpoints (missing routes surface as typed api-client failures after \`generate:api\`). +- \`apps/api/src/api/${camel}/${camel}.routes.ts\` — Elysia routes for the full set: **list (\`GET /\`), get-one (\`GET /:id\`), create (\`POST /\`), update (\`PATCH /:id\`), delete (\`DELETE /:id\`)**. Every route stays under \`requireAuth()\` and passes \`user.id\` to the service on EVERY call. A schema on EVERY route; \`ApiErrors.*\` (never \`throw new Error\`). +- \`apps/api/src/api/${camel}/${camel}.service.ts\` — a user-scoped method backing each route, EXTENDING the scaffold's existing \`listForUser(userId)\` + \`create({ …, userId })\` convention (do NOT rename them — that breaks the baseline): add \`getForUser(id, userId)\`, \`updateForUser(id, userId, data)\`, \`deleteForUser(id, userId)\`. Real Drizzle ops, not stubs. + - **SECURITY (critical): every get/update/delete MUST filter by BOTH the row id AND the authenticated \`userId\`** — \`where: and(eq(${camel}.id, id), eq(${camel}.userId, userId))\` (import \`and\` and \`eq\` from \`drizzle-orm\` — a missing import is the same boot-crashing ReferenceError as a missing column builder). A row is owned by one user; an id-only query lets one user read, modify, or delete another user's records (horizontal privilege escalation). Never look up or mutate a row by id alone. +- \`apps/api/src/api/${camel}/${camel}.schemas.ts\` — request/response validation schemas for each operation (create body, update body, item response) using **Elysia TypeBox** (\`import { t } from "elysia"\` → \`t.Object({ title: t.String(), … })\`). This is the API boundary — do NOT use Zod here; Zod is only for UI form/runtime validation. - \`apps/api/src/api/${camel}/${camel}.types.ts\` — TypeScript types for domain entities and DTO objects -- \`apps/api/src/api/${camel}/${camel}.service.ts\` — Service layer business logic -- \`apps/api/src/api/${camel}/${camel}.routes.ts\` — Elysia routes: a schema on EVERY route; \`ApiErrors.*\` for errors (never \`throw new Error\`) ### UI Feature (apps/ui/src/features/${camel}/) -- The complete React feature slice for ${feature.id} (pages, components, hooks, state) +The generated feature is a HOLLOW starting point — a list-only page plus STUB hooks (\`use${feature.id}\` returns \`[]\`, \`useCreate${feature.id}\` just returns its input). A page that only lists records (or only shows an empty state) is an INCOMPLETE feature and must not be shipped. Build the REAL CRUD UI: +- **Mutations** (\`${feature.id}.mutations.ts\` — PascalCase file in \`apps/ui/src/features/${camel}/\`): implement real \`useCreate${feature.id}\`, \`useUpdate${feature.id}\`, and \`useDelete${feature.id}\` — each calls \`@/lib/api/client\` (\`apiClient.POST/PATCH/DELETE\`). **The typed api-client resolves HTTP 4xx/5xx as a result object \`{ data, error }\` — it does NOT throw.** So each mutationFn MUST \`const { data, error } = await apiClient.POST(…); if (error) throw error; return data;\` — otherwise React Query treats a failed request as a success and runs \`onSuccess\` on an error. Then invalidate the list query in \`onSuccess\`; surface the thrown failure via \`onError\`. Do NOT leave the stub that returns its input. + (Use the REAL domain fields of ${feature.id} — ${domainFields} — NOT a single placeholder \`name\`.) +- **List query** (\`${feature.id}.queries.ts\` — PascalCase file): implement \`use${feature.id}\` to actually fetch via \`apiClient.GET\` (the scaffold stub returns \`[]\`, so the list is permanently empty and create→appears-in-list is impossible until you do this). Same result-object rule as mutations: the queryFn MUST \`const { data, error } = await apiClient.GET(…); if (error) throw error; return data;\` — do NOT return \`data\` without the \`error\` check, or a failed fetch silently resolves to \`undefined\` and the query never enters its error state. +- **List**: render the fetched records (not just an empty state) — one row per record showing those domain fields, each row with **Edit** and **Delete** actions. +- **Create/Edit form**: one input per domain field. Validate with Zod; on submit call the create/update mutation; on error render \`t("features.${camel}.Error")\`. +- **Delete**: a confirmation using \`t("features.${camel}.confirmDelete")\` that calls \`useDelete${feature.id}\`. +- The UI must carry out the feature's flow end to end (create → it appears in the list → edit → delete). Wiring the mutations into a real form + list is what makes the i18n error/confirm keys "used" — that is the intended way to clear \`i18n-locale-keys-used\`, never by deleting keys. --- ## Required Test Siblings -The linter enforces test coverage. You MUST write: +The linter enforces test coverage — **every logic file you add or change needs a mirrored test sibling**, API and UI alike: -- \`apps/api/tests/api/${camel}/${camel}.routes.test.ts\` — API endpoint tests -- \`apps/api/tests/api/${camel}/${camel}.service.test.ts\` — Service layer unit tests +- \`apps/api/tests/api/${camel}/${camel}.routes.test.ts\` — API endpoint tests covering create/update/delete, not just list. +- \`apps/api/tests/api/${camel}/${camel}.service.test.ts\` — Service layer unit tests. **These MUST include an ownership-isolation test that PROVES the userId scoping above:** create a row as user A, then assert user B's \`getForUser\`/\`updateForUser\`/\`deleteForUser\` on that id find/affect NOTHING (returns not-found / 0 rows — the same as a missing id), and that user A still can. Without this test the id-only privilege-escalation bug passes the gate — prose alone does not stop it. +- \`apps/ui/src/features/${camel}/…\` — **vitest**, a co-located mirrored test for every UI logic file you write (mutations/hooks/form). Beyond unit-testing the mutations (mock \`@/lib/api/client\`, assert \`useCreate${feature.id}\`/\`useUpdate${feature.id}\`/\`useDelete${feature.id}\` call it), include a test that RENDERS the feature page and drives the REAL flow through the list: fill the create form and submit (assert the create mutation fires), then trigger a rendered row's **Edit** (assert the update mutation fires) and **Delete** confirmation (assert the delete mutation fires). A test that only checks the hooks call the client — or that exercises create alone — can pass while update/delete stay disconnected from the page and the feature is still hollow; it MUST drive edit and delete from the rendered list. Without these test files, the build will fail. diff --git a/packages/core/tests/boringstack-refine-prompt.test.ts b/packages/core/tests/boringstack-refine-prompt.test.ts index b2a905f..0b0726f 100644 --- a/packages/core/tests/boringstack-refine-prompt.test.ts +++ b/packages/core/tests/boringstack-refine-prompt.test.ts @@ -36,6 +36,67 @@ describe("refinePrompt", () => { expect(prompt).not.toContain("Zod schemas for request/response"); }); + it("demands a REAL CRUD UI (mutations + list + form + delete), not a hollow list-only page", () => { + const feature: IFeature = { + id: "Invoice", + desc: "Customer billing record", + passes: false, + attempts: 0, + }; + + const prompt = refinePrompt(feature); + + // The three real mutations must be named (the stub gap). + expect(prompt).toContain("useCreateInvoice"); + expect(prompt).toContain("useUpdateInvoice"); + expect(prompt).toContain("useDeleteInvoice"); + // A hollow list-only page is explicitly rejected. + expect(prompt).toContain("INCOMPLETE feature"); + // Form + delete confirmation are required. + expect(prompt).toContain("Create/Edit form"); + expect(prompt).toContain("confirmDelete"); + // The old vague phrasing is gone (would let the model ship anything). + expect(prompt).not.toContain("The complete React feature slice for"); + // The API must expose the full CRUD the UI mutations target (no contradiction). + expect(prompt).toContain("PATCH /:id"); + expect(prompt).toContain("DELETE /:id"); + // User-scoped, mirroring the scaffold convention (extend, don't rename). + expect(prompt).toContain("listForUser"); + expect(prompt).toContain("getForUser"); + // SECURITY: get/update/delete must filter by the authenticated userId. + expect(prompt.toLowerCase()).toContain("privilege escalation"); + expect(prompt).toContain("eq(invoice.userId, userId)"); + // The list QUERY must be really implemented (not left as the []-returning stub), + // and referenced by the PascalCase file name the scaffold emits (not camelCase). + expect(prompt).toContain("Invoice.queries.ts"); + expect(prompt).toContain("Invoice.mutations.ts"); + expect(prompt).not.toContain("invoice.mutations.ts"); + // UI logic files need mirrored test siblings, not just the two API tests. + expect(prompt).toContain("features/invoice/"); + }); + + it("UI contract has NO dangling 'above' references on the no-slice path", () => { + const feature: IFeature = { + id: "Invoice", + desc: "Customer billing record", + passes: false, + attempts: 0, + }; + + // refinePrompt(feature) WITHOUT a slice → the Product Context section (its + // "## Product Context" header + "### UI Intent") is not emitted, so the UI + // contract must not require reading fields from a section that isn't there. + const prompt = refinePrompt(feature); + + expect(prompt).not.toContain("## Product Context"); + expect(prompt).not.toContain("### UI Intent"); + // No bare "Product Context above" anchor anywhere (Persistence + UI both use the + // slice-aware fieldSource, so nothing points at a section that wasn't emitted). + expect(prompt).not.toContain("Product Context above"); + // The contract degrades to the behavior-based fallback (no dangling anchor). + expect(prompt).toContain("the fields implied by the behavior"); + }); + it("tells the model to WIRE UP an unused i18n key, never delete what it wrote", () => { const feature: IFeature = { id: "Invoice", @@ -285,6 +346,12 @@ describe("refinePrompt", () => { expect(p).toContain("belongsTo User"); expect(p).toContain("save → list"); // UI intent expect(p).toContain("url required"); // rule + // The slice branch of domainFields must actually be taken — a regression that + // always used the no-slice fallback would still pass the assertions above. + expect(p).toContain("the entity's **Fields** in Product Context above"); + expect(p).not.toContain("the fields implied by the behavior"); + // Display is a rendering hint, NOT columns/inputs (no domain-contract corruption). + expect(p).toContain("**Display** list is for rendering only"); }); it("refinePrompt without a slice is unchanged (contains id + desc)", () => { @@ -297,4 +364,46 @@ describe("refinePrompt", () => { expect(p).toContain("Bookmark"); }); + + it("teaches the result-object error idiom: check `error` and throw, never ignore it", () => { + const p = refinePrompt({ + id: "Invoice", + desc: "Customer billing record", + passes: false, + attempts: 0, + }); + + // The typed api-client returns { data, error } and does NOT throw on 4xx/5xx — + // both queries and mutations must check `error` and throw so React Query behaves. + expect(p).toContain("if (error) throw error"); + expect(p).toContain("does NOT throw"); + // The old, wrong instruction must be gone (it caused silent-undefined + onSuccess-on-error). + expect(p).not.toContain("never check `response.error`"); + }); + + it("forces an ownership-isolation test so the userId security clause is verified, not just prose", () => { + const p = refinePrompt({ + id: "Invoice", + desc: "Customer billing record", + passes: false, + attempts: 0, + }); + + expect(p).toContain("ownership-isolation test"); + expect(p).toContain("user B"); // another user cannot read/update/delete A's row + expect(p.toLowerCase()).toContain("privilege"); + }); + + it("requires the UI test to drive edit AND delete from the list, not just create", () => { + const p = refinePrompt({ + id: "Invoice", + desc: "Customer billing record", + passes: false, + attempts: 0, + }); + + expect(p).toContain("the update mutation fires"); + expect(p).toContain("the delete mutation fires"); + expect(p).toContain("MUST drive edit and delete from the rendered list"); + }); });