diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index c90feeb9981..52999266124 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -3043,7 +3043,7 @@ "title": "Object lifecycle hooks fire on the write path with the right timing, gate, async and error semantics — driven over /api/v1/data/*, oracled by the record effect and the log line", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -3052,7 +3052,7 @@ "fixtures": { "app": "showcase", "requires": [ - "the four seeded showcase hooks (examples/app-showcase/src/data/hooks/index.ts, bound via defineStack({ hooks: allHooks })): showcase_normalize_task_title (showcase_task, events ['beforeInsert','beforeUpdate'], trims title, priority 50, onError:'abort'); showcase_stamp_inquiry_defaults (showcase_inquiry, beforeInsert, stamps status='new'/source='web', onError:'abort'); showcase_audit_task_completion (showcase_task, afterUpdate, condition previous.done!=true && record.done==true, async, retryPolicy {maxRetries:3,backoffMs:1000}, capabilities:['log'], onError:'log', priority 90); showcase_warn_over_budget (showcase_project, afterUpdate, condition record.spent!=null && record.budget!=null && record.spent>record.budget, async, capabilities:['log'], onError:'log')", + "the five seeded showcase hooks (examples/app-showcase/src/data/hooks/index.ts, bound via defineStack({ hooks: allHooks })) — the four this item drives, plus showcase_guard_task_reopen (showcase_task, beforeUpdate, condition previous.done == true && record.done != true, body throws a user-facing sentence, onError:'abort', priority 60), the refusal fixture records-forms.script-action-hook-refusal-toast drives; showcase_normalize_task_title (showcase_task, events ['beforeInsert','beforeUpdate'], trims title, priority 50, onError:'abort'); showcase_stamp_inquiry_defaults (showcase_inquiry, beforeInsert, stamps status='new'/source='web', onError:'abort'); showcase_audit_task_completion (showcase_task, afterUpdate, condition previous.done!=true && record.done==true, async, retryPolicy {maxRetries:3,backoffMs:1000}, capabilities:['log'], onError:'log', priority 90); showcase_warn_over_budget (showcase_project, afterUpdate, condition record.spent!=null && record.budget!=null && record.spent>record.budget, async, capabilities:['log'], onError:'log')", "showcase_task (title required text + done boolean, defaultValue false), showcase_inquiry (status select whose 'new' option is default:true + source plain text with NO default), showcase_project (budget/spent currency + the spent_within_budget script rule that REJECTS spent > budget*1.2)", "seeded rows: not-done task 'Build homepage' (done:false) for the transition PATCH; already-done tasks 'Audit current IA' and 'App wireframes' (done:true) for the non-transition proof; projects 'Data Platform' (budget 600000 / spent 420000) and 'Website Relaunch' (budget 150000 / spent 60000)", "server log capture — the async audit/warn bodies call ctx.log.info/warn, routed to the engine logger (packages/runtime/src/sandbox/body-runner.ts log: engineCtx.logger); AND the ability to register a scratch hook (throwing / ordered) for the variants the four fixtures cannot observe", @@ -3061,8 +3061,8 @@ "knownGaps": [ "afterInsert and afterDelete have NO fixture hook — the four seeded hooks cover beforeInsert, beforeUpdate and afterUpdate only. Exercise these two events with a scratch log hook or record the variant knownGap; do not fake coverage.", "beforeDelete has NO seeded showcase hook — the abort-blocks-delete contract is pinned only by the dogfood hef_ref_guard fixture (packages/qa/dogfood/test/hook-error-format.dogfood.test.ts), a test-only stack, not the showcase app; drive beforeDelete via a scratch hook or cite that pin.", - "priority ordering (lowest-first on the SAME object+event) is not fixture-covered: no two showcase hooks share an object+event (normalize is showcase_task/before*, audit is showcase_task/afterUpdate — different events), so ordering needs a scratch pair of hooks on one object+event with distinct priorities.", - "onError:'abort' ROLLBACK and onError:'log' SUPPRESSION are only observable with a hook that THROWS — the four fixtures never throw (their trim/stamp/log bodies always succeed). The abort-rollback, log-tolerate and fail-closed-condition clauses each require a scratch throwing hook (or the cited dogfood abort pin).", + "priority ordering (lowest-first on the SAME object+event) is not fixture-covered: showcase_normalize_task_title (priority 50) and showcase_guard_task_reopen (priority 60) do share showcase_task/beforeUpdate, but neither logs and the guard fires only on a reopening write it then refuses, so their order is not observable from outside; ordering needs a scratch pair of logging hooks on one object+event with distinct priorities.", + "onError:'abort' ROLLBACK and onError:'log' SUPPRESSION are only observable with a hook that THROWS — the four hooks this item drives never throw (their trim/stamp/log bodies always succeed). The one seeded hook that throws, showcase_guard_task_reopen, is a beforeUpdate abort and does not stand in for this item's beforeInsert abort-rollback clause. The abort-rollback, log-tolerate and fail-closed-condition clauses each require a scratch throwing hook (or the cited dogfood abort pin).", "the async audit/warn lines land AFTER the HTTP write resolves (fire-and-forget runs once the engine has moved on) — a log read taken before the async body executes shows no line; settle before judging any no-fire." ] }, @@ -3085,7 +3085,7 @@ "condition fails CLOSED (#4775) — SCRATCH: an unevaluable/uncompilable condition ABORTS the operation and is NOT softened by onError:'log' nor fire-and-forgotten (the gate runs OUTSIDE both); add a scratch hook with a broken condition (unit-pinned in hook-wrappers.ts)" ], "steps": [ - "boot showcase isolated; sign in as seeded admin; confirm the four hooks actually registered before asserting any no-fire — GET /api/v1/meta/types/hook (or the boot log) lists showcase_normalize_task_title / showcase_stamp_inquiry_defaults / showcase_audit_task_completion / showcase_warn_over_budget (a hook that never bound fakes every no-fire — seed-data-thin)", + "boot showcase isolated; sign in as seeded admin; confirm the four hooks this item drives actually registered before asserting any no-fire — GET /api/v1/meta/types/hook (or the boot log) lists showcase_normalize_task_title / showcase_stamp_inquiry_defaults / showcase_audit_task_completion / showcase_warn_over_budget (a hook that never bound fakes every no-fire — seed-data-thin)", "stamp defaults (beforeInsert): POST /api/v1/data/showcase_inquiry {name:'os-qa-'} OMITTING status AND source; re-read via GET and confirm source == 'web' — the hook is the SOLE producer of that value (the source field carries no default), so a correct read proves the hook fired; status == 'new' corroborates but is NOT hook-attributable alone (the 'new' status option is default:true)", "title trim (beforeInsert + beforeUpdate): POST /api/v1/data/showcase_task with title ' os-qa- ' (leading/trailing spaces) and valid required fields; re-read → title == 'os-qa-' (insert trim); then PATCH the same row's title to another padded value and re-read → trimmed again (update trim); ONE multi-event hook covered both", "completion audit — FIRES (transition): PATCH /api/v1/data/showcase_task/ {done:true} (previous done:false); after the HTTP response resolves, settle briefly (the audit is async fire-and-forget, it runs after the write returns), then read the server log for 'task completed: Build homepage'", @@ -3176,7 +3176,7 @@ "ref": "packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts (pins the beforeInsert stamp: status='new'/source='web' on an anonymous inquiry submit); packages/qa/dogfood/test/hook-error-format.dogfood.test.ts (pins the beforeDelete onError:'abort' throw → REST error body). Declarative-wrapper semantics (two-root condition, async, retry, onError, fail-closed) are unit-pinned in packages/objectql/src/hook-wrappers.ts + hook-binder.ts tests. The transition audit / over-budget warn / priority-ordering LOG oracles and the abort/log/priority SCRATCH variants are NOT yet dogfood-pinned — drive them by hand." }, "source": [ - "examples/app-showcase/src/data/hooks/index.ts#allHooks (the four fixture hooks + allHooks export; header comments spell out the two-root #4784 transition and the != null / not has() #4770 rationale verbatim)", + "examples/app-showcase/src/data/hooks/index.ts#allHooks (the four fixture hooks this item drives, the showcase_guard_task_reopen refusal fixture beside them, + allHooks export; header comments spell out the two-root #4784 transition and the != null / not has() #4770 rationale verbatim)", "packages/spec/src/data/hook.zod.ts#HookSchema (HookSchema + HookEvent enum beforeFind/afterFind/beforeInsert/afterInsert/beforeUpdate/afterUpdate/beforeDelete/afterDelete; defineHook; async 'after* only'; onError default 'abort'; empty-target refusal #4001)", "packages/objectql/src/hook-wrappers.ts#wrapDeclarativeHook (wrapDeclarativeHook wrapping order condition→async→retry→timeout→onError; pickRecordPayload #4770 record = stored ⊕ payload total over declared fields; pickPreviousPayload #4784 previous binding; HookConditionError #4775 fail-closed, raised OUTSIDE onError; fireAndForget = async && isAfterEvent)", "packages/objectql/src/hook-binder.ts#bindHooksToEngine (bindHooksToEngine: per-event × per-object engine.registerHook with priority; unresolved-body / empty-target skips)", @@ -3198,6 +3198,12 @@ "date": "2026-09-09", "change": "citation only, no clause moved — the hook liveness citation asserted `timeout` 'live'; #14478 renamed it to `timeoutMs` and left `timeout` as a 'dead' tombstone row, so the ref sent a runner to a contradiction. Now names the live key and the tombstone.", "ref": "#15839" + }, + { + "revision": 3, + "date": "2026-10-03", + "change": "fixture roster only, no clause moved — the showcase gained a fifth hook, showcase_guard_task_reopen (beforeUpdate on showcase_task, throws a user-facing refusal), the fixture records-forms.script-action-hook-refusal-toast drives. Two knownGaps stated facts it made false ('no two showcase hooks share an object+event', 'the fixtures never throw'); both now name it and say why it does not close this item's ordering or beforeInsert abort-rollback clause.", + "ref": "#21596" } ] }, @@ -4288,6 +4294,98 @@ "ref": "#19518" } ] + }, + { + "id": "records-forms.script-action-hook-refusal-toast", + "title": "A script action whose server-side hook refuses the write with a user-facing sentence answers a 4xx, the console shows exactly one error toast carrying that sentence, and the record is left unchanged", + "since": "v17.5", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the refusal pair on showcase_task: the hook showcase_guard_task_reopen (examples/app-showcase/src/data/hooks/index.ts — beforeUpdate, condition previous.done == true && record.done != true, sandboxed body throws new Error(TASK_REOPEN_REFUSAL), onError:'abort') and the script action showcase_reopen_task (examples/app-showcase/src/ui/actions/index.ts — body writes { id, done: false } through ctx.api, execution perRecord, visible 'has(record.done) && record.done == true', locations list_item only — the Task Detail page is kind:'full' and renders no record_header bar)", + "the refusal sentence, verbatim: 'A finished task cannot be reopened. Create a follow-up task instead.' (TASK_REOPEN_REFUSAL in the hooks file — re-read it there before the run, never from this line)", + "a seeded FINISHED task (done: true) — 'Audit current IA' or 'App wireframes' (examples/app-showcase/src/data/seed/index.ts) — which is the only kind of record the action is offered on", + "a console built at this repo's .objectui-sha (pnpm objectui:build), served by the same boot the run drives", + "a browser driver that can read the network log and the DOM of one page (Playwright with launchOptions.executablePath=/opt/pw-browsers/chromium on these containers — RUNNER environment facts)" + ], + "knownGaps": [ + "the console half is pinned in the objectui repo, not here — objectui packages/app-shell/src/utils/__tests__/consoleServerAction.errorToast.test.ts characterizes the console failure chain (a 500 or a 400 carrying the sentence yields exactly one error toast with that sentence) with fetch and the toast sink as doubles. It is not runnable from this checkout and it stubs the transport, so it is not this item's evidence; this item exists to observe the whole chain live, on a stock app.", + "the toaster auto-dismisses every toast after 4s (objectui ConsoleToaster toastOptions.duration 4000) and closes it with an exit animation that marks the node data-removed=\"true\" first — count inside that window, and exclude removed nodes, or a correct single toast reads as zero." + ] + }, + "steps": [ + "re-read examples/app-showcase/src/data/hooks/index.ts and copy TASK_REOPEN_REFUSAL verbatim — the oracle string; boot the showcase isolated (RUNNER canonical boot line: own port, own file DB, --seed-admin) with the console built at .objectui-sha; sign in through the form as the seeded admin", + "pick a finished task: GET /api/v1/data/showcase_task?where=... (or the list) and record its id, done, progress and modified timestamp — this is the before-image", + "open the showcase_task list in the console (/_console/apps/showcase_app/showcase_task); screenshot after render settles; open the finished task's row menu ('Open menu' on its row) and confirm 'Reopen' is offered there", + "start capturing the network log and, before clicking, count toast nodes: document.querySelectorAll('[data-sonner-toast]:not([data-removed=\"true\"])').length — the baseline (expected 0)", + "click 'Reopen' with a ref-targeted click (RUNNER automation self-check); wait for the response of POST /api/v1/actions/showcase_task/showcase_reopen_task (the request URL may carry the record id as a further path segment) — record its status and its JSON body", + "within 2s of that response landing (the 4s auto-dismiss window), screenshot, then read every live toast node: count, data-type attribute, textContent — and compute count minus baseline", + "tie the toast to the request: the request was the ONLY non-GET request between the baseline read and the toast read in the network log, and the toast text contains the same sentence as that response's error.message", + "re-read the task over the API: GET /api/v1/data/showcase_task/ID — compare done, progress and the modified timestamp against the before-image", + "repeat once on a fresh page load (RUNNER rule 2 — any fail is reproduced twice before it is recorded)" + ], + "acceptance": [ + { + "clause": "the refusal answers a 4xx on the action route — a guard that said no is a successful evaluation, never a 5xx server fault — and the response body carries the hook's sentence", + "oracle": "network", + "verify": "the captured response of POST /api/v1/actions/showcase_task/showcase_reopen_task has 400 <= status < 500 and its body's error.message contains TASK_REOPEN_REFUSAL verbatim", + "evidence": "the request line, the response status and the error envelope (code, message) as text" + }, + { + "clause": "the console shows EXACTLY ONE new toast for that refusal, of type error, whose text contains the sentence — not zero (the objectui#9151 symptom), not two (a duplicated sink), and not a success toast", + "oracle": "dom", + "verify": "after the screenshot confirms the page rendered, the live-toast count read within the auto-dismiss window minus the pre-click baseline is exactly 1; that node's data-type is 'error' and its textContent contains TASK_REOPEN_REFUSAL", + "evidence": "baseline count, post-response count, the node data-type and textContent, as text; a one-line description of the screenshot" + }, + { + "clause": "the toast belongs to THIS request: it is the console reporting the refused action, not some other failure that happened to toast at the same moment", + "oracle": "network", + "verify": "the network log between the baseline read and the toast read holds exactly one non-GET request — the action POST — and the toast's sentence equals the one in that response's error.message", + "evidence": "the ordered list of non-GET requests in that window, with status" + }, + { + "clause": "the record is left unchanged: the refused write did not land, neither partly nor through a second path", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_task/ID after the click returns done true, the same progress and the same modified timestamp as the before-image", + "evidence": "the before-image and the after re-read, side by side" + } + ], + "negative": [ + "a 5xx on the action route is a FAIL of the status clause even when the body carries the sentence — the class #17265 fixed (PR #17679) must not regress", + "zero live toasts after a 4xx carrying the sentence is a FAIL of the toast clause — the objectui#9151 symptom; rule out a count taken after the 4s auto-dismiss before recording it", + "a toast that shows only the generic fallback ('… failed (HTTP nnn)') when the response body carried the sentence is a FAIL — the sentence is what the user must be told", + "two or more new toasts for one click is a FAIL — one refusal, one message" + ], + "traps": [ + "stale-console-bundle", + "hydration-race", + "automation-input" + ], + "source": [ + "examples/app-showcase/src/data/hooks/index.ts#GuardTaskReopenHook (the refusing beforeUpdate hook; the sentence is TASK_REOPEN_REFUSAL in the same file)", + "examples/app-showcase/src/ui/actions/index.ts#ReopenTaskAction (the script action whose ctx.api write the hook refuses)", + "examples/app-showcase/test/task-reopen-hook-refusal.test.ts (pins the fixture: the hook refuses with the sentence and leaves the row unchanged on the real engine, and the action writes the refused shape)", + "packages/runtime/src/sandbox/quickjs-runner.ts#sandboxRefusalMessage (a nested sandboxed hook refusal keeps its business message across the action VM hop, so the action route answers 4xx — #17265 / PR #17679)", + "packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts (the wire half: 400 VALIDATION_ERROR with the sentence, unit-level)", + "objectui packages/app-shell/src/utils/__tests__/consoleServerAction.errorToast.test.ts (objectui#9252 — the console failure chain characterized end to end with the transport stubbed)", + "objectui packages/app-shell/src/chrome/ConsoleToaster.tsx (sonner Toaster: richColors, duration 4000, toast nodes carry data-sonner-toast and data-type)", + "objectui#9151 (the zero-toast symptom this item re-observes on a current build); #21596 (this item)" + ], + "history": [ + { + "revision": 1, + "date": "2026-10-03", + "change": "initial — gives objectui#9151's re-observation a home: the status half was fixed (#17265 / PR #17679) and the console chain pinned correct with the transport stubbed (objectui#9252), but the symptom was never re-observed live on a current build. The showcase had no hook that refuses a write, so the fixture pair (showcase_guard_task_reopen + showcase_reopen_task) lands with it.", + "ref": "#21596" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index 5aa0f36df31..542fd62c23c 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -9,7 +9,8 @@ "api-backend.packaged-action-disabled-dispatch", "api-backend.action-activation-door-contract", "automation.setup-packaged-automation-board", - "platform-core.activation-ledger-registration-home" + "platform-core.activation-ledger-registration-home", + "records-forms.script-action-hook-refusal-toast" ] }, "agent": { @@ -154,7 +155,8 @@ "items": [ "records-forms.object-hook-lifecycle", "cli.hook-body-extraction-gates", - "access-security.record-view-read-audit" + "access-security.record-view-read-audit", + "records-forms.script-action-hook-refusal-toast" ] }, "job": { diff --git a/examples/app-showcase/src/data/hooks/index.ts b/examples/app-showcase/src/data/hooks/index.ts index c809fe28bd6..21eeb6b6537 100644 --- a/examples/app-showcase/src/data/hooks/index.ts +++ b/examples/app-showcase/src/data/hooks/index.ts @@ -119,9 +119,50 @@ export const StampInquiryDefaultsHook = { description: 'Stamps status=new and source=web on every new inquiry (public web-to-lead defaults).', }; +/** + * The sentence {@link GuardTaskReopenHook} refuses with. Exported so the + * fixture's test quotes the same string the hook throws. + */ +export const TASK_REOPEN_REFUSAL = + 'A finished task cannot be reopened. Create a follow-up task instead.'; + +/** + * beforeUpdate (gated) — the showcase's one hook that REFUSES a write, with a + * sentence addressed to the user. + * + * `throw new Error('')` from a sandboxed body is the business-refusal + * shape: the write is aborted (`onError: 'abort'`) and the sentence travels to + * the caller as a 4xx — on `/data`, and through a script action's `ctx.api` + * write on `/actions` too (`packages/runtime/src/sandbox/ + * nested-hook-refusal-is-a-rejection.test.ts`). `showcase_reopen_task` + * (`src/ui/actions/index.ts`) is the script action that reaches it, and the + * platform checklist item `records-forms.script-action-hook-refusal-toast` + * drives that action in the console. + * + * The condition is the two-root transition form (same lesson as + * {@link AuditTaskCompletionHook}): only the write that flips `done` from true + * to not-true is refused. An edit of a finished task that leaves `done` alone + * still lands. + */ +export const GuardTaskReopenHook = { + name: 'showcase_guard_task_reopen', + label: 'Guard Task Reopen', + object: 'showcase_task', + events: ['beforeUpdate'] as LifecycleEvent[], + condition: 'previous.done == true && record.done != true', + body: { + language: 'js' as const, + source: `throw new Error(${JSON.stringify(TASK_REOPEN_REFUSAL)});`, + }, + priority: 60, + onError: 'abort' as const, + description: 'Refuses reopening a finished task (done true to false) with a user-facing sentence.', +}; + export const allHooks = [ NormalizeTaskTitleHook, StampInquiryDefaultsHook, + GuardTaskReopenHook, AuditTaskCompletionHook, WarnOverBudgetHook, ]; diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 6debc096cb2..f0517049b56 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -494,6 +494,9 @@ export const ShowcaseTranslationBundle = { label: '重算所选', successMessage: '已为整个选中集重算工时。', }, + // The hook-refusal specimen: every click is refused by + // showcase_guard_task_reopen, so it carries no successMessage. + showcase_reopen_task: { label: '重新打开' }, }, // Section headings of the six form-view projections in // `ui/views/task.view.ts` (edit / tabbed / wizard / split / quick). diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 0534abaafc3..51a2cd240e4 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -84,6 +84,43 @@ export const MarkDoneAction = defineAction({ refreshAfter: true, }); +/** + * script — the hook-REFUSAL specimen. Reopening a finished task is refused by + * the `showcase_guard_task_reopen` beforeUpdate hook + * (`src/data/hooks/index.ts`), so a click on this action always fails, by + * design: the body's `ctx.api` write is aborted and the hook's sentence is what + * the user must be told. The action route answers it as a 4xx carrying that + * sentence, and the console owes exactly one error toast that contains it. + * Platform checklist item `records-forms.script-action-hook-refusal-toast` + * drives this. + * + * Shown only on finished tasks — the only records the refusal applies to. + */ +export const ReopenTaskAction = defineAction({ + name: 'showcase_reopen_task', + label: 'Reopen', + icon: 'rotate-ccw', + objectName: task, + type: 'script', + body: { + language: 'js', + source: + "var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId;" + + "if (!id) throw new Error('No record to reopen');" + + "await ctx.api.object('showcase_task').update({ id: id, done: false });" + + "return { ok: true, id: id };", + capabilities: ['api.write'], + }, + execution: 'perRecord', + // #8990 — `has()` guards the sparse `list_item` face, as on Mark Done. + visible: 'has(record.done) && record.done == true', + // The task list's row menu only. `record_header` would be inert here: the + // Task Detail page (`showcase_task_detail`, `kind: 'full'`) owns the whole + // record layout and renders no header action bar. + locations: ['list_item'], + refreshAfter: true, +}); + /** url — navigate out, from the row overflow menu. */ export const OpenDocsAction = defineAction({ name: 'showcase_open_docs', @@ -457,6 +494,7 @@ export const PortfolioSnapshotAction = defineAction({ export const allActions = [ MarkDoneAction, + ReopenTaskAction, OpenDocsAction, BulkReassignAction, QuickViewAction, diff --git a/examples/app-showcase/test/task-reopen-hook-refusal.test.ts b/examples/app-showcase/test/task-reopen-hook-refusal.test.ts new file mode 100644 index 00000000000..24cd29553b0 --- /dev/null +++ b/examples/app-showcase/test/task-reopen-hook-refusal.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The showcase's hook-REFUSAL fixture: `showcase_guard_task_reopen` refuses the + * write that reopens a finished task, with a sentence addressed to the user, + * and `showcase_reopen_task` is the script action whose write reaches it. + * + * Platform checklist item `records-forms.script-action-hook-refusal-toast` + * drives this pair in the console (4xx on the action route, exactly one error + * toast carrying the sentence, record unchanged). This file pins the two halves + * the item stands on, so a run that FAILS is about the console and the action + * route, never about a fixture that quietly stopped refusing: + * + * 1. on the real engine with the app's real hooks, the reopening write is + * refused with the sentence and the stored row is unchanged — while an + * edit of the same finished task that leaves `done` alone still lands; + * 2. the action's body writes exactly the reopening shape (`done: false` on + * its own record), so a click reaches the hook's condition. + * + * The action route's own 4xx for a nested sandboxed refusal is pinned in + * `packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts`; + * it is not restated here. + * + * Harness: the production one `hook-body-persisted-writes.test.ts` uses — real + * `ObjectQL`, real `SqlDriver` (better-sqlite3), real `QuickJSScriptRunner` + * behind `hookBodyRunnerFactory`, the app's real objects and hooks. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + QuickJSScriptRunner, + actionBodyRunnerFactory, + hookBodyRunnerFactory, +} from '@objectstack/runtime'; + +import { Account, Project, Task } from '../src/data/objects/index.js'; +import { allHooks, TASK_REOPEN_REFUSAL } from '../src/data/hooks/index.js'; +import { ReopenTaskAction } from '../src/ui/actions/index.js'; + +const APP_ID = 'com.objectstack.showcase'; +const PACKAGE_ID = `app:${APP_ID}`; + +const openEngines: ObjectQL[] = []; +afterEach(async () => { + while (openEngines.length) { + try { await openEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function bootShowcase(hooks: unknown[] = allHooks): Promise { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.connect(); + + const engine = new ObjectQL(); + openEngines.push(engine); + engine.registerDriver(driver as never, true); + await engine.init(); + for (const def of [Account, Project, Task]) { + engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase'); + } + await engine.syncSchemas(); + engine.bindHooks(hooks as never[], { + packageId: PACKAGE_ID, + bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }), + }); + return engine; +} + +const ctx = { context: { userId: 'u_showcase', isSystem: true } }; + +const readBack = async (engine: ObjectQL, id: string) => + (await engine.find('showcase_task', { where: { id } }, ctx as never))[0] as any; + +/** A finished task on a real project chain: done = true, progress = 100. */ +async function finishedTask(engine: ObjectQL): Promise { + const account: any = await engine.insert('showcase_account', { name: 'Initech', status: 'active' }, ctx as never); + const project: any = await engine.insert( + 'showcase_project', + { name: 'Platform', account: String(account.id), status: 'planned' }, + ctx as never, + ); + const task: any = await engine.insert( + 'showcase_task', + { title: 'Audit current IA', project: String(project.id), status: 'backlog' }, + ctx as never, + ); + const id = String(task.id); + await engine.update('showcase_task', { id, done: true, progress: 100 }, ctx as never); + expect((await readBack(engine, id)).done).toBeTruthy(); + return id; +} + +/** Every string an error carries that a client could be shown. */ +function textOf(err: any): string { + return [err?.message, err?.innerMessage, err?.cause?.message].filter(Boolean).join(' | '); +} + +describe('showcase_guard_task_reopen — the hook refuses reopening a finished task', () => { + it('the reopening write is refused with the sentence, and the stored row is unchanged', async () => { + const engine = await bootShowcase(); + const id = await finishedTask(engine); + + const err = await engine + .update('showcase_task', { id, done: false }, ctx as never) + .then(() => null, (e: unknown) => e); + + expect(err, 'expected the reopening write to be refused, but it resolved').not.toBeNull(); + expect(textOf(err)).toContain(TASK_REOPEN_REFUSAL); + // The refusal is a business sentence, not a script fault: no native + // error-class name leads it (`TypeError: …` is the fault shape #7543 keeps + // off the wire). + expect(String((err as any).innerMessage ?? '')).not.toMatch(/^(TypeError|ReferenceError|SyntaxError):/); + + const stored = await readBack(engine, id); + expect(stored.done).toBeTruthy(); + expect(stored.progress).toBe(100); + }, 30000); + + it('an edit of the finished task that leaves `done` alone still lands', async () => { + // The two-root condition: `previous.done == true && record.done != true`. + // A guard collapsed to `previous.done == true` would refuse this too and + // make every finished task read-only. + const engine = await bootShowcase(); + const id = await finishedTask(engine); + + await engine.update('showcase_task', { id, priority: 'high' }, ctx as never); + + const stored = await readBack(engine, id); + expect(stored.priority).toBe('high'); + expect(stored.done).toBeTruthy(); + }, 30000); + + it('REVERSE: with the hooks unbound the same write lands — the refusal is the hook', async () => { + const engine = await bootShowcase([]); + const id = await finishedTask(engine); + + await engine.update('showcase_task', { id, done: false }, ctx as never); + + expect((await readBack(engine, id)).done).toBeFalsy(); + }, 30000); +}); + +describe('showcase_reopen_task — the script action writes the shape the hook refuses', () => { + it("the body updates its own record with done: false and nothing else", async () => { + let written: { object: string; data: Record } | undefined; + const ql = { + object: (object: string) => ({ + update: async (data: Record) => { + written = { object, data }; + return { id: data.id }; + }, + }), + }; + + const handler = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql, appId: 'showcase' })( + ReopenTaskAction as never, + ); + expect(typeof handler).toBe('function'); + + await handler!({ + recordId: 'task_1', + record: { id: 'task_1', done: true, progress: 100 }, + params: {}, + user: { id: 'u1' }, + }); + + expect(written).toEqual({ object: 'showcase_task', data: { id: 'task_1', done: false } }); + }); + + it('is offered only on finished tasks', () => { + expect(ReopenTaskAction.type).toBe('script'); + expect(ReopenTaskAction.execution).toBe('perRecord'); + const visible = ReopenTaskAction.visible as unknown; + const source = typeof visible === 'string' ? visible : (visible as { source?: string }).source; + expect(source).toBe('has(record.done) && record.done == true'); + }); +});