project-bifrost-platform/tests/admin-validate.test.ts
Jonathan Hvid ea056fff7b feat(admin): add Resource type definitions + form validator
Establishes the load-bearing type surface for the Backstage rebuild:

- src/admin/resource-types.ts — discriminated unions for Field, Column,
  Filter, Action, plus the top-level Resource and ResourceGroup. Strict
  per the maintainability bar: a config object missing a required key
  fails TypeScript.
- src/admin/validate.ts — validateForResource() derives validation
  from the field definitions (required, maxLength, multi-text min/max,
  number bounds, date parse, visibleWhen-aware).
- tests/admin-validate.test.ts — 8 cases locking the validator API:
  required, maxLength, visibleWhen skip & reveal, multi-text bounds,
  number bounds, all-valid, form-null short-circuit.

No consumers yet. Next commit pulls these into admin.css and the
shared layout components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:56:42 +02:00

113 lines
3.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { validateForResource } from '../src/admin/validate';
import type { Resource } from '../src/admin/resource-types';
// Minimal resource fixture covering the field kinds the validator handles.
const resource: Resource = {
key: 'fixtures',
label: 'Fixture',
pluralLabel: 'Fixtures',
singularLabel: 'Fixture',
groupKey: 'system',
list: { queryFn: () => [], columns: [] },
ops: {},
form: {
fields: [
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 5 },
{ key: 'kind', label: 'Kind', kind: 'select', options: [{ value: 'a', label: 'A' }] },
{
key: 'options',
label: 'Options',
kind: 'multi-text',
minItems: 2,
maxItems: 4,
},
{ key: 'count', label: 'Count', kind: 'number', min: 0, max: 10 },
{
key: 'extras',
label: 'Extras',
kind: 'text',
// Only visible when kind === 'a'
visibleWhen: (ctx) => ctx.formValues.kind === 'a',
required: true,
},
],
},
};
const baseArgs = { resource, item: null, actingUserId: 1 };
describe('validateForResource', () => {
it('reports required fields that are empty', () => {
const errors = validateForResource({ ...baseArgs, data: {} });
expect(errors.title).toBe('Title is required');
});
it('reports maxLength violations on text fields', () => {
const errors = validateForResource({ ...baseArgs, data: { title: 'too long' } });
expect(errors.title).toMatch(/5 characters or fewer/);
});
it('skips fields hidden by visibleWhen', () => {
// kind = 'b' hides the 'extras' required field — no error expected
const errors = validateForResource({
...baseArgs,
data: { title: 'ok', kind: 'b' },
});
expect(errors.extras).toBeUndefined();
});
it('enforces visibleWhen-revealed required fields', () => {
const errors = validateForResource({
...baseArgs,
data: { title: 'ok', kind: 'a' },
});
expect(errors.extras).toBe('Extras is required');
});
it('enforces multi-text min/max items', () => {
const tooFew = validateForResource({
...baseArgs,
data: { title: 'ok', options: ['only-one'] },
});
expect(tooFew.options).toMatch(/at least 2/);
const tooMany = validateForResource({
...baseArgs,
data: { title: 'ok', options: ['1', '2', '3', '4', '5'] },
});
expect(tooMany.options).toMatch(/at most 4/);
});
it('enforces number min/max', () => {
const tooHigh = validateForResource({
...baseArgs,
data: { title: 'ok', count: 99 },
});
expect(tooHigh.count).toMatch(/no more than 10/);
});
it('returns no errors when every field is valid', () => {
const errors = validateForResource({
...baseArgs,
data: {
title: 'ok',
kind: 'a',
options: ['x', 'y'],
count: 5,
extras: 'fine',
},
});
expect(errors).toEqual({});
});
it('treats form: null as always valid', () => {
const readOnly: Resource = { ...resource, form: null };
const errors = validateForResource({
...baseArgs,
resource: readOnly,
data: {},
});
expect(errors).toEqual({});
});
});