mirror of
https://github.com/yamadashy/repomix.git
synced 2026-05-30 11:18:53 +02:00
61e853e318
intent(drift-guard): claude's follow-up review flagged that the hand-rolled schemaErrorWith fixture can't catch a change in valibot's internal PathItem shape — fixture-based tests would stay green while production fails, so add one real-valibot-issue case decision(fixture-vs-real): keep the existing fixture-based tests as the bulk of coverage (cheap, focused on classifier logic) and layer a single v.safeParse-driven test on top — the fixtures stay fast, the new test catches shape drift Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
5.1 KiB
TypeScript
115 lines
5.1 KiB
TypeScript
import * as v from 'valibot';
|
|
import { describe, expect, test } from 'vitest';
|
|
import { classifyRejectReason, getRepoHost } from '../src/actions/packEventSchema.js';
|
|
import { MESSAGES } from '../src/actions/packRequestMessages.js';
|
|
|
|
// Classifier drift test — imports MESSAGES from the same shared module that
|
|
// packRequestSchema uses. This means a message-text rewrite automatically
|
|
// propagates to the schema (producer), the classifier (consumer), AND the
|
|
// test's expected values, so schema/classifier drift is impossible by
|
|
// construction. The test's value is catching classifier-logic drift: if the
|
|
// classifier's MESSAGE_TO_REASON map loses a key (or maps it to the wrong
|
|
// label), the corresponding case here fails.
|
|
|
|
// Construct a minimal valibot-shaped issue. classifyRejectReason only reads
|
|
// `.message` and `.path` from the first issue, so a plain object is enough —
|
|
// instantiating v.ValiError here would require synthesizing a full BaseIssue
|
|
// (kind/type/input/expected/received) that the classifier never touches.
|
|
const schemaErrorWith = (message: string, path: readonly (string | number)[] = []) => ({
|
|
name: 'ValiError',
|
|
issues: [{ message, path: path.map((key) => ({ key })) }],
|
|
});
|
|
|
|
// Mimic the AppError-with-cause wrapping that `validateRequest` does in
|
|
// production — native Error with `cause` is enough to exercise the
|
|
// cause-chain path in classifyRejectReason.
|
|
const wrapped = (message: string, path: readonly (string | number)[] = []) =>
|
|
new Error(`Invalid request: ${message}`, { cause: schemaErrorWith(message, path) });
|
|
|
|
describe('classifyRejectReason', () => {
|
|
test.each([
|
|
['missing_input', MESSAGES.MISSING_INPUT],
|
|
['both_provided', MESSAGES.BOTH_PROVIDED],
|
|
['invalid_url', MESSAGES.INVALID_URL],
|
|
['url_too_long', MESSAGES.URL_TOO_LONG],
|
|
['url_empty', MESSAGES.URL_REQUIRED],
|
|
['invalid_file', MESSAGES.INVALID_FILE],
|
|
['not_zip', MESSAGES.NOT_ZIP],
|
|
['file_too_large', MESSAGES.FILE_TOO_LARGE],
|
|
['invalid_ignore_chars', MESSAGES.INVALID_IGNORE_CHARS],
|
|
['include_too_long', MESSAGES.INCLUDE_TOO_LONG],
|
|
['ignore_too_long', MESSAGES.IGNORE_TOO_LONG],
|
|
])('%s — classifies "%s"', (expected, message) => {
|
|
expect(classifyRejectReason(schemaErrorWith(message))).toBe(expected);
|
|
// Wrapped via AppError.cause (the real production path)
|
|
expect(classifyRejectReason(wrapped(message))).toBe(expected);
|
|
});
|
|
|
|
test('invalid_format — path "format" maps regardless of message text', () => {
|
|
const err = schemaErrorWith('any schema message', ['format']);
|
|
expect(classifyRejectReason(err)).toBe('invalid_format');
|
|
});
|
|
|
|
test('other — unmapped message + unmapped path', () => {
|
|
const err = schemaErrorWith('some never-seen message', ['options', 'compress']);
|
|
expect(classifyRejectReason(err)).toBe('other');
|
|
});
|
|
|
|
test('unknown — non-error input', () => {
|
|
expect(classifyRejectReason(null)).toBe('unknown');
|
|
expect(classifyRejectReason(undefined)).toBe('unknown');
|
|
expect(classifyRejectReason('string error')).toBe('unknown');
|
|
});
|
|
|
|
test('unknown — schema error with empty issues', () => {
|
|
expect(classifyRejectReason({ name: 'ValiError', issues: [] })).toBe('unknown');
|
|
});
|
|
|
|
test('unknown — plain Error without issues', () => {
|
|
expect(classifyRejectReason(new Error('bare error'))).toBe('unknown');
|
|
});
|
|
|
|
test('cause-chain extraction — issues live on error.cause (AppError path)', () => {
|
|
const wrappedErr = wrapped(MESSAGES.MISSING_INPUT);
|
|
// Cause is the raw schema-error shape, carried through AppError wrapping.
|
|
expect((wrappedErr.cause as { issues: unknown[] }).issues).toHaveLength(1);
|
|
expect(classifyRejectReason(wrappedErr)).toBe('missing_input');
|
|
});
|
|
|
|
test('real valibot issues — guards against PathItem shape drift', () => {
|
|
// The other tests in this file use a hand-rolled `schemaErrorWith` fixture.
|
|
// That's fine for exercising classifier logic, but if valibot ever changes
|
|
// its internal PathItem shape the fixture would silently lie — green tests,
|
|
// red production. This test runs v.safeParse so the classifier sees a real
|
|
// valibot-emitted issue; if the shape drifts, `v.getDotPath` here returns
|
|
// something other than `'format'` and `invalid_format` bucketing breaks.
|
|
const schema = v.object({ format: v.picklist(['xml', 'markdown']) });
|
|
const result = v.safeParse(schema, { format: 'yaml' });
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(classifyRejectReason({ name: 'ValiError', issues: result.issues })).toBe('invalid_format');
|
|
});
|
|
});
|
|
|
|
describe('getRepoHost', () => {
|
|
test('file upload', () => {
|
|
expect(getRepoHost({ file: {} as unknown })).toBe('upload');
|
|
});
|
|
|
|
test('github URL', () => {
|
|
expect(getRepoHost({ url: 'https://github.com/yamadashy/repomix' })).toBe('github.com');
|
|
});
|
|
|
|
test('gitlab URL', () => {
|
|
expect(getRepoHost({ url: 'https://gitlab.com/user/repo' })).toBe('gitlab.com');
|
|
});
|
|
|
|
test('shorthand owner/repo → github.com fallback', () => {
|
|
expect(getRepoHost({ url: 'yamadashy/repomix' })).toBe('github.com');
|
|
});
|
|
|
|
test('neither url nor file', () => {
|
|
expect(getRepoHost({})).toBe('unknown');
|
|
});
|
|
});
|