|
17 | 17 | */ |
18 | 18 |
|
19 | 19 | import { describe, it, expect } from 'vitest'; |
20 | | -import { FieldErrorCode, MarkNotificationsReadRequestSchema } from '@objectstack/spec/api'; |
| 20 | +import { |
| 21 | + AnalyticsQueryRequestSchema, |
| 22 | + FieldErrorCode, |
| 23 | + MarkNotificationsReadRequestSchema, |
| 24 | +} from '@objectstack/spec/api'; |
21 | 25 | import { FlowSchema } from '@objectstack/spec/automation'; |
22 | 26 | import { fieldsFromZodIssues } from './validation-failure'; |
23 | 27 |
|
@@ -101,3 +105,74 @@ describe('fieldsFromZodIssues — ADR-0114 D3 catalog codes, not Zod codes (#812 |
101 | 105 | expect(informed.find((f) => f.field.endsWith('label'))?.code).toBe('required'); |
102 | 106 | }); |
103 | 107 | }); |
| 108 | + |
| 109 | +/** |
| 110 | + * [#17598] ONE condition, ONE wording — for the `timeDimensions[].dateRange` |
| 111 | + * refusal, on the wire and not merely at `error.issues`. |
| 112 | + * |
| 113 | + * `AnalyticsDateRangeSchema` is a `z.union` carrying its own error map, so the |
| 114 | + * single issue zod raises is already a prescription AND already names the |
| 115 | + * arity ("received a 1-element array, not the two bounds [start, end]"). Its |
| 116 | + * tuple arm complains about the same value at the same path in zod's own words |
| 117 | + * ("Too small: expected array to have >=2 items"), and the #5014 union |
| 118 | + * expansion put both on the wire — one condition, two wordings, which is what |
| 119 | + * the #5240 convention exists to prevent and what `analytics.zod.ts` claims for |
| 120 | + * this refusal. |
| 121 | + * |
| 122 | + * Before #17598 the arm was `z.array(z.string())` with no length constraint, so |
| 123 | + * a 1-element window was not refused by the schema at all and there was no |
| 124 | + * second wording to have; the narrowing is what introduced it, and this is |
| 125 | + * where it is collapsed. The two edge cases below are as load-bearing as the |
| 126 | + * collapse itself: this narrows a RESTATEMENT, never a diagnosis. |
| 127 | + */ |
| 128 | +describe('fieldsFromZodIssues — the dateRange refusal keeps one wording (#17598)', () => { |
| 129 | + const analyticsBody = (dateRange: unknown) => ({ |
| 130 | + cube: 'orders', |
| 131 | + measures: ['count'], |
| 132 | + timeDimensions: [{ dimension: 'created_at', granularity: 'day', dateRange }], |
| 133 | + }); |
| 134 | + |
| 135 | + const arities: Array<[string, unknown]> = [ |
| 136 | + ['a 1-element window', ['2026-01-01']], |
| 137 | + ['an empty array', []], |
| 138 | + ['three bounds', ['2026-01-01', '2026-01-15', '2026-01-31']], |
| 139 | + ]; |
| 140 | + |
| 141 | + for (const [name, dateRange] of arities) { |
| 142 | + it(`${name} maps to exactly one entry, and it is the prescription`, () => { |
| 143 | + const fields = fieldsFromZodIssues( |
| 144 | + issuesOf(AnalyticsQueryRequestSchema, analyticsBody(dateRange)), |
| 145 | + ); |
| 146 | + expect(fields).toHaveLength(1); |
| 147 | + expect(fields[0].field).toBe('timeDimensions.0.dateRange'); |
| 148 | + expect(fields[0].message).toContain('not the two bounds [start, end]'); |
| 149 | + // ⛔ The arm's own arity text is the second wording, and it is gone. |
| 150 | + expect(fields[0].message).not.toMatch(/Too (small|big)/); |
| 151 | + }); |
| 152 | + } |
| 153 | + |
| 154 | + it('a NON-string bound keeps the branch entry naming WHICH bound is wrong', () => { |
| 155 | + // The prescription says "an array with a non-string bound"; it does not |
| 156 | + // say WHICH one. `dateRange.1` names a position the prescription has |
| 157 | + // not, so it is a diagnosis rather than a restatement and it stays. |
| 158 | + const fields = fieldsFromZodIssues( |
| 159 | + issuesOf(AnalyticsQueryRequestSchema, analyticsBody(['2026-01-01', 3])), |
| 160 | + ); |
| 161 | + expect(fields.map((f) => f.field)).toContain('timeDimensions.0.dateRange'); |
| 162 | + expect(fields.map((f) => f.field)).toContain('timeDimensions.0.dateRange.1'); |
| 163 | + }); |
| 164 | + |
| 165 | + it('CONTROL — a branch issue at its own branch ROOT still reaches the wire for every other key', () => { |
| 166 | + // `unrecognized_keys` is raised at the BRANCH root — structurally the |
| 167 | + // same position as the tuple arm's arity text — and it carries the |
| 168 | + // #4001 campaign's curated prose. If the collapse above were written as |
| 169 | + // "drop branch issues at the union's own path" rather than keyed on the |
| 170 | + // date-range recogniser, this is the family it would have silenced. |
| 171 | + const fields = fieldsFromZodIssues(issuesOf(FlowSchema, { |
| 172 | + ...WELL_FORMED_FLOW, |
| 173 | + nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }], |
| 174 | + })); |
| 175 | + expect(fields.map((f) => f.code)).toContain('unknown_field'); |
| 176 | + expect(fields.some((f) => f.message.includes('next'))).toBe(true); |
| 177 | + }); |
| 178 | +}); |
0 commit comments