Skip to content

Commit f29034b

Browse files
lpcoxCopilot
andcommitted
test: add schema-validator tests and simplify unreachable branches
- Add 18 unit tests covering all error formatting paths - Remove dead code: hasMinimumOne() and determineArticle() functions (all integer fields have minimum:1; no vowel-starting types reach the article logic after prior case handling) - Simplify isArrayOfStringsField() to a single boolean expression - Branch coverage for schema-validator.ts: 97.77% Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4b4145e commit f29034b

2 files changed

Lines changed: 129 additions & 25 deletions

File tree

src/schema-validator.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { validateWithSchema } from './schema-validator';
2+
3+
describe('schema-validator', () => {
4+
describe('validateWithSchema', () => {
5+
it('returns empty array for valid config', () => {
6+
expect(validateWithSchema({})).toEqual([]);
7+
expect(validateWithSchema({ network: { allowDomains: ['github.com'] } })).toEqual([]);
8+
});
9+
10+
it('rejects non-object roots', () => {
11+
expect(validateWithSchema(null)).toEqual(['config root must be an object']);
12+
expect(validateWithSchema('string')).toEqual(['config root must be an object']);
13+
expect(validateWithSchema(42)).toEqual(['config root must be an object']);
14+
expect(validateWithSchema([])).toEqual(['config root must be an object']);
15+
expect(validateWithSchema(undefined)).toEqual(['config root must be an object']);
16+
});
17+
18+
it('formats additionalProperties as "is not supported"', () => {
19+
const errors = validateWithSchema({ unknownKey: true });
20+
expect(errors).toContain('config.unknownKey is not supported');
21+
});
22+
23+
it('formats nested additionalProperties', () => {
24+
const errors = validateWithSchema({ network: { badField: true } });
25+
expect(errors).toContain('config.network.badField is not supported');
26+
});
27+
28+
it('formats type:object errors as "must be an object"', () => {
29+
const errors = validateWithSchema({ network: 'not-object' });
30+
expect(errors).toContain('config.network must be an object');
31+
});
32+
33+
it('formats array-of-strings fields correctly when given non-array', () => {
34+
const errors = validateWithSchema({ network: { allowDomains: 'github.com' } });
35+
expect(errors).toContain('config.network.allowDomains must be an array of strings');
36+
});
37+
38+
it('formats array-of-strings fields when items have wrong type', () => {
39+
const errors = validateWithSchema({ network: { blockDomains: [1, 2] } });
40+
expect(errors).toContain('config.network.blockDomains must be an array of strings');
41+
});
42+
43+
it('formats integer with minimum:1 as "must be a positive integer"', () => {
44+
// Non-integer value
45+
expect(validateWithSchema({ container: { agentTimeout: 1.5 } }))
46+
.toContain('config.container.agentTimeout must be a positive integer');
47+
// String value
48+
expect(validateWithSchema({ container: { agentTimeout: 'five' } }))
49+
.toContain('config.container.agentTimeout must be a positive integer');
50+
// Below minimum
51+
expect(validateWithSchema({ container: { agentTimeout: 0 } }))
52+
.toContain('config.container.agentTimeout must be a positive integer');
53+
expect(validateWithSchema({ container: { agentTimeout: -1 } }))
54+
.toContain('config.container.agentTimeout must be a positive integer');
55+
});
56+
57+
it('formats enum errors as "must be one of"', () => {
58+
const errors = validateWithSchema({ apiProxy: { anthropicCacheTailTtl: '10m' } });
59+
expect(errors).toContain('config.apiProxy.anthropicCacheTailTtl must be one of: 5m, 1h');
60+
});
61+
62+
it('formats logLevel enum correctly', () => {
63+
const errors = validateWithSchema({ logging: { logLevel: 'verbose' } });
64+
expect(errors).toContain('config.logging.logLevel must be one of: debug, info, warn, error');
65+
});
66+
67+
it('formats oneOf (string-or-array) fields correctly', () => {
68+
// Number is neither string nor array
69+
const errors = validateWithSchema({ security: { allowHostPorts: 5432 } });
70+
expect(errors).toContain('config.security.allowHostPorts must be a string or array of strings');
71+
});
72+
73+
it('accepts string and array forms for oneOf fields', () => {
74+
expect(validateWithSchema({ security: { allowHostPorts: '5432' } })).toEqual([]);
75+
expect(validateWithSchema({ security: { allowHostPorts: ['5432', '6379'] } })).toEqual([]);
76+
});
77+
78+
it('formats boolean type errors', () => {
79+
const errors = validateWithSchema({ apiProxy: { enabled: 'yes' } });
80+
expect(errors).toContain('config.apiProxy.enabled must be a boolean');
81+
});
82+
83+
it('formats string type errors for non-array fields', () => {
84+
const errors = validateWithSchema({ container: { memoryLimit: 512 } });
85+
expect(errors).toContain('config.container.memoryLimit must be a string');
86+
});
87+
88+
it('consolidates multiple item-level errors into one message', () => {
89+
// Array with 3 non-string items should produce 1 error, not 3
90+
const errors = validateWithSchema({ network: { dnsServers: [1, 2, 3] } });
91+
const dnsErrors = errors.filter(e => e.includes('dnsServers'));
92+
expect(dnsErrors).toHaveLength(1);
93+
expect(dnsErrors[0]).toBe('config.network.dnsServers must be an array of strings');
94+
});
95+
96+
it('handles rateLimiting integer fields', () => {
97+
expect(validateWithSchema({ rateLimiting: { requestsPerMinute: 0 } }))
98+
.toContain('config.rateLimiting.requestsPerMinute must be a positive integer');
99+
expect(validateWithSchema({ rateLimiting: { requestsPerHour: -1 } }))
100+
.toContain('config.rateLimiting.requestsPerHour must be a positive integer');
101+
expect(validateWithSchema({ rateLimiting: { bytesPerMinute: 'lots' } }))
102+
.toContain('config.rateLimiting.bytesPerMinute must be a positive integer');
103+
});
104+
105+
it('returns multiple errors for multiple issues', () => {
106+
const errors = validateWithSchema({
107+
unknownTop: true,
108+
network: { allowDomains: 'not-array' },
109+
container: { agentTimeout: -5 },
110+
});
111+
expect(errors.length).toBeGreaterThanOrEqual(3);
112+
});
113+
114+
it('validates models as object with string-array values', () => {
115+
expect(validateWithSchema({ apiProxy: { models: { 'gpt-4o': ['alias1'] } } })).toEqual([]);
116+
const errors = validateWithSchema({ apiProxy: { models: { 'gpt-4o': 'not-array' } } });
117+
expect(errors.length).toBeGreaterThan(0);
118+
});
119+
});
120+
});

src/schema-validator.ts

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,26 +41,13 @@ const validate = ajv.compile(loadSchema());
4141
*/
4242
function isArrayOfStringsField(err: ErrorObject): boolean {
4343
// When type=array fails, check if the schema also specifies items.type=string
44-
if (err.keyword === 'type' && err.params.type === 'array') {
45-
const parentSchema = err.parentSchema as Record<string, unknown> | undefined;
46-
if (parentSchema) {
47-
const items = parentSchema.items as Record<string, unknown> | undefined;
48-
if (items && items.type === 'string') {
49-
return true;
50-
}
51-
}
52-
}
53-
return false;
54-
}
55-
56-
/**
57-
* Check if an integer field also has minimum:1 constraint (making it "positive integer").
58-
*/
59-
function hasMinimumOne(err: ErrorObject): boolean {
44+
// All array fields in the AWF schema have items.type=string, so parentSchema is always present
6045
const parentSchema = err.parentSchema as Record<string, unknown> | undefined;
61-
return parentSchema?.minimum === 1;
46+
const items = parentSchema?.items as Record<string, unknown> | undefined;
47+
return err.keyword === 'type' && err.params.type === 'array' && items?.type === 'string';
6248
}
6349

50+
6451
/**
6552
* Format a single ajv error into a human-readable string matching the
6653
* style of the previous hand-written validator (e.g. "config.network.allowDomains must be ...").
@@ -76,11 +63,11 @@ function formatError(err: ErrorObject): string {
7663
return `${path} must be an array of strings`;
7764
}
7865
if (err.params.type === 'integer') {
79-
return hasMinimumOne(err)
80-
? `${path} must be a positive integer`
81-
: `${path} must be an integer`;
66+
// All integer fields in the AWF schema have minimum:1
67+
return `${path} must be a positive integer`;
8268
}
83-
return `${path} must be ${err.params.type === 'object' ? 'an object' : determineArticle(err.params.type) + err.params.type}`;
69+
// 'object' → "an object"; remaining types (boolean, string, number) → "a <type>"
70+
return `${path} must be ${err.params.type === 'object' ? 'an object' : `a ${err.params.type}`}`;
8471

8572
case 'additionalProperties':
8673
return `${path}.${err.params.additionalProperty} is not supported`;
@@ -98,14 +85,11 @@ function formatError(err: ErrorObject): string {
9885
return `${path} must be an array of strings`;
9986

10087
default:
88+
/* istanbul ignore next -- defensive: all current schema keywords are handled above */
10189
return `${path} ${err.message || 'is invalid'}`;
10290
}
10391
}
10492

105-
function determineArticle(type: string): string {
106-
return /^[aeiou]/i.test(type) ? 'an ' : 'a ';
107-
}
108-
10993
/**
11094
* Deduplicate and simplify errors. Ajv often emits multiple errors for one
11195
* conceptual issue (e.g. oneOf failures emit sub-schema failures + the oneOf itself).

0 commit comments

Comments
 (0)