mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
feat(schemas): STRUCT-001 - JSON Schema Definition for v2 project format
Add 8 JSON schemas, SchemaValidator with Ajv v8, TS interfaces, and 33 tests. All smoke tests passing. Critical path for STRUCT-002 (Export Engine).
This commit is contained in:
1
packages/noodl-editor/tests/schemas/index.ts
Normal file
1
packages/noodl-editor/tests/schemas/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './schema-validator.test';
|
||||
745
packages/noodl-editor/tests/schemas/schema-validator.test.ts
Normal file
745
packages/noodl-editor/tests/schemas/schema-validator.test.ts
Normal file
@@ -0,0 +1,745 @@
|
||||
/**
|
||||
* Schema Validator Tests — STRUCT-001
|
||||
*
|
||||
* Tests for the Ajv-based validation utilities covering all 8 v2 format schemas.
|
||||
* Each schema is tested with a valid minimal fixture and key invalid cases.
|
||||
*/
|
||||
|
||||
import { SchemaValidator, SCHEMA_IDS, validateSchema, formatValidationErrors } from '../../src/editor/src/schemas/validator';
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const validProject = {
|
||||
name: 'My Conference App',
|
||||
version: '2.0',
|
||||
nodegxVersion: '1.1.0'
|
||||
};
|
||||
|
||||
const validComponent = {
|
||||
id: 'comp_header_abc123',
|
||||
name: 'Header',
|
||||
type: 'visual'
|
||||
};
|
||||
|
||||
const validNodes = {
|
||||
componentId: 'comp_header_abc123',
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 'node_root_001',
|
||||
type: 'Group',
|
||||
label: 'Header Container',
|
||||
x: 0,
|
||||
y: 0,
|
||||
parameters: { layout: 'row' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const validConnections = {
|
||||
componentId: 'comp_header_abc123',
|
||||
version: 1,
|
||||
connections: [
|
||||
{
|
||||
fromId: 'node_root_001',
|
||||
fromProperty: 'onClick',
|
||||
toId: 'node_root_002',
|
||||
toProperty: 'trigger'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const validRegistry = {
|
||||
version: 1,
|
||||
lastUpdated: '2026-02-18T00:00:00.000Z',
|
||||
components: {
|
||||
Header: {
|
||||
path: 'Header',
|
||||
type: 'visual',
|
||||
nodeCount: 10,
|
||||
connectionCount: 5
|
||||
}
|
||||
},
|
||||
stats: {
|
||||
totalComponents: 1,
|
||||
totalNodes: 10,
|
||||
totalConnections: 5
|
||||
}
|
||||
};
|
||||
|
||||
const validRoutes = {
|
||||
routes: [
|
||||
{ path: '/home', component: 'pages/HomePage' },
|
||||
{ path: '/profile', component: 'pages/ProfilePage', title: 'Profile' }
|
||||
]
|
||||
};
|
||||
|
||||
const validStyles = {
|
||||
version: 1,
|
||||
colors: {
|
||||
primary: '#3B82F6',
|
||||
background: '#ffffff'
|
||||
},
|
||||
textStyles: {
|
||||
heading: { fontSize: '24px', fontWeight: '700' }
|
||||
}
|
||||
};
|
||||
|
||||
const validModel = {
|
||||
name: 'User',
|
||||
fields: [
|
||||
{ name: 'username', type: 'String', required: true },
|
||||
{ name: 'email', type: 'String', unique: true },
|
||||
{ name: 'age', type: 'Number' }
|
||||
]
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SchemaValidator', () => {
|
||||
beforeEach(() => {
|
||||
// Reset singleton between tests to ensure clean state
|
||||
SchemaValidator.reset();
|
||||
});
|
||||
|
||||
describe('singleton', () => {
|
||||
it('returns the same instance on repeated calls', () => {
|
||||
const a = SchemaValidator.instance;
|
||||
const b = SchemaValidator.instance;
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('creates a new instance after reset()', () => {
|
||||
const a = SchemaValidator.instance;
|
||||
SchemaValidator.reset();
|
||||
const b = SchemaValidator.instance;
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── project-v2.schema.json ─────────────────────────────────────────────────
|
||||
|
||||
describe('validateProject', () => {
|
||||
it('accepts a valid minimal project file', () => {
|
||||
const result = SchemaValidator.instance.validateProject(validProject);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts a full project file with all optional fields', () => {
|
||||
const result = SchemaValidator.instance.validateProject({
|
||||
...validProject,
|
||||
id: 'proj_abc123',
|
||||
runtimeVersion: 'react19',
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
modified: '2026-02-18T00:00:00.000Z',
|
||||
settings: { rootComponent: 'App', defaultRoute: '/home' },
|
||||
structure: { componentsDir: 'components', modelsDir: 'models', assetsDir: 'assets' },
|
||||
metadata: { styles: {}, cloudservices: {} }
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when name is missing', () => {
|
||||
const result = SchemaValidator.instance.validateProject({ version: '2.0', nodegxVersion: '1.1.0' });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.message.includes('name'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when version is missing', () => {
|
||||
const result = SchemaValidator.instance.validateProject({ name: 'Test', nodegxVersion: '1.1.0' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when nodegxVersion is missing', () => {
|
||||
const result = SchemaValidator.instance.validateProject({ name: 'Test', version: '2.0' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid runtimeVersion enum value', () => {
|
||||
const result = SchemaValidator.instance.validateProject({
|
||||
...validProject,
|
||||
runtimeVersion: 'react18'
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid date-time format for created', () => {
|
||||
const result = SchemaValidator.instance.validateProject({
|
||||
...validProject,
|
||||
created: 'not-a-date'
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-object input', () => {
|
||||
expect(SchemaValidator.instance.validateProject(null).valid).toBe(false);
|
||||
expect(SchemaValidator.instance.validateProject('string').valid).toBe(false);
|
||||
expect(SchemaValidator.instance.validateProject(42).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── component.schema.json ──────────────────────────────────────────────────
|
||||
|
||||
describe('validateComponent', () => {
|
||||
it('accepts a valid minimal component file', () => {
|
||||
const result = SchemaValidator.instance.validateComponent(validComponent);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a full component with ports and dependencies', () => {
|
||||
const result = SchemaValidator.instance.validateComponent({
|
||||
...validComponent,
|
||||
displayName: 'Site Header',
|
||||
path: '/#Header',
|
||||
description: 'Main navigation header',
|
||||
category: 'layout',
|
||||
tags: ['navigation', 'header'],
|
||||
ports: {
|
||||
inputs: [
|
||||
{ name: 'userName', type: 'string', displayName: 'User Name' },
|
||||
{ name: 'isLoggedIn', type: 'boolean', default: false }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'onLogout', type: 'signal' }
|
||||
]
|
||||
},
|
||||
dependencies: ['shared/Avatar', 'shared/Button'],
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
modified: '2026-02-18T00:00:00.000Z'
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when id is missing', () => {
|
||||
const result = SchemaValidator.instance.validateComponent({ name: 'Header', type: 'visual' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when name is missing', () => {
|
||||
const result = SchemaValidator.instance.validateComponent({ id: 'comp_abc', type: 'visual' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid type enum', () => {
|
||||
const result = SchemaValidator.instance.validateComponent({ ...validComponent, type: 'widget' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts all valid type enum values', () => {
|
||||
const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const;
|
||||
for (const type of types) {
|
||||
const result = SchemaValidator.instance.validateComponent({ ...validComponent, type });
|
||||
expect(result.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects port with missing name', () => {
|
||||
const result = SchemaValidator.instance.validateComponent({
|
||||
...validComponent,
|
||||
ports: { inputs: [{ type: 'string' }] }
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── nodes.schema.json ──────────────────────────────────────────────────────
|
||||
|
||||
describe('validateNodes', () => {
|
||||
it('accepts a valid nodes file', () => {
|
||||
const result = SchemaValidator.instance.validateNodes(validNodes);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty nodes array', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
nodes: []
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts nodes with all optional fields', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 'node_001',
|
||||
type: 'Group',
|
||||
label: 'Container',
|
||||
x: 100,
|
||||
y: 200,
|
||||
variant: 'primary',
|
||||
parameters: { layout: 'row', gap: '12px' },
|
||||
stateParameters: { hover: { opacity: 0.8 } },
|
||||
children: ['node_002'],
|
||||
metadata: { comment: 'Main container' }
|
||||
},
|
||||
{
|
||||
id: 'node_002',
|
||||
type: '/#Header',
|
||||
parent: 'node_001',
|
||||
dynamicports: [{ name: 'item-0-label', type: 'string' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when componentId is missing', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({ nodes: [] });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when nodes is missing', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({ componentId: 'comp_abc' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a node with missing id', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
nodes: [{ type: 'Group' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a node with missing type', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
nodes: [{ id: 'node_001' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid annotation values', () => {
|
||||
const annotations = ['Deleted', 'Created', 'Changed'] as const;
|
||||
for (const annotation of annotations) {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
nodes: [{ id: 'node_001', type: 'Group', annotation }]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid annotation value', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
nodes: [{ id: 'node_001', type: 'Group', annotation: 'Modified' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── connections.schema.json ────────────────────────────────────────────────
|
||||
|
||||
describe('validateConnections', () => {
|
||||
it('accepts a valid connections file', () => {
|
||||
const result = SchemaValidator.instance.validateConnections(validConnections);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty connections array', () => {
|
||||
const result = SchemaValidator.instance.validateConnections({
|
||||
componentId: 'comp_abc',
|
||||
connections: []
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when componentId is missing', () => {
|
||||
const result = SchemaValidator.instance.validateConnections({ connections: [] });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when connections is missing', () => {
|
||||
const result = SchemaValidator.instance.validateConnections({ componentId: 'comp_abc' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a connection missing fromId', () => {
|
||||
const result = SchemaValidator.instance.validateConnections({
|
||||
componentId: 'comp_abc',
|
||||
connections: [{ fromProperty: 'onClick', toId: 'node_002', toProperty: 'trigger' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a connection missing toProperty', () => {
|
||||
const result = SchemaValidator.instance.validateConnections({
|
||||
componentId: 'comp_abc',
|
||||
connections: [{ fromId: 'node_001', fromProperty: 'onClick', toId: 'node_002' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts connection with annotation', () => {
|
||||
const result = SchemaValidator.instance.validateConnections({
|
||||
componentId: 'comp_abc',
|
||||
connections: [
|
||||
{
|
||||
fromId: 'node_001',
|
||||
fromProperty: 'onClick',
|
||||
toId: 'node_002',
|
||||
toProperty: 'trigger',
|
||||
annotation: 'Created'
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── registry.schema.json ───────────────────────────────────────────────────
|
||||
|
||||
describe('validateRegistry', () => {
|
||||
it('accepts a valid registry file', () => {
|
||||
const result = SchemaValidator.instance.validateRegistry(validRegistry);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty components map', () => {
|
||||
const result = SchemaValidator.instance.validateRegistry({ version: 1, components: {} });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when version is missing', () => {
|
||||
const result = SchemaValidator.instance.validateRegistry({ components: {} });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when components is missing', () => {
|
||||
const result = SchemaValidator.instance.validateRegistry({ version: 1 });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a component entry with invalid type', () => {
|
||||
const result = SchemaValidator.instance.validateRegistry({
|
||||
version: 1,
|
||||
components: {
|
||||
Header: { path: 'Header', type: 'widget' }
|
||||
}
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a component entry missing path', () => {
|
||||
const result = SchemaValidator.instance.validateRegistry({
|
||||
version: 1,
|
||||
components: {
|
||||
Header: { type: 'visual' }
|
||||
}
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts all valid component type values', () => {
|
||||
const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const;
|
||||
for (const type of types) {
|
||||
const result = SchemaValidator.instance.validateRegistry({
|
||||
version: 1,
|
||||
components: { Test: { path: 'Test', type } }
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── routes.schema.json ─────────────────────────────────────────────────────
|
||||
|
||||
describe('validateRoutes', () => {
|
||||
it('accepts a valid routes file', () => {
|
||||
const result = SchemaValidator.instance.validateRoutes(validRoutes);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty routes array', () => {
|
||||
const result = SchemaValidator.instance.validateRoutes({ routes: [] });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts routes with all optional fields', () => {
|
||||
const result = SchemaValidator.instance.validateRoutes({
|
||||
version: 1,
|
||||
routes: [
|
||||
{
|
||||
path: '/home',
|
||||
component: 'pages/HomePage',
|
||||
title: 'Home',
|
||||
exact: true,
|
||||
metadata: { requiresAuth: false }
|
||||
}
|
||||
],
|
||||
notFound: 'pages/NotFoundPage'
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when routes is missing', () => {
|
||||
const result = SchemaValidator.instance.validateRoutes({});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a route missing path', () => {
|
||||
const result = SchemaValidator.instance.validateRoutes({
|
||||
routes: [{ component: 'pages/HomePage' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a route missing component', () => {
|
||||
const result = SchemaValidator.instance.validateRoutes({
|
||||
routes: [{ path: '/home' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── styles.schema.json ─────────────────────────────────────────────────────
|
||||
|
||||
describe('validateStyles', () => {
|
||||
it('accepts a valid styles file', () => {
|
||||
const result = SchemaValidator.instance.validateStyles(validStyles);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty styles file', () => {
|
||||
const result = SchemaValidator.instance.validateStyles({});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts styles with variants', () => {
|
||||
const result = SchemaValidator.instance.validateStyles({
|
||||
variants: [
|
||||
{
|
||||
name: 'primary',
|
||||
typename: 'Button',
|
||||
parameters: { backgroundColor: '#3B82F6', color: '#ffffff' },
|
||||
stateParameters: { hover: { backgroundColor: '#2563EB' } }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a variant missing name', () => {
|
||||
const result = SchemaValidator.instance.validateStyles({
|
||||
variants: [{ typename: 'Button' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a variant missing typename', () => {
|
||||
const result = SchemaValidator.instance.validateStyles({
|
||||
variants: [{ name: 'primary' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── model.schema.json ──────────────────────────────────────────────────────
|
||||
|
||||
describe('validateModel', () => {
|
||||
it('accepts a valid model file', () => {
|
||||
const result = SchemaValidator.instance.validateModel(validModel);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a model with all field types', () => {
|
||||
const result = SchemaValidator.instance.validateModel({
|
||||
name: 'Session',
|
||||
fields: [
|
||||
{ name: 'title', type: 'String' },
|
||||
{ name: 'capacity', type: 'Number' },
|
||||
{ name: 'isPublished', type: 'Boolean' },
|
||||
{ name: 'startTime', type: 'Date' },
|
||||
{ name: 'metadata', type: 'Object' },
|
||||
{ name: 'tags', type: 'Array' },
|
||||
{ name: 'speaker', type: 'Pointer', targetClass: 'User' },
|
||||
{ name: 'attendees', type: 'Relation', targetClass: 'User' },
|
||||
{ name: 'thumbnail', type: 'File' },
|
||||
{ name: 'location', type: 'GeoPoint' }
|
||||
]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a model with indexes', () => {
|
||||
const result = SchemaValidator.instance.validateModel({
|
||||
...validModel,
|
||||
indexes: [
|
||||
{ name: 'email_idx', fields: ['email'], unique: true },
|
||||
{ fields: ['username'] }
|
||||
]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when name is missing', () => {
|
||||
const result = SchemaValidator.instance.validateModel({ fields: [] });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when fields is missing', () => {
|
||||
const result = SchemaValidator.instance.validateModel({ name: 'User' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a field with invalid type', () => {
|
||||
const result = SchemaValidator.instance.validateModel({
|
||||
name: 'User',
|
||||
fields: [{ name: 'username', type: 'Text' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a field missing name', () => {
|
||||
const result = SchemaValidator.instance.validateModel({
|
||||
name: 'User',
|
||||
fields: [{ type: 'String' }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an index with empty fields array', () => {
|
||||
const result = SchemaValidator.instance.validateModel({
|
||||
...validModel,
|
||||
indexes: [{ fields: [] }]
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateSchema convenience function ────────────────────────────────────
|
||||
|
||||
describe('validateSchema (convenience function)', () => {
|
||||
it('delegates to the singleton validator', () => {
|
||||
const result = validateSchema(SCHEMA_IDS.COMPONENT, validComponent);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns errors for invalid data', () => {
|
||||
const result = validateSchema(SCHEMA_IDS.PROJECT, {});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateOrThrow ────────────────────────────────────────────────────────
|
||||
|
||||
describe('validateOrThrow', () => {
|
||||
it('does not throw for valid data', () => {
|
||||
expect(() => {
|
||||
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, validComponent);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws for invalid data', () => {
|
||||
expect(() => {
|
||||
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, { name: 'Header' });
|
||||
}).toThrow('Schema validation failed');
|
||||
});
|
||||
|
||||
it('includes context in the error message', () => {
|
||||
expect(() => {
|
||||
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, {}, 'components/Header/component.json');
|
||||
}).toThrow('[components/Header/component.json]');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatValidationErrors ─────────────────────────────────────────────────
|
||||
|
||||
describe('formatValidationErrors', () => {
|
||||
it('returns "No errors" for empty array', () => {
|
||||
const { formatValidationErrors } = require('../../src/editor/src/schemas/validator');
|
||||
expect(formatValidationErrors([])).toBe('No errors');
|
||||
});
|
||||
|
||||
it('formats errors with path and message', () => {
|
||||
const { formatValidationErrors } = require('../../src/editor/src/schemas/validator');
|
||||
const errors = [
|
||||
{ path: '/name', message: 'must be string', keyword: 'type', params: {} },
|
||||
{ path: '/type', message: 'must be equal to one of the allowed values', keyword: 'enum', params: {} }
|
||||
];
|
||||
const formatted = formatValidationErrors(errors);
|
||||
expect(formatted).toContain('/name');
|
||||
expect(formatted).toContain('must be string');
|
||||
expect(formatted).toContain('/type');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SCHEMA_IDS ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SCHEMA_IDS', () => {
|
||||
it('has all 8 schema IDs defined', () => {
|
||||
expect(Object.keys(SCHEMA_IDS).length).toBe(8);
|
||||
});
|
||||
|
||||
it('all IDs are valid opennoodl.dev URLs', () => {
|
||||
for (const id of Object.values(SCHEMA_IDS)) {
|
||||
expect(id).toMatch(/^https:\/\/opennoodl\.dev\/schemas\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('collects multiple errors when allErrors is true', () => {
|
||||
// Missing name, version, AND nodegxVersion — should get 3 errors
|
||||
const result = SchemaValidator.instance.validateProject({});
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('handles deeply nested valid node metadata', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node_001',
|
||||
type: 'JavaScriptNode',
|
||||
metadata: {
|
||||
merge: { soureCodePorts: ['script'] },
|
||||
prompt: {
|
||||
history: [
|
||||
{ role: 'user', content: 'Create a function that...' },
|
||||
{ role: 'assistant', content: 'Here is the code...' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('handles component references in node type (legacy path format)', () => {
|
||||
const result = SchemaValidator.instance.validateNodes({
|
||||
componentId: 'comp_abc',
|
||||
nodes: [
|
||||
{ id: 'node_001', type: '/#Header' },
|
||||
{ id: 'node_002', type: '/#__cloud__/SendGrid/SendEmail' }
|
||||
]
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('handles port type as object (complex type definition)', () => {
|
||||
const result = SchemaValidator.instance.validateComponent({
|
||||
...validComponent,
|
||||
ports: {
|
||||
inputs: [
|
||||
{
|
||||
name: 'items',
|
||||
type: { name: 'stringlist', allowConnectionsOnly: true },
|
||||
displayName: 'Items'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user