Skip to content

Commit 8802f90

Browse files
authored
fix(cli): include allowed values in oneOf const validation errors (#2455)
1 parent af6de73 commit 8802f90

2 files changed

Lines changed: 104 additions & 1 deletion

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { validateValueAgainstTypeSpec } from '../../lib/operation-args';
3+
import { CliError } from '../../lib/errors';
4+
import type { CliTypeSpec } from '../../cli/types';
5+
6+
describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => {
7+
const schema: CliTypeSpec = {
8+
oneOf: [
9+
{ const: 'headerRow' },
10+
{ const: 'totalRow' },
11+
{ const: 'firstColumn' },
12+
{ const: 'lastColumn' },
13+
{ const: 'bandedRows' },
14+
{ const: 'bandedColumns' },
15+
],
16+
};
17+
18+
test('accepts a valid const value', () => {
19+
expect(() => validateValueAgainstTypeSpec('headerRow', schema, 'flag')).not.toThrow();
20+
expect(() => validateValueAgainstTypeSpec('bandedColumns', schema, 'flag')).not.toThrow();
21+
});
22+
23+
test('rejects an invalid value and lists all allowed values', () => {
24+
try {
25+
validateValueAgainstTypeSpec('lastRow', schema, 'tables set-style-option:flag');
26+
throw new Error('Expected CliError to be thrown');
27+
} catch (error) {
28+
expect(error).toBeInstanceOf(CliError);
29+
const cliError = error as CliError;
30+
expect(cliError.code).toBe('VALIDATION_ERROR');
31+
expect(cliError.message).toBe(
32+
'tables set-style-option:flag must be one of: headerRow, totalRow, firstColumn, lastColumn, bandedRows, bandedColumns.',
33+
);
34+
}
35+
});
36+
37+
test('preserves per-variant errors in details', () => {
38+
try {
39+
validateValueAgainstTypeSpec('invalid', schema, 'flag');
40+
throw new Error('Expected CliError to be thrown');
41+
} catch (error) {
42+
const cliError = error as CliError;
43+
const details = cliError.details as { errors: string[] };
44+
expect(details.errors).toBeArrayOfSize(6);
45+
}
46+
});
47+
});
48+
49+
describe('validateValueAgainstTypeSpec – oneOf with mixed schemas', () => {
50+
const mixedSchema: CliTypeSpec = {
51+
oneOf: [{ const: 'block' }, { type: 'object', properties: { kind: { const: 'inline' } }, required: ['kind'] }],
52+
};
53+
54+
test('falls back to generic message when variants are not all const', () => {
55+
try {
56+
validateValueAgainstTypeSpec('nope', mixedSchema, 'target');
57+
throw new Error('Expected CliError to be thrown');
58+
} catch (error) {
59+
const cliError = error as CliError;
60+
expect(cliError.message).toBe('target must match one of the allowed schema variants.');
61+
}
62+
});
63+
});
64+
65+
describe('validateValueAgainstTypeSpec – enum branch', () => {
66+
const enumSchema: CliTypeSpec = {
67+
type: 'string',
68+
enum: ['direct', 'tracked'],
69+
} as CliTypeSpec & { enum: string[] };
70+
71+
test('accepts a valid enum value', () => {
72+
expect(() => validateValueAgainstTypeSpec('direct', enumSchema, 'changeMode')).not.toThrow();
73+
});
74+
75+
test('rejects an invalid enum value with allowed list', () => {
76+
try {
77+
validateValueAgainstTypeSpec('bogus', enumSchema, 'changeMode');
78+
throw new Error('Expected CliError to be thrown');
79+
} catch (error) {
80+
const cliError = error as CliError;
81+
expect(cliError.message).toBe('changeMode must be one of: direct, tracked.');
82+
}
83+
});
84+
});

apps/cli/src/lib/operation-args.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ function acceptsLegacyTextAddressTarget(
100100
return docApiId === 'replace' || docApiId === 'delete' || docApiId?.startsWith('format.') === true;
101101
}
102102

103+
/**
104+
* If every variant in a `oneOf` is a `{ const: X }`, return the values as strings.
105+
* Returns an empty array when the pattern doesn't hold (mixed / nested schemas).
106+
*/
107+
function extractConstValues(variants: CliTypeSpec[]): string[] {
108+
const values: string[] = [];
109+
for (const variant of variants) {
110+
if (!('const' in variant)) return [];
111+
values.push(String(variant.const));
112+
}
113+
return values;
114+
}
115+
103116
export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec, path: string): void {
104117
if ('const' in schema) {
105118
if (value !== schema.const) {
@@ -119,7 +132,13 @@ export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec
119132
errors.push(error instanceof Error ? error.message : String(error));
120133
}
121134
}
122-
throw new CliError('VALIDATION_ERROR', `${path} must match one of the allowed schema variants.`, { errors });
135+
136+
const allowedValues = extractConstValues(variants);
137+
const message =
138+
allowedValues.length > 0
139+
? `${path} must be one of: ${allowedValues.join(', ')}.`
140+
: `${path} must match one of the allowed schema variants.`;
141+
throw new CliError('VALIDATION_ERROR', message, { errors });
123142
}
124143

125144
if (schema.type === 'json') return;

0 commit comments

Comments
 (0)