|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `defineStack(config, { strict: false })` refuses a non-array `objects` with |
| 5 | + * an ADR-0112 envelope (#19785). |
| 6 | + * |
| 7 | + * ## What was wrong |
| 8 | + * |
| 9 | + * The non-strict door skips the parse and hands the normalized input straight |
| 10 | + * to `mergeActionsIntoObjects`, whose only guard was |
| 11 | + * `if (!config.objects || config.objects.length === 0)`. A truthy non-array |
| 12 | + * (`5`, `'abc'`) went on to `config.objects.map(…)` and raised a bare |
| 13 | + * `TypeError` — `code` and `status` both `undefined`, outside the envelope |
| 14 | + * every other refusal in the file carries — and a `null` ENTRY raised one |
| 15 | + * reading `actions` off it. A falsy non-array (`null`, `''`, `0`, `false`) was |
| 16 | + * handed on untouched, to be refused one door later by `composeStacks`. |
| 17 | + * |
| 18 | + * ## What is pinned |
| 19 | + * |
| 20 | + * `strict: false` skips validation; it never promised to accept a shape the |
| 21 | + * merge cannot read. So every non-array `objects` except an absent one is |
| 22 | + * refused here with the strict parse's own envelope — `STACK_SCHEMA_INVALID`, |
| 23 | + * `status: 422`, the zod issue on `issues` at `path: ['objects']` — the same |
| 24 | + * line `composeStacks` step 2 draws for the same key. |
| 25 | + * |
| 26 | + * A non-object ENTRY is refused with the same envelope, one zod issue per |
| 27 | + * entry at `path: ['objects', index]` (`expected: 'object'`): handing it on |
| 28 | + * would return a success whose objects are not all objects, and the next |
| 29 | + * consumer (plugin-object registration) drops every object after it. |
| 30 | + * |
| 31 | + * Every refusal has its CONTROL: the same stack with `objects` authored as an |
| 32 | + * array, or as the map form, is accepted and its bound actions are merged. |
| 33 | + */ |
| 34 | +import { describe, it, expect } from 'vitest'; |
| 35 | +import { defineStack } from './stack.zod'; |
| 36 | + |
| 37 | +type Envelope = Error & { |
| 38 | + code?: string; |
| 39 | + status?: number; |
| 40 | + issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>; |
| 41 | +}; |
| 42 | + |
| 43 | +/** The thrown value, or `null` when the call is accepted. */ |
| 44 | +function refusal(fn: () => unknown): Envelope | null { |
| 45 | + try { |
| 46 | + fn(); |
| 47 | + return null; |
| 48 | + } catch (e) { |
| 49 | + return e as Envelope; |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +const manifest = { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'app' as const }; |
| 54 | + |
| 55 | +const obj = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } }); |
| 56 | + |
| 57 | +const bound = { name: 'b_open', label: 'Open', type: 'url' as const, target: 'https://example.com', objectName: 'b_item' }; |
| 58 | + |
| 59 | +const nonStrict = (overrides: Record<string, unknown>) => |
| 60 | + defineStack({ manifest, ...overrides } as never, { strict: false }); |
| 61 | + |
| 62 | +const rows: Array<{ label: string; objects: unknown }> = [ |
| 63 | + { label: 'a number', objects: 5 }, |
| 64 | + { label: 'a string', objects: 'abc' }, |
| 65 | + { label: 'null', objects: null }, |
| 66 | + { label: "''", objects: '' }, |
| 67 | + { label: '0', objects: 0 }, |
| 68 | + { label: 'false', objects: false }, |
| 69 | +]; |
| 70 | + |
| 71 | +describe('#19785 — defineStack strict: false refuses a non-array `objects` with an ADR-0112 envelope', () => { |
| 72 | + for (const row of rows) { |
| 73 | + it(`\`objects\` as ${row.label} is refused with code STACK_SCHEMA_INVALID and status 422, the issue at ['objects']`, () => { |
| 74 | + const refused = refusal(() => nonStrict({ objects: row.objects, actions: [bound] })); |
| 75 | + expect(refused).toBeInstanceOf(Error); |
| 76 | + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); |
| 77 | + expect(refused?.status).toBe(422); |
| 78 | + expect(refused?.issues).toHaveLength(1); |
| 79 | + expect(refused?.issues?.[0]?.path).toEqual(['objects']); |
| 80 | + expect(refused?.issues?.[0]?.code).toBe('invalid_type'); |
| 81 | + expect(refused?.issues?.[0]?.expected).toBe('array'); |
| 82 | + expect(refused?.message).toContain("'objects'"); |
| 83 | + }); |
| 84 | + } |
| 85 | + |
| 86 | + it('the control — `objects` as an array is accepted and the bound action is merged into its object', () => { |
| 87 | + const stack = nonStrict({ objects: [obj('b_item')], actions: [bound] }); |
| 88 | + expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']); |
| 89 | + }); |
| 90 | + |
| 91 | + it('the control — the map form is normalized first and accepted the same way', () => { |
| 92 | + const stack = nonStrict({ objects: { b_item: obj('b_item') }, actions: [bound] }); |
| 93 | + expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']); |
| 94 | + }); |
| 95 | + |
| 96 | + it('an ABSENT `objects` is not a malformed one — accepted', () => { |
| 97 | + const stack = nonStrict({ actions: [bound] }); |
| 98 | + expect(stack.objects).toBeUndefined(); |
| 99 | + expect(stack.actions?.map((a) => a.name)).toEqual(['b_open']); |
| 100 | + }); |
| 101 | + |
| 102 | + it('a non-object ENTRY is refused with the same envelope, one issue per entry at [objects, index] — never handed on, never a bare TypeError', () => { |
| 103 | + const refused = refusal(() => nonStrict({ objects: [null, obj('b_item'), 7], actions: [bound] })); |
| 104 | + expect(refused).toBeInstanceOf(Error); |
| 105 | + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); |
| 106 | + expect(refused?.status).toBe(422); |
| 107 | + expect(refused?.issues?.map((issue) => issue.path)).toEqual([ |
| 108 | + ['objects', 0], |
| 109 | + ['objects', 2], |
| 110 | + ]); |
| 111 | + expect(refused?.issues?.every((issue) => issue.code === 'invalid_type' && issue.expected === 'object')).toBe(true); |
| 112 | + }); |
| 113 | + |
| 114 | + it('a single non-object entry beside a good one is refused too — the card\'s `[null, obj]` repro', () => { |
| 115 | + const refused = refusal(() => nonStrict({ objects: [null, obj('b_item')] })); |
| 116 | + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); |
| 117 | + expect(refused?.status).toBe(422); |
| 118 | + expect(refused?.issues?.map((issue) => issue.path)).toEqual([['objects', 0]]); |
| 119 | + }); |
| 120 | + |
| 121 | + it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => { |
| 122 | + const refused = refusal(() => defineStack({ manifest, objects: 5 } as never)); |
| 123 | + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); |
| 124 | + expect(refused?.status).toBe(422); |
| 125 | + }); |
| 126 | +}); |
0 commit comments