Files
OpenNoodl/packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Richard Osborne 6e0ad689ae feat(styles+uba): STYLE-005 wiring + UBA-001/002
STYLE-005: ElementStyleSectionHost wraps ElementStyleSection with
SuggestionBanner. Uses useStyleSuggestions + executeSuggestionAction.
propertyeditor.ts updated to use host (drop-in, zero API change).

UBA-001: types.ts — Field discriminated union (8 types), BackendMetadata,
Section, Condition, ParseResult<T>. Zero external deps.

UBA-002: SchemaParser.ts validates pre-parsed objects against UBA v1.
Forward-compat: unknown field types/versions produce warnings not errors.
22 unit tests in tests/models/UBASchemaParser.test.ts.
2026-02-18 17:38:55 +01:00

312 lines
11 KiB
TypeScript

/**
* UBA-002: Unit tests for SchemaParser
*
* Tests run against pure JS objects (no YAML parsing needed).
* Covers: happy path, required field errors, optional fields,
* field type validation, warnings for unknown types/versions.
*/
import { describe, it, expect, beforeEach } from '@jest/globals';
import { SchemaParser } from '../../src/editor/src/models/UBA/SchemaParser';
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const minimalValid = {
schema_version: '1.0',
backend: {
id: 'test-backend',
name: 'Test Backend',
version: '1.0.0',
endpoints: {
config: 'https://example.com/config'
}
},
sections: []
};
function makeValid(overrides: Record<string, unknown> = {}) {
return { ...minimalValid, ...overrides };
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('SchemaParser', () => {
let parser: SchemaParser;
beforeEach(() => {
parser = new SchemaParser();
});
// ─── Root validation ───────────────────────────────────────────────────────
describe('root validation', () => {
it('rejects null input', () => {
const result = parser.parse(null);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors[0].path).toBe('');
}
});
it('rejects array input', () => {
const result = parser.parse([]);
expect(result.success).toBe(false);
});
it('rejects missing schema_version', () => {
const { schema_version: _sv, ...noVersion } = minimalValid;
const result = parser.parse(noVersion);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true);
}
});
it('warns on unknown major version', () => {
const result = parser.parse(makeValid({ schema_version: '2.0' }));
// Should still succeed (best-effort) but include a warning
expect(result.success).toBe(true);
if (result.success) {
expect(result.warnings?.some((w) => w.includes('2.0'))).toBe(true);
}
});
it('accepts a minimal valid schema', () => {
const result = parser.parse(minimalValid);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.schema_version).toBe('1.0');
expect(result.data.backend.id).toBe('test-backend');
expect(result.data.sections).toEqual([]);
}
});
});
// ─── Backend validation ────────────────────────────────────────────────────
describe('backend validation', () => {
it('errors when backend is missing', () => {
const { backend: _b, ...noBackend } = minimalValid;
const result = parser.parse(noBackend);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors.some((e) => e.path === 'backend')).toBe(true);
}
});
it('errors when backend.id is missing', () => {
const data = makeValid({ backend: { ...minimalValid.backend, id: undefined } });
const result = parser.parse(data);
expect(result.success).toBe(false);
});
it('errors when backend.endpoints.config is missing', () => {
const data = makeValid({
backend: { ...minimalValid.backend, endpoints: { health: '/health' } }
});
const result = parser.parse(data);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true);
}
});
it('accepts optional backend fields', () => {
const data = makeValid({
backend: {
...minimalValid.backend,
description: 'My backend',
icon: 'https://example.com/icon.png',
homepage: 'https://example.com',
auth: { type: 'bearer' },
capabilities: { hot_reload: true, debug: false }
}
});
const result = parser.parse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.backend.description).toBe('My backend');
expect(result.data.backend.auth?.type).toBe('bearer');
expect(result.data.backend.capabilities?.hot_reload).toBe(true);
}
});
it('errors on invalid auth type', () => {
const data = makeValid({
backend: { ...minimalValid.backend, auth: { type: 'oauth2' } }
});
const result = parser.parse(data);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true);
}
});
});
// ─── Sections validation ───────────────────────────────────────────────────
describe('sections validation', () => {
it('errors when sections is not an array', () => {
const data = makeValid({ sections: 'not-an-array' });
const result = parser.parse(data);
expect(result.success).toBe(false);
});
it('accepts a section with minimal fields', () => {
const data = makeValid({
sections: [{ id: 'general', name: 'General', fields: [] }]
});
const result = parser.parse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections[0].id).toBe('general');
expect(result.data.sections[0].fields).toEqual([]);
}
});
it('skips section without id and collects error', () => {
const data = makeValid({
sections: [{ name: 'Missing ID', fields: [] }]
});
const result = parser.parse(data);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors.some((e) => e.path.includes('id'))).toBe(true);
}
});
});
// ─── Field type validation ─────────────────────────────────────────────────
describe('field types', () => {
function sectionWith(fields: unknown[]) {
return makeValid({ sections: [{ id: 's', name: 'S', fields }] });
}
it('parses a string field', () => {
const result = parser.parse(sectionWith([{ id: 'host', name: 'Host', type: 'string', default: 'localhost' }]));
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('string');
if (field.type === 'string') expect(field.default).toBe('localhost');
}
});
it('parses a boolean field', () => {
const result = parser.parse(sectionWith([{ id: 'ssl', name: 'SSL', type: 'boolean', default: true }]));
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('boolean');
if (field.type === 'boolean') expect(field.default).toBe(true);
}
});
it('parses a select field with options', () => {
const result = parser.parse(
sectionWith([
{
id: 'region',
name: 'Region',
type: 'select',
options: [
{ value: 'eu-west-1', label: 'EU West' },
{ value: 'us-east-1', label: 'US East' }
],
default: 'eu-west-1'
}
])
);
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('select');
if (field.type === 'select') {
expect(field.options).toHaveLength(2);
expect(field.default).toBe('eu-west-1');
}
}
});
it('errors when select field has no options array', () => {
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'select' }]));
expect(result.success).toBe(false);
});
it('warns on unknown field type and skips it', () => {
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'color_picker' }]));
// Section-level parse succeeds (unknown field is skipped, not fatal)
if (result.success) {
expect(result.data.sections[0].fields).toHaveLength(0);
expect(result.warnings?.some((w) => w.includes('color_picker'))).toBe(true);
}
});
it('parses a number field with min/max', () => {
const result = parser.parse(
sectionWith([{ id: 'port', name: 'Port', type: 'number', default: 5432, min: 1, max: 65535 }])
);
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
if (field.type === 'number') {
expect(field.default).toBe(5432);
expect(field.min).toBe(1);
expect(field.max).toBe(65535);
}
}
});
it('parses a secret field', () => {
const result = parser.parse(sectionWith([{ id: 'api_key', name: 'API Key', type: 'secret' }]));
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('secret');
}
});
it('parses a multi_select field', () => {
const result = parser.parse(
sectionWith([
{
id: 'roles',
name: 'Roles',
type: 'multi_select',
options: [
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write' }
]
}
])
);
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('multi_select');
}
});
});
// ─── Debug schema ──────────────────────────────────────────────────────────
describe('debug schema', () => {
it('parses an enabled debug block', () => {
const data = makeValid({
debug: {
enabled: true,
event_schema: [{ id: 'query_time', name: 'Query Time', type: 'number' }]
}
});
const result = parser.parse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.debug?.enabled).toBe(true);
expect(result.data.debug?.event_schema).toHaveLength(1);
}
});
});
});