import fs from 'fs'; import path from 'path'; import type { JSONSchema } from '@squiz/json-schema-library'; import Draft07Schema from '../../v1/Draft-07.json'; import { JSONSchemaService, SchemaValidationError, TypeResolverBuilder } from '../../..'; /** Must align with `integration-fixtures/valid-full.manifest.json` `$schema`. */ const FULL_MANIFEST_FIXTURE_SCHEMA = 'https://unpkg.com/@squiz/dx-json-schema-lib@latest/lib/manifest/userApiManifest/UserApiManifestV1.json'; /** Directory containing `UserApiManifestV1.json` (this spec sits alongside those files). */ const USER_API_SCHEMA_DIR = __dirname; /** `src/manifest/userApi/v1` → package root (four levels up). */ const PACKAGE_ROOT = path.join(USER_API_SCHEMA_DIR, '..', '..', '..', '..'); const draft07Remotes = { 'http://json-schema.org/draft-07/schema': Draft07Schema, 'http://json-schema.org/draft-07/schema#': Draft07Schema, } as const; function loadJson(relPathUnderSrcDir: string): unknown { const abs = path.isAbsolute(relPathUnderSrcDir) ? relPathUnderSrcDir : path.join(USER_API_SCHEMA_DIR, relPathUnderSrcDir); return JSON.parse(fs.readFileSync(abs, 'utf8')) as unknown; } function fixture(name: string): unknown { return loadJson(path.join('integration-fixtures', name)); } function fixtureRecord(name: string): Record { return fixture(name) as Record; } /** Standalone resolved manifests: validated only after {@link wrapResolvedFixtureAsFull}. */ const INVALID_WRAPPED_FIXTURES = [ 'invalid-nested-manifest-name.manifest.json', 'invalid-nested-manifest-handlerMap.manifest.json', 'invalid-endpoint-extra-public.manifest.json', 'invalid-endpoint-handler-leading-digit.manifest.json', 'invalid-endpoint-handler-hyphen.manifest.json', 'invalid-endpoint-method-invalid.manifest.json', 'invalid-endpoint-missing-method.manifest.json', 'invalid-endpoint-missing-path.manifest.json', 'invalid-resolved-empty-endpoints.manifest.json', 'invalid-resolved-no-leading-slash.manifest.json', ] as const; /** Full top-level documents consumed directly by {@link JSONSchemaService.validateInput}. */ const INVALID_FULL_MANIFEST_FIXTURES = [ 'invalid-full-root-extra-property.manifest.json', 'invalid-full-name-empty.manifest.json', 'invalid-full-displayName-empty.manifest.json', 'invalid-full-missing-schema.manifest.json', 'invalid-full-missing-manifest.manifest.json', 'invalid-full-manifest-not-object.manifest.json', ] as const; type FixtureEndpoint = { path: string; method: string; handler: string; }; /** Non-schema regression check: validators must not omit or reorder fixture endpoint fields. */ function expectFixtureEndpointsUnchanged(validated: unknown) { const data = validated as { manifest?: { endpoints: FixtureEndpoint[] }; endpoints?: FixtureEndpoint[] }; const endpoints = data.manifest?.endpoints ?? data.endpoints; expect(endpoints).toHaveLength(2); expect(endpoints!).toContainEqual({ path: '/integration/hello', method: 'GET', handler: 'index.helloWorld', }); expect(endpoints!).toContainEqual({ path: '/integration/items', method: 'POST', handler: 'handlers.createItem', }); } /** Wrap standalone resolved fixture JSON under a minimal UserApiManifestV1 root (only full manifest is validated publicly). */ function wrapResolvedFixtureAsFull(resolved: Record) { return { $schema: FULL_MANIFEST_FIXTURE_SCHEMA, name: typeof resolved.name === 'string' ? resolved.name : 'wrapped-api', displayName: 'Integration fixture envelope', description: '', filesDir: 'src', entry: 'index.ts', manifest: resolved, }; } function createFullManifestValidator(schemaFromDisk: JSONSchema) { const resolver = TypeResolverBuilder.new().build(); return new JSONSchemaService(resolver, { root: schemaFromDisk, remotes: draft07Remotes, }); } describe('user-api JSON schemas (integration, filesystem-backed)', () => { describe('published schema documents on disk', () => { it('parses UserApiManifestV1.json and exposes nested resolved manifest definition', () => { const schema = loadJson('UserApiManifestV1.json') as Record; expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#'); expect(schema.title).toBe('UserApiManifestV1'); expect(schema.additionalProperties).toBe(false); expect(schema.required).toEqual( expect.arrayContaining(['$schema', 'name', 'displayName', 'filesDir', 'entry', 'manifest']), ); const { resolvedApiManifest } = schema.definitions as { resolvedApiManifest: { properties: Record; required: string[]; }; }; expect(resolvedApiManifest.required).toEqual(expect.arrayContaining(['endpoints'])); const rootProps = schema.properties as { manifest: { $ref?: string } }; expect(rootProps.manifest.$ref).toBe('#/definitions/resolvedApiManifest'); expect(resolvedApiManifest.properties).toHaveProperty('endpoints'); expect(Object.keys(resolvedApiManifest.properties ?? {})).toEqual(['endpoints']); expect(resolvedApiManifest.required).toEqual(['endpoints']); }); it('UserApiManifestV1 endpoint items require path, method, handler only and forbid extra endpoint keys', () => { const schema = loadJson('UserApiManifestV1.json') as { definitions: { resolvedApiManifest: { properties: { endpoints: { items: { required: string[]; additionalProperties?: boolean; properties?: Record; }; }; }; }; }; }; const items = schema.definitions.resolvedApiManifest.properties.endpoints.items; expect(items.required).toEqual(['path', 'method', 'handler']); expect(items.additionalProperties).toBe(false); expect(items.properties).toMatchObject({ path: expect.objectContaining({ type: 'string' }), method: expect.objectContaining({ type: 'string' }), handler: expect.objectContaining({ type: 'string' }), description: expect.objectContaining({ type: 'string' }), }); expect(Object.keys(items.properties ?? {})).toHaveLength(4); }); it('UserApiManifestV1 nested resolved manifest forbids arbitrary root keys (endpoints only)', () => { const schema = loadJson('UserApiManifestV1.json') as { definitions: { resolvedApiManifest: { additionalProperties?: boolean } }; }; expect(schema.definitions.resolvedApiManifest.additionalProperties).toBe(false); }); }); describe('validation pipeline using schemas read from filesystem (not TS imports)', () => { const fullManifestSchemaRoot = loadJson('UserApiManifestV1.json') as JSONSchema; const fullValidator = createFullManifestValidator(fullManifestSchemaRoot); const validResolved = fixtureRecord('valid-resolved.manifest.json'); function expectRejectWhenWrapped(filename: string) { expect(() => fullValidator.validateInput(wrapResolvedFixtureAsFull(fixtureRecord(filename)))).toThrow( SchemaValidationError, ); } function expectRejectFullDocument(blob: unknown) { expect(() => fullValidator.validateInput(blob)).toThrow(SchemaValidationError); } it.each(INVALID_WRAPPED_FIXTURES)('rejects wrapped nested manifest from %s', (filename) => { expectRejectWhenWrapped(filename); }); it.each(INVALID_FULL_MANIFEST_FIXTURES)('rejects full manifest document %s', (filename) => { expectRejectFullDocument(fixture(filename)); }); it('accepts realistic full manifest fixture aligned with nested endpoint schema', () => { const blob = fixture('valid-full.manifest.json'); expect(fullValidator.validateInput(blob)).toBe(true); expectFixtureEndpointsUnchanged(blob); }); it('accepts valid-resolved fixture when wrapped under a minimal full manifest envelope', () => { const wrapped = wrapResolvedFixtureAsFull(validResolved); expect(fullValidator.validateInput(wrapped)).toBe(true); expectFixtureEndpointsUnchanged(wrapped); }); it('valid-resolved fixture is endpoints-only at nested root (no manifest.name)', () => { expect(Array.isArray(validResolved.endpoints)).toBe(true); }); it('accepts api-builder-style paths (wildcard and :param) using disk-backed schema', () => { const wrapped = wrapResolvedFixtureAsFull({ endpoints: [ { path: '/hello/*', method: 'GET', handler: 'hello' }, { path: '/user/:id/:type', method: 'GET', handler: 'user' }, ], }); expect(fullValidator.validateInput(wrapped)).toBe(true); }); it('accepts alternate $schema URL on envelope (filesystem validation path)', () => { const blob = { $schema: 'https://raw.githubusercontent.com/user/api-builder-prototype/main/schemas/api-builder.schema.json', name: 'integration-proto', displayName: 'Proto', filesDir: 'src', entry: 'index.ts', manifest: { endpoints: [{ path: '/health', method: 'GET', handler: 'health' }], }, }; expect(fullValidator.validateInput(blob)).toBe(true); }); it('accepts full manifest when optional description is omitted', () => { expect(fullValidator.validateInput(fixture('valid-full-no-description.manifest.json'))).toBe(true); }); it('accepts one endpoint per allowed HTTP method value', () => { const blob = fixture('valid-full-each-http-method.manifest.json') as { manifest: { endpoints: { method: string }[] }; }; expect(fullValidator.validateInput(blob)).toBe(true); expect(blob.manifest.endpoints.map((e) => e.method).sort()).toEqual( ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'].sort(), ); }); it('accepts minimal path "/" in wrapped resolved fixture alongside another endpoint', () => { const wrapped = wrapResolvedFixtureAsFull(fixtureRecord('valid-resolved-slash-path.manifest.json')); expect(fullValidator.validateInput(wrapped)).toBe(true); const endpoints = (wrapped.manifest as { endpoints: { path: string }[] }).endpoints; expect(endpoints.some((e) => e.path === '/')).toBe(true); }); }); describe('artifacts under lib/ after compile (when present)', () => { const srcFull = path.join(USER_API_SCHEMA_DIR, 'UserApiManifestV1.json'); const libFull = path.join(PACKAGE_ROOT, 'lib', 'manifest', 'userApi', 'v1', 'UserApiManifestV1.json'); const libExists = fs.existsSync(libFull); const itOrSkip = libExists ? it : it.skip; itOrSkip('copied lib UserApiManifestV1.json payload matches src', () => { expect(loadJson(libFull)).toEqual(loadJson(srcFull)); }); }); });