|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#29894] An undeclared hook abort answers `HOOK_ABORTED` on the per-object |
| 4 | +// batch row too, the code `/data`, `/createMany` and `/batch` give it. |
| 5 | +// |
| 6 | +// #27597 gave a sandboxed hook body's free-text refusal (`throw new Error('…')`, |
| 7 | +// no `code`) the ledgered wire code `HOOK_ABORTED`. The per-object batch door |
| 8 | +// (`POST /data/:object/batch`) reports a row failure through `toRowApiError` |
| 9 | +// (`@objectstack/metadata-protocol`), which derived the code from the 400 the |
| 10 | +// sibling doors serve, so its row said `VALIDATION_ERROR` for the same |
| 11 | +// refusal. This file is the card's four-door reproduction over the real HTTP |
| 12 | +// stack of one boot, plus the batch row's other modes and verbs, and the |
| 13 | +// control that a code the hook DID declare still wins on every door. |
| 14 | + |
| 15 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 16 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 17 | +import { defineStack } from '@objectstack/spec'; |
| 18 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 19 | + |
| 20 | +const SENTENCE = 'A QA budget line needs an approver before it can be recorded.'; |
| 21 | +const DECLARED = 'This budget period is locked.'; |
| 22 | + |
| 23 | +const obj = (name: string) => ObjectSchema.create({ |
| 24 | + name, |
| 25 | + sharingModel: 'public_read_write', |
| 26 | + label: name, |
| 27 | + pluralLabel: name, |
| 28 | + fields: { name: Field.text({ label: 'Name', required: true }) }, |
| 29 | +}); |
| 30 | + |
| 31 | +const throwUndeclared = { language: 'js', source: `throw new Error(${JSON.stringify(SENTENCE)});` } as const; |
| 32 | + |
| 33 | +const stack29894 = defineStack({ |
| 34 | + manifest: { |
| 35 | + id: 'com.dogfood.batch-row-hook-aborted', |
| 36 | + namespace: 'bra', |
| 37 | + version: '0.0.0', |
| 38 | + type: 'app', |
| 39 | + name: 'Batch Row Hook Aborted Fixture', |
| 40 | + description: 'Hooks that abort a write in their own words, with and without a declared code.', |
| 41 | + }, |
| 42 | + objects: [obj('bra_ins'), obj('bra_upd'), obj('bra_del'), obj('bra_declared')], |
| 43 | + hooks: [ |
| 44 | + { name: 'bra_ins_refuse', object: 'bra_ins', events: ['beforeInsert'], body: throwUndeclared }, |
| 45 | + { name: 'bra_upd_refuse', object: 'bra_upd', events: ['beforeUpdate'], body: throwUndeclared }, |
| 46 | + { name: 'bra_del_refuse', object: 'bra_del', events: ['beforeDelete'], body: throwUndeclared }, |
| 47 | + { |
| 48 | + name: 'bra_declared_refuse', |
| 49 | + object: 'bra_declared', |
| 50 | + events: ['beforeInsert'], |
| 51 | + body: { language: 'js', source: `throw Object.assign(new Error(${JSON.stringify(DECLARED)}), { code: 'RECORD_LOCKED' });` }, |
| 52 | + }, |
| 53 | + ], |
| 54 | +} as any); |
| 55 | + |
| 56 | +const SYS = { context: { isSystem: true } } as const; |
| 57 | + |
| 58 | +/** The failing row of a per-object batch answer: the one that is not a sibling's NOT_ATTEMPTED / ROLLED_BACK. */ |
| 59 | +function causalRowError(body: any): any { |
| 60 | + const rows: any[] = body?.results ?? body?.data?.results ?? body?.error?.details?.results ?? []; |
| 61 | + const errors = rows.flatMap((r) => r?.errors ?? []); |
| 62 | + return errors.find((e) => e?.code !== 'NOT_ATTEMPTED' && e?.code !== 'ROLLED_BACK'); |
| 63 | +} |
| 64 | + |
| 65 | +describe('[#29894] an undeclared hook abort is HOOK_ABORTED on every data door, the batch row included', () => { |
| 66 | + let stack: VerifyStack; |
| 67 | + let admin = ''; |
| 68 | + let ql: any; |
| 69 | + let updId = ''; |
| 70 | + let delId = ''; |
| 71 | + |
| 72 | + beforeAll(async () => { |
| 73 | + stack = await bootStack(stack29894 as never, {}); |
| 74 | + admin = await stack.signIn(); |
| 75 | + ql = await stack.kernel.getServiceAsync('objectql'); |
| 76 | + updId = String((await ql.insert('bra_upd', { name: 'seed' }, SYS))?.id); |
| 77 | + delId = String((await ql.insert('bra_del', { name: 'seed' }, SYS))?.id); |
| 78 | + }, 240_000); |
| 79 | + |
| 80 | + afterAll(async () => { |
| 81 | + await stack?.stop?.(); |
| 82 | + }); |
| 83 | + |
| 84 | + describe('§1 the card: the whole-request doors answer 400 HOOK_ABORTED with the sentence', () => { |
| 85 | + const doors: Array<[string, () => Promise<Response>]> = [ |
| 86 | + ['POST /data/OBJECT', () => stack.apiAs(admin, 'POST', '/data/bra_ins', { name: 'x' })], |
| 87 | + ['POST /data/OBJECT/createMany', () => stack.apiAs(admin, 'POST', '/data/bra_ins/createMany', [{ name: 'x' }])], |
| 88 | + ['POST /batch', () => stack.apiAs(admin, 'POST', '/batch', { operations: [{ action: 'create', object: 'bra_ins', data: { name: 'x' } }] })], |
| 89 | + ]; |
| 90 | + for (const [door, send] of doors) { |
| 91 | + it(door, async () => { |
| 92 | + const res = await send(); |
| 93 | + const body: any = await res.json(); |
| 94 | + expect(res.status, JSON.stringify(body)).toBe(400); |
| 95 | + expect(JSON.stringify(body)).toContain(SENTENCE); |
| 96 | + expect(JSON.stringify(body)).toContain('"code":"HOOK_ABORTED"'); |
| 97 | + }); |
| 98 | + } |
| 99 | + }); |
| 100 | + |
| 101 | + describe('§2 the per-object batch row answers the same code, in every mode and verb', () => { |
| 102 | + const cases: Array<[string, () => Promise<Response>]> = [ |
| 103 | + ['create', () => stack.apiAs(admin, 'POST', '/data/bra_ins/batch', { operation: 'create', records: [{ data: { name: 'x' } }] })], |
| 104 | + ['create atomic', () => stack.apiAs(admin, 'POST', '/data/bra_ins/batch', { operation: 'create', records: [{ data: { name: 'x' } }, { data: { name: 'y' } }], options: { atomic: true } })], |
| 105 | + ['create continueOnError', () => stack.apiAs(admin, 'POST', '/data/bra_ins/batch', { operation: 'create', records: [{ data: { name: 'x' } }, { data: { name: 'y' } }], options: { continueOnError: true } })], |
| 106 | + ['update', () => stack.apiAs(admin, 'POST', '/data/bra_upd/batch', { operation: 'update', records: [{ id: updId, data: { name: 'moved' } }] })], |
| 107 | + ['upsert (existing id)', () => stack.apiAs(admin, 'POST', '/data/bra_upd/batch', { operation: 'upsert', records: [{ id: updId, data: { name: 'moved' } }] })], |
| 108 | + ['delete', () => stack.apiAs(admin, 'POST', '/data/bra_del/batch', { operation: 'delete', records: [{ id: delId }] })], |
| 109 | + ]; |
| 110 | + for (const [label, send] of cases) { |
| 111 | + it(label, async () => { |
| 112 | + const res = await send(); |
| 113 | + const body: any = await res.json(); |
| 114 | + const row = causalRowError(body); |
| 115 | + expect(row, JSON.stringify(body)).toBeDefined(); |
| 116 | + expect(row.message, JSON.stringify(body)).toBe(SENTENCE); |
| 117 | + expect(row.code, `#29894: the row answers the code its sibling doors give the same abort: ${JSON.stringify(body)}`).toBe('HOOK_ABORTED'); |
| 118 | + }); |
| 119 | + } |
| 120 | + |
| 121 | + it('nothing was written or moved', async () => { |
| 122 | + expect(await ql.find('bra_ins', { where: {}, ...SYS })).toHaveLength(0); |
| 123 | + const [upd] = await ql.find('bra_upd', { where: {}, ...SYS }); |
| 124 | + expect(upd?.name).toBe('seed'); |
| 125 | + expect(await ql.find('bra_del', { where: {}, ...SYS })).toHaveLength(1); |
| 126 | + }); |
| 127 | + }); |
| 128 | + |
| 129 | + describe('§3 control: a code the hook declared still wins on every door', () => { |
| 130 | + const doors: Array<[string, () => Promise<Response>]> = [ |
| 131 | + ['POST /data/OBJECT', () => stack.apiAs(admin, 'POST', '/data/bra_declared', { name: 'x' })], |
| 132 | + ['POST /data/OBJECT/createMany', () => stack.apiAs(admin, 'POST', '/data/bra_declared/createMany', [{ name: 'x' }])], |
| 133 | + ['POST /batch', () => stack.apiAs(admin, 'POST', '/batch', { operations: [{ action: 'create', object: 'bra_declared', data: { name: 'x' } }] })], |
| 134 | + ]; |
| 135 | + for (const [door, send] of doors) { |
| 136 | + it(door, async () => { |
| 137 | + const res = await send(); |
| 138 | + const text = await res.text(); |
| 139 | + expect(text).toContain(DECLARED); |
| 140 | + expect(text).toContain('"code":"RECORD_LOCKED"'); |
| 141 | + expect(text).not.toContain('HOOK_ABORTED'); |
| 142 | + }); |
| 143 | + } |
| 144 | + |
| 145 | + it('POST /data/OBJECT/batch', async () => { |
| 146 | + const res = await stack.apiAs(admin, 'POST', '/data/bra_declared/batch', { operation: 'create', records: [{ data: { name: 'x' } }] }); |
| 147 | + const body: any = await res.json(); |
| 148 | + const row = causalRowError(body); |
| 149 | + expect(row?.message, JSON.stringify(body)).toBe(DECLARED); |
| 150 | + expect(row?.code, JSON.stringify(body)).toBe('RECORD_LOCKED'); |
| 151 | + }); |
| 152 | + }); |
| 153 | +}); |
0 commit comments