|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `ObjectExtensionSchema` refuses `hooks` and `listViews` on an extension and |
| 5 | + * says what to do instead. Each sentence makes a claim about `defineStack()`, |
| 6 | + * and this file holds each claim at `defineStack()` itself: |
| 7 | + * |
| 8 | + * - a top-level hook bound to an object the stack does not define is refused at |
| 9 | + * build, even when the stack extends that object through `objectExtensions`. |
| 10 | + * ADR-0130 §1.5 records that refusal as deliberate: "the split must follow |
| 11 | + * hook ownership". A hook on an object the stack defines is accepted; |
| 12 | + * - a top-level view whose `list` or `form` data source is an object the stack |
| 13 | + * does not define is refused the same way, extended or not. A view on an |
| 14 | + * object the stack defines is accepted. |
| 15 | + * |
| 16 | + * Both refusals come from `validateCrossReferences` in `stack.zod.ts`, which |
| 17 | + * keeps exactly what it accepts and refuses; only the guidance text names it. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect } from 'vitest'; |
| 21 | +import { defineStack } from './stack.zod'; |
| 22 | +import { ObjectExtensionSchema } from './data/object.zod'; |
| 23 | + |
| 24 | +const manifest = { id: 'com.test.extender', name: 'extender', version: '1.0.0', type: 'app' as const }; |
| 25 | +const OWN = { name: 'extender_note', label: 'Note', fields: { title: { type: 'text' as const, label: 'Title' } } }; |
| 26 | +const FOREIGN = 'sys_organization'; |
| 27 | +const extendsForeign = [{ extend: FOREIGN, fields: { tier: { type: 'text' as const, label: 'Tier' } } }]; |
| 28 | + |
| 29 | +const hook = (object: string) => ({ name: 'probe_hook', object, events: ['beforeInsert'], handler: 'noop' }); |
| 30 | +const listView = (object: string) => ({ |
| 31 | + list: { type: 'grid', data: { provider: 'object', object }, columns: ['name'] }, |
| 32 | +}); |
| 33 | +const formView = (object: string) => ({ |
| 34 | + form: { type: 'simple', data: { provider: 'object', object }, sections: [{ label: 'Main', fields: [{ field: 'name' }] }] }, |
| 35 | +}); |
| 36 | + |
| 37 | +const refusalOf = (config: unknown): any => { |
| 38 | + try { |
| 39 | + defineStack(config as any); |
| 40 | + return undefined; |
| 41 | + } catch (error) { |
| 42 | + return error; |
| 43 | + } |
| 44 | +}; |
| 45 | + |
| 46 | +describe('defineStack and a top-level hook bound to an object outside the stack', () => { |
| 47 | + it('refuses a hook on an object the stack only extends', () => { |
| 48 | + const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, hooks: [hook(FOREIGN)] }); |
| 49 | + expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); |
| 50 | + expect(err.message).toContain(`Hook 'probe_hook' references object '${FOREIGN}'`); |
| 51 | + }); |
| 52 | + |
| 53 | + it('accepts a hook on an object the stack defines (the control)', () => { |
| 54 | + const stack = defineStack({ |
| 55 | + manifest, objects: [OWN], objectExtensions: extendsForeign, hooks: [hook(OWN.name)], |
| 56 | + } as any); |
| 57 | + expect(stack.hooks?.map((h) => h.object)).toEqual([OWN.name]); |
| 58 | + }); |
| 59 | +}); |
| 60 | + |
| 61 | +describe('defineStack and a top-level view whose data source is an object outside the stack', () => { |
| 62 | + it('refuses a view whose `list` reads an object the stack only extends', () => { |
| 63 | + const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, views: [listView(FOREIGN)] }); |
| 64 | + expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); |
| 65 | + expect(err.message).toContain(`View[0].list references object '${FOREIGN}'`); |
| 66 | + }); |
| 67 | + |
| 68 | + it('refuses a view whose `form` reads an object the stack only extends', () => { |
| 69 | + const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, views: [formView(FOREIGN)] }); |
| 70 | + expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); |
| 71 | + expect(err.message).toContain(`View[0].form references object '${FOREIGN}'`); |
| 72 | + }); |
| 73 | + |
| 74 | + it('accepts a view on an object the stack defines (the control)', () => { |
| 75 | + const stack = defineStack({ |
| 76 | + manifest, objects: [OWN], objectExtensions: extendsForeign, views: [listView(OWN.name)], |
| 77 | + } as any); |
| 78 | + expect(stack.views).toHaveLength(1); |
| 79 | + }); |
| 80 | +}); |
| 81 | + |
| 82 | +describe('the extension refusal says what defineStack does', () => { |
| 83 | + const guidanceFor = (key: 'hooks' | 'listViews') => { |
| 84 | + const parsed = ObjectExtensionSchema.safeParse({ extend: FOREIGN, [key]: key === 'hooks' ? [] : {} }); |
| 85 | + expect(parsed.success).toBe(false); |
| 86 | + return JSON.stringify(parsed.error?.issues); |
| 87 | + }; |
| 88 | + |
| 89 | + it('`hooks` names the owning stack and the refusal of an extended target', () => { |
| 90 | + const text = guidanceFor('hooks'); |
| 91 | + expect(text).toContain('declare it as a top-level hook in the stack that defines the object'); |
| 92 | + expect(text).toContain('refuses a top-level hook bound to an object the stack does not define, an extended one included'); |
| 93 | + }); |
| 94 | + |
| 95 | + it('`listViews` names the owning stack and the refusal of an extended target', () => { |
| 96 | + const text = guidanceFor('listViews'); |
| 97 | + expect(text).toContain('top-level `view` in the stack that defines the target object'); |
| 98 | + expect(text).toContain('data source is an object the stack does not define, an extended one included'); |
| 99 | + }); |
| 100 | +}); |
0 commit comments