From 38c2de8809f1a40a3f4575fb6204cafce8f1b17e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:55:09 +0000 Subject: [PATCH] test(dogfood): pin five records-forms checklist items at the REST door (slice 2) Items action-param-widgets, cascading-multilevel-and-clear, cascading-options, conditional-rules-header and field-history-tracking. Five new dogfood pins on the showcase as shipped score the api clauses the 18.2.0 sweep left PARTIAL: the action param gate's body-never-ran echo, the two recorded cascade gaps pinned as they stand, both sides of the org_admin option gate, the invoice requiredWhen/readonlyWhen server half, and the trackHistory activity summary plus the derived history operation. Each item's automated.ref names its pin, revision +1, one history row. Claude-Session: https://claude.ai/code/session_01JQwxbZ3oTCkvGGTMNd5XYU Co-authored-by: Claude --- .../areas/records-forms.json | 50 ++++++-- ...ion-param-gallery-dispatch.dogfood.test.ts | 75 ++++++++++++ .../cascade-role-gated-option.dogfood.test.ts | 104 ++++++++++++++++ .../cascade-server-boundary.dogfood.test.ts | 104 ++++++++++++++++ ...-history-tracking-activity.dogfood.test.ts | 111 ++++++++++++++++++ ...e-conditional-rules-server.dogfood.test.ts | 87 ++++++++++++++ 6 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 packages/qa/dogfood/test/action-param-gallery-dispatch.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/cascade-role-gated-option.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/cascade-server-boundary.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/field-history-tracking-activity.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/invoice-conditional-rules-server.dogfood.test.ts diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 1216024ac56..a99407d63e8 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -848,7 +848,7 @@ "title": "Header-level conditional rules: visibleWhen / requiredWhen / readonlyWhen on invoice fields, enforced on BOTH ends", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "mixed", "personas": [ @@ -924,7 +924,7 @@ ], "automated": { "kind": "e2e", - "ref": "objectui: e2e/live/required-when-submit.spec.ts (acceptance[0]: a requiredWhen-required Issued On blocks submit with its field error) + e2e/live/field-conditional-rules.spec.ts (acceptance[0]'s required marker and acceptance[3], on the New Invoice dialog as Status flips: Issued On's required marker, and paid_on mounted, and required, only at paid). Neither pins acceptance[2]'s client lock: on that New dialog the spec asserts the OPPOSITE at paid, tax_rate enabled and no read-only field group, because the server exempts INSERT from readonlyWhen and the create form follows it (objectui#13436); the lock lives on an edit form of a paid invoice, which no pin drives. Both read the DOM through Playwright locators with no screenshot step: their wait for visibility is what stands in for the render-first order acceptance[0] and [3]'s dom oracle asks for, so a run citing them says so. No pin covers acceptance[1], [2], [4] or [5]; the row-action spec cited here until revision 3 was dropped (history, revision 3)" + "ref": "objectui: e2e/live/required-when-submit.spec.ts (acceptance[0]: a requiredWhen-required Issued On blocks submit with its field error) + e2e/live/field-conditional-rules.spec.ts (acceptance[0]'s required marker and acceptance[3], on the New Invoice dialog as Status flips: Issued On's required marker, and paid_on mounted, and required, only at paid). Neither pins acceptance[2]'s client lock: on that New dialog the spec asserts the OPPOSITE at paid, tax_rate enabled and no read-only field group, because the server exempts INSERT from readonlyWhen and the create form follows it (objectui#13436); the lock lives on an edit form of a paid invoice, which no pin drives. Both read the DOM through Playwright locators with no screenshot step: their wait for visibility is what stands in for the render-first order acceptance[0] and [3]'s dom oracle asks for, so a run citing them says so. packages/qa/dogfood/test/invoice-conditional-rules-server.dogfood.test.ts, at the REST data door of the showcase as shipped, pins acceptance[1] and negative[0] (a sent invoice with no issued_on is 400 VALIDATION_FAILED with exactly issued_on required and nothing is stored; the draft twin and a dated sent invoice are 201), acceptance[2]'s server half and negative[1] (a tax_rate PATCH on the seeded paid INV-1003 answers 200 and the re-read is unchanged; the same PATCH on a draft persists) and acceptance[5]'s API half. No pin covers acceptance[2]'s client lock, acceptance[4] or negative[2]; the row-action spec cited here until revision 3 was dropped (history, revision 3)" }, "source": [ "examples/app-showcase/src/data/objects/invoice.object.ts#userActions (the B2 rules + userActions gating, with server-semantics comments)", @@ -961,6 +961,12 @@ "date": "2026-10-26", "change": "automated.ref, from Tier 1 run #25815 (6679644a, pin b41ed7f68956), re-read at ui/e2e/live/field-conditional-rules.spec.ts on main cc378e93. (1) It claimed the spec pins acceptance[2]'s client lock; at paid on the New dialog the spec asserts tax_rate enabled and no read-only group, since INSERT is exempt from readonlyWhen (objectui#13436), and no pin drives an edit form of a paid invoice, so acceptance[2] joins the unpinned list. (2) It says both pins read the DOM by Playwright locator with no screenshot step. (3) It no longer names the row-action spec dropped at revision 3, which a ref parser read as a pin. fixtures.knownGaps[0], the objectui-pinned-automation note, said the objectui specs live in a separate repo and none exist here; they are under ui/ in this checkout since the import, so it says where they resolve and how they run (devx seat 2's direction, carried for #26035). Re-read on main cc378e93. No clause changed", "ref": "#25846" + }, + { + "revision": 6, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin for acceptance[1], acceptance[2]'s server half, acceptance[5]'s API half and negative[0]–[1] at the REST data door (#26573 slice 2). The client lock, acceptance[4] and negative[2] stay browser legs. No clause changed", + "ref": "#26573" } ] }, @@ -1105,7 +1111,7 @@ "title": "Cascading and gated select options: dependsOn + per-option visibleWhen, client narrows / server rejects", "since": "v16", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "mixed", "personas": [ @@ -1175,7 +1181,7 @@ ], "automated": { "kind": "e2e", - "ref": "objectui: e2e/live/cascading-options.spec.ts — its re-filter case pins acceptance[0]'s re-filter and clear halves ONLY (on the New form, country cn offers gd and zj, us offers ca and tx, and the chosen Zhejiang leaves the province trigger). It never reads the province before a country is chosen, so 'gates the dependent field until its driver has a value' is driven by hand. Its server case pins acceptance[1] (the out-of-set POST answers 400 VALIDATION_FAILED naming province with invalid_option, the in-set one 201). No pin covers acceptance[2], [3] or [4]" + "ref": "objectui: e2e/live/cascading-options.spec.ts — its re-filter case pins acceptance[0]'s re-filter and clear halves ONLY (on the New form, country cn offers gd and zj, us offers ca and tx, and the chosen Zhejiang leaves the province trigger). It never reads the province before a country is chosen, so 'gates the dependent field until its driver has a value' is driven by hand. Its server case pins acceptance[1] (the out-of-set POST answers 400 VALIDATION_FAILED naming province with invalid_option, the in-set one 201). packages/qa/dogfood/test/cascade-role-gated-option.dogfood.test.ts, at the REST data door of the showcase as shipped, pins acceptance[2]'s persistence and server halves and negative[1]: a signed-up member graded admin (positions read back with org_admin) stores tier restricted and it re-reads; a plain member (no org_admin) is 400 VALIDATION_FAILED naming tier with invalid_option, nothing stored, and stores tier standard as the control. It also pins acceptance[4] (cn/zj re-reads verbatim over GET). acceptance[2]'s 'sees' half (the picker) and acceptance[3] stay browser legs" }, "source": [ "examples/app-showcase/src/data/objects/cascading-select.object.ts (the B3 / #1583 fixture, with both-sides contract in its header comment)", @@ -1218,6 +1224,12 @@ "date": "2026-10-26", "change": "acceptance[1].verify says its pin is a Playwright live spec (Tier 1 run #25815 (6679644a, pin b41ed7f68956)): it scores acceptance[1] and negative[0] only when the run executes it against a booted showcase. fixtures.knownGaps[0], the objectui-pinned-automation note, said the objectui specs live in a separate repo and none exist here; they are under ui/ in this checkout since the import, so it says where they resolve and how they run (devx seat 2's direction, carried for #26035). Re-read on main cc378e93. No clause changed", "ref": "#25846" + }, + { + "revision": 7, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin for acceptance[2]'s server and persistence halves, acceptance[4] and negative[1] at the REST data door (#26573 slice 2). The org_admin persona is a signed-up member whose membership is graded admin, read back from the resolved positions; the seeded admin is org_owner and does not hold org_admin. No clause changed", + "ref": "#26573" } ] }, @@ -1506,7 +1518,7 @@ "title": "Action params render their real widgets, and the param contract is enforced at dispatch", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -1575,7 +1587,7 @@ ], "automated": { "kind": "api", - "ref": "packages/qa/dogfood/test/action-params-contract.dogfood.test.ts — acceptance[2] ONLY (a malformed param bag is refused before the action body runs; the conformant bag passes). No pin covers acceptance[0], [1] or [3]. The objectui spec cited here until revision 3 asserts none of the gallery action's param widgets and was dropped (history, revision 3); it is not a pin of this item" + "ref": "packages/qa/dogfood/test/action-params-contract.dogfood.test.ts (the refusal status of a three-way malformed bag) + packages/qa/dogfood/test/action-param-gallery-dispatch.dogfood.test.ts, at POST /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery of the showcase as shipped: acceptance[2] and negative[1] (the conformant bag answers 200 with the body's received echo naming p_text and p_priority; a bag with an out-of-set p_priority and an undeclared key is 400, names both, and carries no received echo, so the body never ran) and acceptance[3]'s server half (a bag missing only the required p_text is 400 naming p_text, with no echo). No pin covers acceptance[0], [1], acceptance[3]'s dialog half or negative[0]; they are browser legs. The objectui spec cited here until revision 3 was dropped (history, revision 3)" }, "source": [ "#3358 §4 (evidence table)", @@ -1607,6 +1619,12 @@ "date": "2026-10-26", "change": "automated.ref no longer names the objectui spec it dropped at revision 3. A ref parser read that path as a pin (Tier 1 run #25815 (6679644a, pin b41ed7f68956)), and it scores nothing here. The drop and its reason stay in history revision 3. What the ref pins is unchanged", "ref": "#25846" + }, + { + "revision": 5, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin that reads whether the action body ran (#26573 slice 2): acceptance[2] and negative[1] now assert the refusal carries no received echo, and acceptance[3]'s server half is pinned by a bag whose only fault is the missing p_text. The dialog half and the dom clauses stay unpinned. No clause changed", + "ref": "#26573" } ] }, @@ -4038,7 +4056,7 @@ "title": "Cascade clear-semantics and the server boundary: stale-child clear on parent change, the WRITTEN-value server gate, and where cascade scope is client-only", "since": "v16", "status": "active", - "revision": 8, + "revision": 9, "priority": "P1", "surface": "mixed", "personas": [ @@ -4116,7 +4134,7 @@ ], "automated": { "kind": "e2e", - "ref": "objectui: e2e/live/cascading-options.spec.ts (acceptance[0]'s client clear-on-change, through its re-filter case: country cn offers gd and zj, us offers ca and tx, and the chosen Zhejiang leaves the province trigger. It never submits, so 'the stale province is not carried into the submit' is driven by hand. Its second case, 'the server rejects an out-of-set option value, and accepts an in-set one', is acceptance[1] at the HTTP door: it POSTs {country:'us', province:'zj'} to the live backend and asserts 400 VALIDATION_FAILED with an invalid_option field on province, then {country:'cn', province:'zj'} and asserts 201)" + "ref": "objectui: e2e/live/cascading-options.spec.ts (acceptance[0]'s client clear-on-change, through its re-filter case: country cn offers gd and zj, us offers ca and tx, and the chosen Zhejiang leaves the province trigger. It never submits, so 'the stale province is not carried into the submit' is driven by hand. Its second case, 'the server rejects an out-of-set option value, and accepts an in-set one', is acceptance[1] at the HTTP door: it POSTs {country:'us', province:'zj'} to the live backend and asserts 400 VALIDATION_FAILED with an invalid_option field on province, then {country:'cn', province:'zj'} and asserts 201). packages/qa/dogfood/test/cascade-server-boundary.dogfood.test.ts, at the REST data door of the showcase as shipped, pins the two recorded gaps as they stand, so the day the server starts enforcing either one it goes red: acceptance[2] and negative[2] (create {cn, zj}, PATCH {country:'us'} answers 200 and the re-read still holds province zj; the same stale child IN the payload is the 400 invalid_option control, negative[0] and [1]), acceptance[4] and negative[2] (an invoice on Contoso naming an existing Northwind contact is 201 and stored as sent; a fabricated contact id is 400 reference_not_found and nothing is stored) and acceptance[5] (cn/zj and Northwind with a Northwind contact re-read verbatim). No pin covers acceptance[3], a browser leg" }, "source": [ "packages/objectql/src/validation/rule-validator.ts#evaluateOptionVisibility (evaluateOptionVisibility — WRITTEN-value only via `!(name in data) continue`; a predicate that cannot be evaluated refuses the write as rule_violation / unevaluable (ADR-0137 D2), except a system write with no acting user whose predicate reads one; invalid_option/option_unavailable)", @@ -4174,6 +4192,12 @@ "date": "2026-10-26", "change": "From Tier 1 run #25815 (6679644a, pin b41ed7f68956), re-read at ui/e2e/live/cascading-options.spec.ts on main cc378e93. (1) acceptance[1].verify expected 200 for the in-set write; it is a create and answers 201. The verify now also says the pin drives {country:'us', province:'zj'} rather than its cn/ca vector and asserts the invalid_option code alone, so the option_unavailable message key is the run's. (2) automated.ref no longer lists rule-validator.option-visibility.test.ts, which by its own words scores no clause (validator layer); acceptance[1].verify still names it, and acceptance[2].verify now does, as validator-layer evidence. No clause changed", "ref": "#25846" + }, + { + "revision": 9, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin for acceptance[2], [4], [5] and negative[2] at the REST data door (#26573 slice 2). The two KNOWN GAP clauses are pinned as recorded, 201/200 today, so new server enforcement turns them red to be filed rather than read as a pass. acceptance[0] and [1] keep the objectui pin; acceptance[3] stays a browser leg. No clause changed", + "ref": "#26573" } ] }, @@ -4816,7 +4840,7 @@ "title": "Per-field trackHistory: a tracked edit lands the ADR-0052 §5b old→new timeline summary with display values, an untracked edit does not, and object-level enable.trackHistory gates the History tab and derived history op both ways", "since": "v15", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "mixed", "personas": [ @@ -4883,7 +4907,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts (reference titles + read plan); audit-option-label-summary.test.ts (option labels); objectui e2e/live/record-history-display.spec.ts (the History tab's display contract, which negative[3] assigns to records-forms.crud-roundtrip. It opens showcase_account's History tab in a browser, the enable.trackHistory object, so it holds acceptance[2]'s flag-true half; it never reads the task detail, so the absence half has no pin and is driven by hand)" + "ref": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts (reference titles + read plan); audit-option-label-summary.test.ts (option labels); objectui e2e/live/record-history-display.spec.ts (the History tab's display contract, which negative[3] assigns to records-forms.crud-roundtrip. It opens showcase_account's History tab in a browser, the enable.trackHistory object, so it holds acceptance[2]'s flag-true half; it never reads the task detail, so the absence half has no pin and is driven by hand). packages/qa/dogfood/test/field-history-tracking-activity.dogfood.test.ts, at the REST doors of the showcase with AuditPlugin added as objectstack serve composes it: acceptance[0] and negative[2] (PATCH industry retail → finance lands a sys_activity row whose summary is exactly 'Industry: Retail → Finance', message key fieldChanges), acceptance[1] and negative[0] (a website-only PATCH lands the generic frame naming the Website label with no arrow and neither URL, message key activityUpdatedFields) and acceptance[3] (GET /api/v1/auth/me/permissions: showcase_account's apiOperations carry history, showcase_task's carry get and not history). acceptance[4] stays the unit pin's" }, "source": [ "packages/plugins/plugin-audit/src/audit-writers.ts#planTrackedLookupReads (planTrackedLookupReads), (renderTrackedChangeSummary — ADR-0052 §5b, #7230/#7289 localization), (zero-reads-by-default plan)", @@ -4933,6 +4957,12 @@ "date": "2026-10-26", "change": "automated.ref: record-history-display.spec.ts does hold acceptance[2]'s flag-true half, since it opens showcase_account's History tab in a browser; only the task-detail absence half is unpinned (Tier 1 run #25815 (6679644a, pin b41ed7f68956); re-read at ui/e2e/live/record-history-display.spec.ts on main cc378e93). No clause changed", "ref": "#25846" + }, + { + "revision": 8, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin for acceptance[0], [1], [3] and negative[0], [2] over REST (#26573 slice 2). Measured: the untracked edit's row is the #25787 generic frame, which names the changed field's label and never its values, so acceptance[1]'s 'no field-diff summary naming website' holds as 'no old → new for website'. acceptance[2]'s absence half and negative[1] stay browser legs. No clause changed", + "ref": "#26573" } ] }, diff --git a/packages/qa/dogfood/test/action-param-gallery-dispatch.dogfood.test.ts b/packages/qa/dogfood/test/action-param-gallery-dispatch.dogfood.test.ts new file mode 100644 index 00000000000..9c7cc2e0f19 --- /dev/null +++ b/packages/qa/dogfood/test/action-param-gallery-dispatch.dogfood.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.action-param-widgets` (#26573): the two api +// clauses, at the dispatch door of the booted showcase — +// `POST /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery`. +// +// acceptance[2], negative[1] — the declared param contract is enforced +// BEFORE the body runs: a malformed bag is refused 400 and its answer +// carries no body output; the conformant bag passes and the body echoes +// the keys it received. +// acceptance[3] (its server half) — a bag missing ONLY the required +// `p_text` is refused server-side, naming `p_text`, and the body never ran. +// +// `action-params-contract.dogfood.test.ts` already proves the refusal status +// of a three-way malformed bag; what it does not read is whether the body ran, +// and it never sends a bag whose ONLY fault is the missing required param. +// The gallery action's body has no side effect: it returns +// `{ ok: true, received: Object.keys(input) }`. So "the body ran" is +// observable in exactly one place, its `received` echo, and every refusal +// below asserts that echo is absent. The dialog half of acceptance[3] and +// the dom clauses (acceptance[0], [1], negative[0]) stay browser legs. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const ACTION_PATH = '/actions/showcase_field_zoo/showcase_action_param_gallery'; + +type Answer = { status: number; json: any; text: string }; + +describe('records-forms.action-param-widgets — the param contract at the dispatch door', () => { + let stack: VerifyStack; + let token = ''; + + const dispatch = async (params: Record): Promise => { + const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params }); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + + beforeAll(async () => { + delete process.env.OS_ALLOW_LAX_ACTION_PARAMS; + stack = await bootShowcase(); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[2]: the conformant bag passes and the body echoes the keys it received', async () => { + const ok = await dispatch({ p_text: 'Hello', p_priority: 'high' }); + expect(ok.status, ok.text).toBe(200); + expect(ok.text).toContain('received'); + expect(ok.text).toContain('p_text'); + expect(ok.text).toContain('p_priority'); + }); + + it('acceptance[2], negative[1]: a malformed bag is refused 400 and the body never ran', async () => { + const bad = await dispatch({ p_text: 'Hello', p_priority: 'NOT_AN_OPTION', bogus: 123 }); + expect(bad.status, bad.text).toBe(400); + expect(bad.text).toMatch(/p_priority/); + expect(bad.text).toMatch(/bogus/); + expect(bad.text, 'the refusal carries the body\'s echo').not.toContain('received'); + }); + + it('acceptance[3]: a bag missing only the required p_text is refused server-side, naming it, and the body never ran', async () => { + const missing = await dispatch({ p_priority: 'high' }); + expect(missing.status, missing.text).toBe(400); + expect(missing.text).toMatch(/p_text/); + expect(missing.text, 'the refusal carries the body\'s echo').not.toContain('received'); + }); +}); diff --git a/packages/qa/dogfood/test/cascade-role-gated-option.dogfood.test.ts b/packages/qa/dogfood/test/cascade-role-gated-option.dogfood.test.ts new file mode 100644 index 00000000000..6ce2a9a6ff6 --- /dev/null +++ b/packages/qa/dogfood/test/cascade-role-gated-option.dogfood.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.cascading-options` (#26573): its api clauses +// at the REST data door of the booted showcase, as shipped. +// +// acceptance[2], negative[1] — BOTH sides of the role gate on +// `showcase_cascade.tier`, whose `restricted` option carries +// `visibleWhen: 'org_admin' in current_user.positions`: a member holding +// org_admin stores and re-reads tier 'restricted'; a plain member's direct +// POST is refused invalid_option and nothing is stored. The seeded admin +// is the org OWNER (org_owner, which does not imply org_admin), so the +// admin persona here is a signed-up member whose membership is graded +// `admin` — what accepting an admin invitation leaves (the grade +// `org-admin-affordance-reach.dogfood.test.ts` measures the same way). +// acceptance[4] — a legal cascade selection (cn / zj) re-reads verbatim. +// +// The picker narrowing (acceptance[0]) and the contact picker's re-query +// (acceptance[3]) stay browser legs; acceptance[1] is pinned by the objectui +// live spec's server case. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { context: { isSystem: true } } as const; +const CASCADE = 'showcase_cascade'; + +type Answer = { status: number; json: any }; + +describe('records-forms.cascading-options — the role-gated option and persistence over REST', () => { + let stack: VerifyStack; + let ql: any; + let adminTok = ''; + let orgAdminTok = ''; + let memberTok = ''; + + const call = async (token: string, method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + const uniq = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + + /** The sign-up reconciler's membership row lands after the sign-up transaction. */ + const membershipOf = async (email: string) => { + const [user] = await ql.find('sys_user', { where: { email }, limit: 1 }, SYS); + for (let i = 0; i < 40; i++) { + const [row] = await ql.find('sys_member', { where: { user_id: user.id }, limit: 1 }, SYS); + if (row) return row; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${email}`); + }; + const positionsOf = async (token: string): Promise => (await stack.contextFor(token)).positions ?? []; + + beforeAll(async () => { + stack = await bootShowcase(); + adminTok = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + orgAdminTok = await stack.signUp('cro-admin@verify.test', 'Cro!Pass12345', 'Cascade org admin'); + const grade = await membershipOf('cro-admin@verify.test'); + await ql.update('sys_member', { id: grade.id, role: 'admin' }, SYS); + memberTok = await stack.signUp('cro-member@verify.test', 'Cro!Pass12345', 'Cascade member'); + await membershipOf('cro-member@verify.test'); + + // The personas are what the clause says they are, read off the resolved context. + expect(await positionsOf(orgAdminTok)).toContain('org_admin'); + expect(await positionsOf(memberTok)).not.toContain('org_admin'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[2]: a member holding org_admin stores tier restricted, and it re-reads', async () => { + const name = uniq('cro-restricted'); + const created = await call(orgAdminTok, 'POST', `/data/${CASCADE}`, { name, tier: 'restricted' }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + const [row] = await stack.rows(CASCADE, { name }); + expect(row?.tier).toBe('restricted'); + }); + + it('acceptance[2], negative[1]: a plain member\'s direct tier restricted POST is refused invalid_option, and nothing is stored', async () => { + const name = uniq('cro-denied'); + const denied = await call(memberTok, 'POST', `/data/${CASCADE}`, { name, tier: 'restricted' }); + expect(denied.status, JSON.stringify(denied.json)).toBe(400); + expect(denied.json.code).toBe('VALIDATION_FAILED'); + expect((denied.json.fields ?? []).map((f: any) => [f.field, f.code])).toEqual([['tier', 'invalid_option']]); + expect(await stack.rows(CASCADE, { name })).toHaveLength(0); + // Control: the same member stores the ungated option. + const standard = await call(memberTok, 'POST', `/data/${CASCADE}`, { name: uniq('cro-standard'), tier: 'standard' }); + expect(standard.status, JSON.stringify(standard.json)).toBe(201); + }); + + it('acceptance[4]: a legal cascade selection re-reads verbatim', async () => { + const name = uniq('cro-legal'); + const created = await call(adminTok, 'POST', `/data/${CASCADE}`, { name, country: 'cn', province: 'zj' }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + const [row] = await stack.rows(CASCADE, { name }); + const read = await call(adminTok, 'GET', `/data/${CASCADE}/${row.id}`); + expect(read.status).toBe(200); + expect(read.json.record ?? read.json).toMatchObject({ name, country: 'cn', province: 'zj' }); + }); +}); diff --git a/packages/qa/dogfood/test/cascade-server-boundary.dogfood.test.ts b/packages/qa/dogfood/test/cascade-server-boundary.dogfood.test.ts new file mode 100644 index 00000000000..798419a597f --- /dev/null +++ b/packages/qa/dogfood/test/cascade-server-boundary.dogfood.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.cascading-multilevel-and-clear` (#26573): its +// api clauses at the REST data door of the booted showcase, as shipped — +// `showcase_cascade` (country → province, per-option visibleWhen) and the +// invoice's dependent `contact` lookup (dependsOn ['account']). +// +// acceptance[2], negative[2] — RECORDED GAP: a parent-only PATCH does not +// revalidate the unwritten child. Create {cn, zj}, PATCH {country: 'us'} +// → 200, and the row still holds province 'zj'. +// acceptance[4], negative[2] — RECORDED GAP: lookup cascade scope is not +// server-enforced. An invoice on Contoso naming a Northwind contact that +// exists is stored; a fabricated contact id is refused reference_not_found. +// acceptance[5] — a legal selection on each cascade re-reads verbatim. +// +// The two gap cases pin TODAY's behaviour on purpose, exactly as the item +// records it: the day the server starts revalidating the stale child or +// scoping the contact, these go red, which is the item's own instruction — +// "a run that shows the server auto-clearing/rejecting … would be NEW +// enforcement to file, not a pass here" — made mechanical. The client clear +// (acceptance[0]) and the picker re-query (acceptance[3]) stay browser legs. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +type Answer = { status: number; json: any }; + +describe('records-forms.cascading-multilevel-and-clear — the server boundary over REST', () => { + let stack: VerifyStack; + let token = ''; + let northwindId = ''; + let contosoId = ''; + let northwindContactId = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + const uniq = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + const record = (json: any) => json?.record ?? json; + const idOf = async (object: string, name: string) => String((await stack.rows(object, { name }))[0]?.id ?? ''); + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + northwindId = await idOf('showcase_account', 'Northwind'); + contosoId = await idOf('showcase_account', 'Contoso'); + expect(northwindId && contosoId, 'the showcase seed carries Northwind and Contoso').toBeTruthy(); + const [contact] = await stack.rows('showcase_contact', { account: northwindId }); + expect(contact, 'the showcase seed carries a Northwind contact').toBeTruthy(); + northwindContactId = String(contact.id); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[2], negative[2]: a parent-only PATCH is accepted and the stale child is kept (recorded gap)', async () => { + const name = uniq('csb-stale'); + const created = await call('POST', '/data/showcase_cascade', { name, country: 'cn', province: 'zj' }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + const id = await idOf('showcase_cascade', name); + + const patched = await call('PATCH', `/data/showcase_cascade/${id}`, { country: 'us' }); + expect(patched.status, JSON.stringify(patched.json)).toBe(200); + const read = await call('GET', `/data/showcase_cascade/${id}`); + expect(read.status).toBe(200); + expect(record(read.json)).toMatchObject({ country: 'us', province: 'zj' }); + + // Control: the same stale child IN the payload is the written-value gate's to refuse. + const written = await call('PATCH', `/data/showcase_cascade/${id}`, { country: 'us', province: 'zj' }); + expect(written.status, JSON.stringify(written.json)).toBe(400); + expect((written.json.fields ?? []).map((f: any) => [f.field, f.code])).toEqual([['province', 'invalid_option']]); + }); + + it('acceptance[4], negative[2]: a cross-account contact that exists is stored (recorded gap); a fabricated one is reference_not_found', async () => { + const crossName = uniq('csb-cross'); + const cross = await call('POST', '/data/showcase_invoice', { name: crossName, account: contosoId, contact: northwindContactId }); + expect(cross.status, JSON.stringify(cross.json)).toBe(201); + const [stored] = await stack.rows('showcase_invoice', { name: crossName }); + expect(stored).toMatchObject({ account: contosoId, contact: northwindContactId }); + + const fakeName = uniq('csb-fake'); + const fake = await call('POST', '/data/showcase_invoice', { name: fakeName, account: contosoId, contact: 'no_such_contact_26573' }); + expect(fake.status, JSON.stringify(fake.json)).toBe(400); + expect((fake.json.fields ?? []).map((f: any) => [f.field, f.code])).toEqual([['contact', 'reference_not_found']]); + expect(await stack.rows('showcase_invoice', { name: fakeName })).toHaveLength(0); + }); + + it('acceptance[5]: a legal selection on each cascade re-reads verbatim', async () => { + const name = uniq('csb-legal'); + const created = await call('POST', '/data/showcase_cascade', { name, country: 'cn', province: 'zj' }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + const read = await call('GET', `/data/showcase_cascade/${await idOf('showcase_cascade', name)}`); + expect(record(read.json)).toMatchObject({ name, country: 'cn', province: 'zj' }); + + const invoiceName = uniq('csb-twin'); + const invoice = await call('POST', '/data/showcase_invoice', { name: invoiceName, account: northwindId, contact: northwindContactId }); + expect(invoice.status, JSON.stringify(invoice.json)).toBe(201); + const invoiceRead = await call('GET', `/data/showcase_invoice/${await idOf('showcase_invoice', invoiceName)}`); + expect(record(invoiceRead.json)).toMatchObject({ account: northwindId, contact: northwindContactId }); + }); +}); diff --git a/packages/qa/dogfood/test/field-history-tracking-activity.dogfood.test.ts b/packages/qa/dogfood/test/field-history-tracking-activity.dogfood.test.ts new file mode 100644 index 00000000000..f83267709f3 --- /dev/null +++ b/packages/qa/dogfood/test/field-history-tracking-activity.dogfood.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.field-history-tracking` (#26573): its api +// clauses at the REST doors of the booted showcase, as shipped. +// `showcase_account` declares `enable.trackHistory: true` and per-field +// `trackHistory` on `industry` and `status` (labels Industry / Lifecycle); +// `website` is untracked. `showcase_task` declares per-field trackHistory and +// NO object-level opt-in — the natural negative for the derived operation. +// +// acceptance[0], negative[2] — a tracked edit made through +// `PATCH /data/showcase_account/:id` lands its sys_activity row with the +// ADR-0052 §5b summary in DISPLAY values: the field label and the option +// labels ('Industry: Retail → Finance'), never the raw option values. +// acceptance[1], negative[0] — a website-only edit lands no old → new diff: +// the row carries the #25787 generic frame, which names the changed +// field's LABEL and never its values. +// acceptance[3] — the derived `history` operation follows get ∧ +// enable.trackHistory: present in showcase_account's effective operation +// vocabulary and absent from showcase_task's, read off +// `GET /auth/me/permissions`. +// +// The plugin-audit unit pins drive the summary renderer below the data door +// (RUNNER rule 6). The History-tab clauses (acceptance[2], negative[1]) stay +// browser legs; acceptance[4] stays the unit pin's (no showcase object tracks +// a reference field). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; +import { AuditPlugin } from '@objectstack/plugin-audit'; + +const SYS = { context: { isSystem: true } } as const; +const ACCOUNT = 'showcase_account'; + +describe('records-forms.field-history-tracking — the activity summary and the derived op over REST', () => { + let stack: VerifyStack; + let ql: any; + let token = ''; + + const uniq = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + /** The account's activity rows, newest last — written by plugin-audit's CRUD mirror, which may settle a tick later. */ + const activityOf = async (recordId: string, atLeast: number): Promise => { + for (let i = 0; i < 40; i++) { + const rows = await ql.find('sys_activity', { where: { object_name: ACCOUNT, record_id: recordId } }, SYS); + if (rows.length >= atLeast) return rows; + await new Promise((r) => setTimeout(r, 100)); + } + return ql.find('sys_activity', { where: { object_name: ACCOUNT, record_id: recordId } }, SYS); + }; + const createAccount = async (): Promise<{ id: string; name: string; baseline: number }> => { + const name = uniq('fht'); + const res = await stack.apiAs(token, 'POST', `/data/${ACCOUNT}`, { + name, industry: 'retail', status: 'active', website: 'https://before.example', + }); + expect(res.status, await res.clone().text()).toBe(201); + const [row] = await stack.rows(ACCOUNT, { name }); + const id = String(row.id); + return { id, name, baseline: (await activityOf(id, 1)).length }; + }; + const patch = async (id: string, body: Record) => { + const res = await stack.apiAs(token, 'PATCH', `/data/${ACCOUNT}/${id}`, body); + expect(res.status, await res.clone().text()).toBe(200); + }; + + beforeAll(async () => { + // The showcase boot composes no audit plugin of its own; `objectstack serve` does. + stack = await bootShowcase({ extraPlugins: [new AuditPlugin()] }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[0], negative[2]: a tracked edit lands the old → new summary in labels, never raw values', async () => { + const { id, baseline } = await createAccount(); + await patch(id, { industry: 'finance' }); + const rows = await activityOf(id, baseline + 1); + expect(rows.length, JSON.stringify(rows)).toBe(baseline + 1); + const row = rows[rows.length - 1]; + expect(row.summary, JSON.stringify(row)).toBe('Industry: Retail → Finance'); + expect(JSON.parse(row.metadata).summary_message.key).toBe('fieldChanges'); + expect(row.changed_fields).toEqual(['industry']); + }); + + it('acceptance[1], negative[0]: a website-only edit lands the generic frame, never an old → new diff', async () => { + const { id, name, baseline } = await createAccount(); + await patch(id, { website: 'https://after.example' }); + const rows = await activityOf(id, baseline + 1); + expect(rows.length, JSON.stringify(rows)).toBe(baseline + 1); + const row = rows[rows.length - 1]; + // The #25787 generic update summary names WHICH field changed (its label), + // never its values: no arrow, neither URL. + expect(row.summary, JSON.stringify(row)).toBe(`Updated Account (Success Overlay) "${name}": Website`); + expect(row.summary).not.toContain('→'); + expect(row.summary).not.toContain('before.example'); + expect(row.summary).not.toContain('after.example'); + expect(JSON.parse(row.metadata).summary_message.key).toBe('activityUpdatedFields'); + }); + + it('acceptance[3]: the derived history operation is offered on showcase_account and not on showcase_task', async () => { + const res = await stack.apiAs(token, 'GET', '/auth/me/permissions'); + expect(res.status).toBe(200); + const body: any = await res.json(); + const opsOf = (object: string): string[] => body.objects?.[object]?.apiOperations; + expect(opsOf(ACCOUNT), 'showcase_account declares enable.trackHistory').toContain('history'); + expect(opsOf('showcase_task'), 'showcase_task has per-field flags only').not.toContain('history'); + expect(opsOf('showcase_task'), 'the control: the task is readable').toContain('get'); + }); +}); diff --git a/packages/qa/dogfood/test/invoice-conditional-rules-server.dogfood.test.ts b/packages/qa/dogfood/test/invoice-conditional-rules-server.dogfood.test.ts new file mode 100644 index 00000000000..902d30dedcf --- /dev/null +++ b/packages/qa/dogfood/test/invoice-conditional-rules-server.dogfood.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.conditional-rules-header` (#26573): the +// server half of the invoice's header rules, at the REST data door of the +// booted showcase, as shipped (`showcase_invoice`, invoice.object.ts): +// +// acceptance[1], negative[0] — `issued_on` requiredWhen status in +// ['sent', 'paid']: a direct POST of a 'sent' invoice with no issued_on is +// 400 VALIDATION_FAILED naming issued_on with code 'required', and no +// invoice of that name exists. The draft twin is the 201 control. +// acceptance[2], negative[1] — `tax_rate` readonlyWhen status == 'paid': a +// PATCH to tax_rate on the seeded paid invoice INV-1003 answers 200 and is +// DISCARDED (stripReadonlyWhenFields keeps, never rejects) — the re-read +// equals the read before. A draft invoice's tax_rate PATCH persisting is +// the control that the strip is the rule's, not the door's. +// acceptance[5] — the API half of "one rule, not two": the predicates the +// form evaluates (status in sent/paid, status == paid) are the ones these +// refusals answer to. The form half is the objectui live specs'. +// +// The dom clauses (acceptance[0], [3], [4], negative[2]) stay browser legs. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const INVOICE = 'showcase_invoice'; + +type Answer = { status: number; json: any }; + +describe('records-forms.conditional-rules-header — the invoice rules at the server', () => { + let stack: VerifyStack; + let token = ''; + let accountId = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + const uniq = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + const [account] = await stack.rows('showcase_account', { status: 'active' }); + accountId = String(account.id); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[1], negative[0]: a sent invoice with no issued_on is refused, naming issued_on, and nothing is stored', async () => { + const name = uniq('icr-sent'); + const refused = await call('POST', `/data/${INVOICE}`, { name, account: accountId, status: 'sent' }); + expect(refused.status, JSON.stringify(refused.json)).toBe(400); + expect(refused.json.code).toBe('VALIDATION_FAILED'); + expect((refused.json.fields ?? []).map((f: any) => [f.field, f.code])).toEqual([['issued_on', 'required']]); + expect(await stack.rows(INVOICE, { name })).toHaveLength(0); + + // Control: the draft twin is admitted; the same sent invoice with its date is too. + const draft = await call('POST', `/data/${INVOICE}`, { name: uniq('icr-draft'), account: accountId, status: 'draft' }); + expect(draft.status, JSON.stringify(draft.json)).toBe(201); + const dated = await call('POST', `/data/${INVOICE}`, { name: uniq('icr-dated'), account: accountId, status: 'sent', issued_on: '2026-10-01' }); + expect(dated.status, JSON.stringify(dated.json)).toBe(201); + }); + + it('acceptance[2], negative[1]: a tax_rate PATCH on the paid invoice answers 200 and is discarded', async () => { + const [paid] = await stack.rows(INVOICE, { name: 'INV-1003' }); + expect(paid?.status, 'the showcase seed carries the paid INV-1003').toBe('paid'); + const before = (await call('GET', `/data/${INVOICE}/${paid.id}`)).json; + const beforeRate = (before.record ?? before).tax_rate; + expect(beforeRate).not.toBe(99); + + const patched = await call('PATCH', `/data/${INVOICE}/${paid.id}`, { tax_rate: 99 }); + expect(patched.status, JSON.stringify(patched.json)).toBe(200); + const after = (await call('GET', `/data/${INVOICE}/${paid.id}`)).json; + expect((after.record ?? after).tax_rate).toBe(beforeRate); + + // Control: on a draft invoice the same PATCH persists. + const name = uniq('icr-rate'); + expect((await call('POST', `/data/${INVOICE}`, { name, account: accountId, status: 'draft' })).status).toBe(201); + const [draft] = await stack.rows(INVOICE, { name }); + expect((await call('PATCH', `/data/${INVOICE}/${draft.id}`, { tax_rate: 12 })).status).toBe(200); + const [rated] = await stack.rows(INVOICE, { name }); + expect(rated.tax_rate).toBe(12); + }); +});