|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #6970 — an action param's `defaultValue` must satisfy the param's OWN value |
| 5 | + * contract at AUTHORING time, checked through the same `valueSchemaFor` the |
| 6 | + * dispatcher runs at submit (ADR-0104 D2). |
| 7 | + * |
| 8 | + * Before this, `defaultValue` was `z.unknown().optional()`: a default that |
| 9 | + * could never satisfy its own param parsed clean, prefilled the control, and |
| 10 | + * 400'd at submit on a field the user never touched. |
| 11 | + */ |
| 12 | + |
| 13 | +import { describe, it, expect } from 'vitest'; |
| 14 | + |
| 15 | +import { ActionParamSchema } from './action.zod'; |
| 16 | +import { validateActionParams } from './action-params.zod'; |
| 17 | +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; |
| 18 | + |
| 19 | +/** Parse a param and return its first `defaultValue` issue, or `null`. */ |
| 20 | +function defaultValueIssue(param: Record<string, unknown>) { |
| 21 | + const r = ActionParamSchema.safeParse(param); |
| 22 | + if (r.success) return null; |
| 23 | + return r.error.issues.find((i) => i.path.join('.') === 'defaultValue') ?? null; |
| 24 | +} |
| 25 | + |
| 26 | +/** What the ADR-0104 D2 dispatcher does with this default AS A SUBMITTED VALUE. */ |
| 27 | +function submitIssue(param: Record<string, unknown>) { |
| 28 | + const issues = validateActionParams( |
| 29 | + [{ |
| 30 | + name: param.name as string, |
| 31 | + type: param.type as string | undefined, |
| 32 | + multiple: param.multiple as boolean | undefined, |
| 33 | + options: param.options as never, |
| 34 | + }], |
| 35 | + { [param.name as string]: param.defaultValue }, |
| 36 | + ); |
| 37 | + return issues[0] ?? null; |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * The cases the issue names, plus the deliberate NON-rejections. `accepted` |
| 42 | + * is the verdict for BOTH moments — that equivalence is the point (see the |
| 43 | + * parity test at the bottom), so the table carries one column, not two. |
| 44 | + */ |
| 45 | +const CASES: Array<{ label: string; param: Record<string, unknown>; accepted: boolean }> = [ |
| 46 | + // ── The hole, per type ──────────────────────────────────────────────────── |
| 47 | + { |
| 48 | + label: "datetime + a wall-clock literal (the issue's example)", |
| 49 | + param: { name: 'start', type: 'datetime', defaultValue: '2026-08-10T15:00' }, |
| 50 | + accepted: false, |
| 51 | + }, |
| 52 | + { label: "number + 'abc'", param: { name: 'qty', type: 'number', defaultValue: 'abc' }, accepted: false }, |
| 53 | + { |
| 54 | + label: 'select + a value not in its own options', |
| 55 | + param: { |
| 56 | + name: 'tier', |
| 57 | + type: 'select', |
| 58 | + options: [{ label: 'Gold', value: 'gold' }, { label: 'Silver', value: 'silver' }], |
| 59 | + defaultValue: 'platinum', |
| 60 | + }, |
| 61 | + accepted: false, |
| 62 | + }, |
| 63 | + { |
| 64 | + label: 'multiple: true + a scalar default', |
| 65 | + param: { name: 'owners', type: 'user', multiple: true, defaultValue: 'usr_1' }, |
| 66 | + accepted: false, |
| 67 | + }, |
| 68 | + { |
| 69 | + label: 'date + a full instant (the mirror of the datetime case)', |
| 70 | + param: { name: 'due', type: 'date', defaultValue: '2026-08-10T15:00:00.000Z' }, |
| 71 | + accepted: false, |
| 72 | + }, |
| 73 | + { label: 'boolean + a string', param: { name: 'notify', type: 'boolean', defaultValue: 'yes' }, accepted: false }, |
| 74 | + { |
| 75 | + label: 'lookup + an embedded record object instead of an id', |
| 76 | + param: { name: 'owner', type: 'lookup', reference: 'sys_user', defaultValue: { id: 'usr_1', name: 'Ada' } }, |
| 77 | + accepted: false, |
| 78 | + }, |
| 79 | + |
| 80 | + // ── Valid defaults of each type: untouched ──────────────────────────────── |
| 81 | + { |
| 82 | + label: 'VALID datetime (ISO instant with zone)', |
| 83 | + param: { name: 'start', type: 'datetime', defaultValue: '2026-08-10T15:00:00.000Z' }, |
| 84 | + accepted: true, |
| 85 | + }, |
| 86 | + { label: 'VALID number', param: { name: 'qty', type: 'number', defaultValue: 7 }, accepted: true }, |
| 87 | + { |
| 88 | + label: 'VALID select member', |
| 89 | + param: { name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }], defaultValue: 'gold' }, |
| 90 | + accepted: true, |
| 91 | + }, |
| 92 | + { |
| 93 | + label: 'VALID multiple array', |
| 94 | + param: { name: 'owners', type: 'user', multiple: true, defaultValue: ['usr_1'] }, |
| 95 | + accepted: true, |
| 96 | + }, |
| 97 | + { label: 'VALID date', param: { name: 'due', type: 'date', defaultValue: '2026-08-10' }, accepted: true }, |
| 98 | + { label: 'VALID boolean', param: { name: 'notify', type: 'boolean', defaultValue: true }, accepted: true }, |
| 99 | + { |
| 100 | + label: 'json — an explicitly OPEN value contract, so any default rides', |
| 101 | + param: { name: 'blob', type: 'json', defaultValue: { anything: ['at', 'all'] } }, |
| 102 | + accepted: true, |
| 103 | + }, |
| 104 | + { |
| 105 | + label: 'no `type` — the value shape is unresolvable, so it stays open', |
| 106 | + param: { name: 'loose', defaultValue: 'whatever' }, |
| 107 | + accepted: true, |
| 108 | + }, |
| 109 | + |
| 110 | + // ── Presence parity: the dispatcher treats these as ABSENT ──────────────── |
| 111 | + { |
| 112 | + label: "empty-string default on a datetime — ABSENT at submit, so not judged here either", |
| 113 | + param: { name: 'start', type: 'datetime', defaultValue: '' }, |
| 114 | + accepted: true, |
| 115 | + }, |
| 116 | + { |
| 117 | + label: 'null default on a number — ABSENT at submit', |
| 118 | + param: { name: 'qty', type: 'number', defaultValue: null }, |
| 119 | + accepted: true, |
| 120 | + }, |
| 121 | +]; |
| 122 | + |
| 123 | +describe('#6970 ActionParamSchema.defaultValue — authored defaults meet the param value contract', () => { |
| 124 | + for (const { label, param, accepted } of CASES) { |
| 125 | + it(`${accepted ? 'accepts' : 'rejects'}: ${label}`, () => { |
| 126 | + const issue = defaultValueIssue(param); |
| 127 | + if (accepted) { |
| 128 | + expect(issue).toBeNull(); |
| 129 | + return; |
| 130 | + } |
| 131 | + // Rejection pin: this is a pure Zod parse (no error envelope), so the |
| 132 | + // assertion set is issue PATH + message shape — never a bare |
| 133 | + // `success === false`, which cannot tell this rejection from the |
| 134 | + // schema refusing the param for some unrelated reason. |
| 135 | + expect(issue).not.toBeNull(); |
| 136 | + expect(issue!.path).toEqual(['defaultValue']); |
| 137 | + expect(issue!.code).toBe('custom'); |
| 138 | + // Names the param, its type, and the offending literal — the three |
| 139 | + // things the submit-time 400 could not say. |
| 140 | + expect(issue!.message).toContain(`"${param.name as string}"`); |
| 141 | + expect(issue!.message).toContain(`(${param.type as string})`); |
| 142 | + expect(issue!.message).toContain(JSON.stringify(param.defaultValue)); |
| 143 | + }); |
| 144 | + } |
| 145 | + |
| 146 | + it("names the author's default as the cause, not just the param", () => { |
| 147 | + const issue = defaultValueIssue({ name: 'start', type: 'datetime', defaultValue: '2026-08-10T15:00' })!; |
| 148 | + // The underlying reason is carried verbatim from the shared value contract, |
| 149 | + // so authoring and submit read identically. |
| 150 | + expect(issue.message).toContain('expected an ISO-8601 instant with explicit zone'); |
| 151 | + expect(issue.message).toContain('cannot satisfy this param'); |
| 152 | + expect(issue.message).toContain('PREFILL'); |
| 153 | + }); |
| 154 | + |
| 155 | + it('reports through the real authoring door with a full params path', () => { |
| 156 | + const r = getMetadataTypeSchema('action')!.safeParse({ |
| 157 | + name: 'schedule_visit', |
| 158 | + label: 'Schedule Visit', |
| 159 | + type: 'script', |
| 160 | + params: [ |
| 161 | + { name: 'note', type: 'text' }, |
| 162 | + { name: 'start', type: 'datetime', defaultValue: '2026-08-10T15:00' }, |
| 163 | + ], |
| 164 | + }); |
| 165 | + expect(r.success).toBe(false); |
| 166 | + const paths = r.error!.issues.map((i) => i.path.join('.')); |
| 167 | + expect(paths).toContain('params.1.defaultValue'); |
| 168 | + }); |
| 169 | + |
| 170 | + // ── The design's two deliberate non-rejections ──────────────────────────── |
| 171 | + it('does NOT judge arity it cannot know — a field-backed param inherits `multiple`', () => { |
| 172 | + // `{ field: 'owners' }` inherits `multiple: true` from the referenced |
| 173 | + // field, which is invisible at parse time. Rejecting this array would be |
| 174 | + // the authoring gate guessing, and guessing wrong rejects valid metadata. |
| 175 | + expect(defaultValueIssue({ field: 'owners', type: 'user', defaultValue: ['usr_1', 'usr_2'] })).toBeNull(); |
| 176 | + // The scalar spelling of the same inherited-arity param is equally legal. |
| 177 | + expect(defaultValueIssue({ field: 'owners', type: 'user', defaultValue: 'usr_1' })).toBeNull(); |
| 178 | + }); |
| 179 | + |
| 180 | + it('still judges arity when the param STATES it, field-backed or not', () => { |
| 181 | + // `multiple` declared → the declaration answers the question, so it binds. |
| 182 | + expect(defaultValueIssue({ field: 'owners', type: 'user', multiple: true, defaultValue: 'usr_1' })) |
| 183 | + .not.toBeNull(); |
| 184 | + // Inline (no `field`) → nothing to inherit from, so silence means scalar. |
| 185 | + expect(defaultValueIssue({ name: 'owners', type: 'user', defaultValue: ['usr_1'] })).not.toBeNull(); |
| 186 | + }); |
| 187 | + |
| 188 | + it('does NOT judge option membership it cannot know — inherited option sets stay open', () => { |
| 189 | + // No inline `options`: the set comes from the referenced field, so |
| 190 | + // `valueSchemaFor` degrades to free-form and any string default rides. |
| 191 | + expect(defaultValueIssue({ field: 'tier', type: 'select', defaultValue: 'gold' })).toBeNull(); |
| 192 | + }); |
| 193 | + |
| 194 | + it('leaves params WITHOUT a defaultValue completely untouched', () => { |
| 195 | + for (const type of ['datetime', 'number', 'select', 'user', 'date', 'boolean']) { |
| 196 | + expect(ActionParamSchema.safeParse({ name: 'x', type }).success).toBe(true); |
| 197 | + } |
| 198 | + }); |
| 199 | + |
| 200 | + /** |
| 201 | + * The ruling's actual claim: `defaultValue` goes through the SAME |
| 202 | + * `valueSchemaFor` machinery the dispatcher uses — no second rule set. This |
| 203 | + * is the pin that would catch a future edit re-implementing the check by |
| 204 | + * hand, which is how the two ends drift into two dialects. |
| 205 | + */ |
| 206 | + it('agrees with the dispatcher on every case — one rule set, two moments', () => { |
| 207 | + for (const { label, param } of CASES) { |
| 208 | + // Arity/membership the AUTHORING side deliberately cannot resolve are |
| 209 | + // excluded: the dispatcher is fed the RESOLVED param, so for those rows |
| 210 | + // the two sides are answering different questions by design. |
| 211 | + if (param.field !== undefined) continue; |
| 212 | + const authoringRejects = defaultValueIssue(param) !== null; |
| 213 | + const submitRejects = submitIssue(param) !== null; |
| 214 | + expect( |
| 215 | + { case: label, authoringRejects }, |
| 216 | + `authoring and submit must agree for: ${label}`, |
| 217 | + ).toEqual({ case: label, authoringRejects: submitRejects }); |
| 218 | + } |
| 219 | + }); |
| 220 | +}); |
0 commit comments