Files
apophis-fastify/src/test/cli/config-validation.test.ts
T
John Dvorak 6295c476dc refactor: remove depth/generationProfile tiering, use explicit runs
- Replace TestDepth ('quick'|'standard'|'thorough') with explicit runs:number
- Remove GenerationProfile type and all generationProfile parameters
- schema-to-arbitrary.ts now uses fixed defaults (string≤128, array≤10, props≤6)
- Delete src/cli/core/generation-profile.ts
- Remove --generation-profile CLI flag from verify and qualify
- Remove depth field from PresetDefinition and all preset scaffolds
- Remove generationProfiles from Config interface
- Update all test files: depth:'quick' → runs:10
- Remove depth from all fixture configs
- Update builders.ts, mutation.ts, outbound-mock-runtime.ts
- Build: clean | Tests: 849 pass, 0 fail
2026-04-30 13:47:24 -07:00

390 lines
12 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert';
import * as fc from 'fast-check';
import {
validateConfigAgainstSchema,
validateConfigSemantics,
ConfigValidationError,
CONFIG_SCHEMA,
loadConfig,
} from '../../cli/core/config-loader.js';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
function tempDir(): string {
return mkdtempSync(join(tmpdir(), 'apophis-config-test-'));
}
function writeJson(dir: string, file: string, value: unknown): string {
const filePath = join(dir, file);
writeFileSync(filePath, JSON.stringify(value, null, 2));
return filePath;
}
function expectValidationError(
fn: () => void,
expectedPath: string,
): ConfigValidationError {
try {
fn();
assert.fail('Expected ConfigValidationError');
} catch (err) {
assert.ok(err instanceof ConfigValidationError);
assert.strictEqual(err.path, expectedPath);
return err;
}
}
async function expectLoadConfigError(
config: Record<string, unknown>,
expectedPath: string,
options: Partial<Parameters<typeof loadConfig>[0]> = {},
): Promise<ConfigValidationError> {
const dir = tempDir();
writeJson(dir, 'apophis.config.json', config);
try {
await loadConfig({ cwd: dir, ...options });
assert.fail('Expected ConfigValidationError');
} catch (err) {
assert.ok(err instanceof ConfigValidationError);
assert.strictEqual(err.path, expectedPath);
return err;
}
}
test('schema: accepts minimal valid configs', () => {
validateConfigAgainstSchema({}, CONFIG_SCHEMA);
validateConfigAgainstSchema({ mode: 'verify', routes: ['GET /users'], seed: 42 }, CONFIG_SCHEMA);
validateConfigAgainstSchema({ presets: { quick: { runs: 10, timeout: 5000 } } }, CONFIG_SCHEMA);
});
test('schema: rejects unknown keys with guidance', () => {
const cases = [
{
value: { unknown: true },
path: 'unknown',
guidance: 'Valid top-level keys',
},
{
value: { profiles: { default: { unknownKey: 1 } } },
path: 'profiles.default.unknownKey',
guidance: 'Valid keys for profiles entries',
},
{
value: { presets: { quick: { unknownKey: 1 } } },
path: 'presets.quick.unknownKey',
guidance: 'Valid keys for presets entries',
},
{
value: { environments: { ci: { unknownKey: 1 } } },
path: 'environments.ci.unknownKey',
guidance: 'Valid keys for environments entries',
},
] as const;
for (const c of cases) {
const err = expectValidationError(() => validateConfigAgainstSchema(c.value, CONFIG_SCHEMA), c.path);
assert.ok(err.message.includes('Unknown config key'));
assert.ok(err.guidance?.includes(c.guidance));
}
});
test('schema: rejects key type mismatches', () => {
const cases = [
{ value: { mode: 123 }, path: 'mode', key: 'mode', expectedType: 'string' },
{ value: { seed: '42' }, path: 'seed', key: 'seed', expectedType: 'number' },
{ value: { routes: 'GET /users' }, path: 'routes', key: 'routes', expectedType: 'array' },
{ value: { routes: ['GET /users', 1] }, path: 'routes[1]', key: 'routes[1]', expectedType: 'string' },
{
value: { presets: { quick: { parallel: 'yes' } } },
path: 'presets.quick.parallel',
key: 'parallel',
expectedType: 'boolean',
},
{
value: { environments: { ci: { allowVerify: 'yes' } } },
path: 'environments.ci.allowVerify',
key: 'allowVerify',
expectedType: 'boolean',
},
] as const;
for (const c of cases) {
const err = expectValidationError(() => validateConfigAgainstSchema(c.value, CONFIG_SCHEMA), c.path);
assert.strictEqual(err.key, c.key);
assert.ok(err.message.includes('Invalid type'));
assert.ok(err.message.includes(`expected ${c.expectedType}`));
}
});
test('schema: rejects enum and numeric range violations with clear guidance', () => {
const enumCases = [
{
value: { mode: 'invalid' },
path: 'mode',
expectedGuidance: ['verify', 'observe', 'qualify'],
},
{
value: { profiles: { default: { mode: 'invalid' } } },
path: 'profiles.default.mode',
expectedGuidance: ['verify', 'observe', 'qualify'],
},
] as const;
for (const c of enumCases) {
const err = expectValidationError(() => validateConfigAgainstSchema(c.value, CONFIG_SCHEMA), c.path);
assert.ok(err.message.includes('Invalid value'));
for (const token of c.expectedGuidance) {
assert.ok(err.guidance?.includes(token));
}
}
const timeoutErr = expectValidationError(
() => validateConfigAgainstSchema({ presets: { quick: { timeout: -1 } } }, CONFIG_SCHEMA),
'presets.quick.timeout',
);
assert.ok(timeoutErr.message.includes('less than minimum'));
assert.ok(timeoutErr.guidance?.includes('>='));
});
test('property: non-string mode always fails schema validation', () => {
const invalidModeArbitrary = fc.oneof(
fc.boolean(),
fc.integer(),
fc.double({ noNaN: true }),
fc.array(fc.anything()),
fc.object(),
);
fc.assert(
fc.property(invalidModeArbitrary, (modeValue) => {
const err = expectValidationError(
() => validateConfigAgainstSchema({ mode: modeValue }, CONFIG_SCHEMA),
'mode',
);
assert.strictEqual(err.key, 'mode');
assert.ok(err.message.includes('expected string'));
}),
{ seed: 1337, numRuns: 75 },
);
});
test('property: routes arrays with one non-string item fail at that index', () => {
const badItemArbitrary = fc.oneof(fc.integer(), fc.boolean(), fc.object());
fc.assert(
fc.property(
fc.array(fc.string(), { maxLength: 4 }),
badItemArbitrary,
fc.array(fc.string(), { maxLength: 4 }),
(prefix, badItem, suffix) => {
const routes = [...prefix, badItem, ...suffix];
const expectedPath = `routes[${prefix.length}]`;
const err = expectValidationError(
() => validateConfigAgainstSchema({ routes }, CONFIG_SCHEMA),
expectedPath,
);
assert.strictEqual(err.key, `routes[${prefix.length}]`);
},
),
{ seed: 2026, numRuns: 75 },
);
});
test('semantic: validates cross-reference and value rules', () => {
const rejectCases = [
{
value: { profiles: { default: { preset: 'missing' } }, presets: { quick: {} } },
path: 'profiles.default.preset',
guidance: 'Available presets',
},
{
value: { environments: { staging: { allowedModes: ['verify', 'hack'] } } },
path: 'environments.staging.allowedModes',
guidance: 'Allowed modes',
},
{ value: { routes: ['GET /users', ''] }, path: 'routes[1]', guidance: 'non-empty strings' },
{ value: { routes: [' '] }, path: 'routes[0]', guidance: 'non-empty strings' },
{ value: { seed: 3.14 }, path: 'seed', guidance: 'integer' },
{ value: { presets: { quick: { timeout: -100 } } }, path: 'presets.quick.timeout', guidance: 'non-negative' },
{
value: { presets: { quick: { timeout: -100 } } },
path: 'presets.quick.timeout',
guidance: 'non-negative',
},
] as const;
for (const c of rejectCases) {
const err = expectValidationError(() => validateConfigSemantics(c.value as any), c.path);
assert.ok((err.guidance ?? '').includes(c.guidance));
}
const acceptCases = [
{ profiles: { default: { preset: 'quick' } }, presets: { quick: {} } },
{ environments: { staging: { allowedModes: ['verify', 'observe'] } } },
{ routes: ['GET /users', 'POST /items'] },
{ seed: -42 },
{ seed: 0 },
{ presets: { quick: { timeout: 0 } } },
];
for (const value of acceptCases) {
validateConfigSemantics(value as any);
}
});
test('ConfigValidationError exposes user-facing diagnostics fields', () => {
const err = new ConfigValidationError('Invalid value', 'mode', 'mode', 'bad', 'Must be one of: verify, observe, qualify.');
assert.ok(err instanceof Error);
assert.strictEqual(err.name, 'ConfigValidationError');
assert.strictEqual(err.path, 'mode');
assert.strictEqual(err.key, 'mode');
assert.strictEqual(err.value, 'bad');
assert.ok(err.guidance?.includes('Must be one of'));
});
test('loadConfig: returns empty config when nothing is discovered', async () => {
const dir = tempDir();
const result = await loadConfig({ cwd: dir });
assert.deepStrictEqual(result.config, {});
assert.strictEqual(result.configPath, null);
assert.strictEqual(result.profileName, null);
assert.strictEqual(result.presetName, null);
});
test('loadConfig: discovery order prefers apophis.config.js over json', async () => {
const dir = tempDir();
writeFileSync(join(dir, 'apophis.config.js'), `module.exports = { mode: 'verify', seed: 1 };`);
writeJson(dir, 'apophis.config.json', { mode: 'observe', seed: 2 });
const result = await loadConfig({ cwd: dir });
assert.strictEqual(result.config.mode, 'verify');
assert.strictEqual(result.config.seed, 1);
});
test('loadConfig: uses package.json apophis config when no config file exists', async () => {
const dir = tempDir();
writeJson(dir, 'package.json', {
name: 'test',
apophis: { mode: 'observe', seed: 99 },
});
const result = await loadConfig({ cwd: dir });
assert.strictEqual(result.config.mode, 'observe');
assert.strictEqual(result.config.seed, 99);
});
test('loadConfig: explicit config path missing throws useful error', async () => {
const dir = tempDir();
await assert.rejects(
loadConfig({ cwd: dir, configPath: 'missing.json' }),
(err: unknown) => err instanceof Error && err.message.includes('Config file not found'),
);
});
test('loadConfig: preserves schema and semantic diagnostics', async () => {
const schemaErr = await expectLoadConfigError({ mode: 123 }, 'mode');
assert.ok(schemaErr.message.includes('Invalid type'));
const semanticErr = await expectLoadConfigError(
{ profiles: { default: { preset: 'missing' } }, presets: { quick: {} } },
'profiles.default.preset',
);
assert.ok((semanticErr.guidance ?? '').includes('Available presets'));
});
test('loadConfig: schema validation runs before semantic validation', async () => {
const err = await expectLoadConfigError(
{ mode: 'invalid', profiles: { default: { preset: 'missing' } } },
'mode',
);
assert.ok(err.message.includes('Invalid value'));
});
test('loadConfig: resolves profile and preset and applies profile overrides', async () => {
const dir = tempDir();
writeJson(dir, 'apophis.config.json', {
mode: 'verify',
seed: 1,
profiles: {
default: {
preset: 'quick',
seed: 42,
routes: ['GET /health'],
},
},
presets: {
quick: {
runs: 10,
timeout: 5000,
parallel: false,
},
},
});
const result = await loadConfig({ cwd: dir, profileName: 'default' });
assert.strictEqual(result.profileName, 'default');
assert.strictEqual(result.presetName, 'quick');
assert.strictEqual(result.config.seed, 42);
assert.deepStrictEqual(result.config.routes, ['GET /health']);
assert.strictEqual((result.config as Record<string, unknown>).timeout, 5000);
});
test('loadConfig: unknown profile includes available profile names', async () => {
const dir = tempDir();
writeJson(dir, 'apophis.config.json', {
profiles: {
default: {},
nightly: {},
},
});
await assert.rejects(
loadConfig({ cwd: dir, profileName: 'missing' }),
(err: unknown) => {
if (!(err instanceof Error)) {
return false;
}
return err.message.includes('Unknown profile')
&& err.message.includes('default')
&& err.message.includes('nightly');
},
);
});
test('loadConfig: keeps environment policy data when env is selected', async () => {
const dir = tempDir();
writeJson(dir, 'apophis.config.json', {
mode: 'verify',
environments: {
staging: {
allowVerify: true,
allowObserve: true,
},
},
});
const result = await loadConfig({ cwd: dir, env: 'staging' });
assert.strictEqual(result.config.mode, 'verify');
assert.deepStrictEqual(result.config.environments?.staging, {
allowVerify: true,
allowObserve: true,
});
});
test('loadConfig: monorepo detection reports true when workspaces exist', async () => {
const dir = tempDir();
writeJson(dir, 'package.json', {
name: 'root',
workspaces: ['packages/*'],
});
mkdirSync(join(dir, 'packages', 'api'), { recursive: true });
writeJson(join(dir, 'packages', 'api'), 'package.json', { name: 'api' });
const result = await loadConfig({ cwd: dir });
assert.strictEqual(result.isMonorepo, true);
});