import { USER_API_FULL_MANIFEST_SCHEMA, USER_API_MANIFEST_SCHEMA_URL, validateUserApiFullManifest, } from './userApiManifestValidation'; import { SchemaValidationError } from '../../../errors/SchemaValidationError'; describe('user API manifest validation', () => { const validResolved = { endpoints: [{ path: '/hello', method: 'GET', handler: 'index.hello' }], }; const validFull = { $schema: USER_API_MANIFEST_SCHEMA_URL, name: 'api', displayName: 'API', description: '', filesDir: 'src', entry: 'index.ts', manifest: validResolved, }; /** Resolves `#/definitions/resolvedApiManifest` via the full-manifest root schema only. */ const wrapResolved = (manifestNested: Record) => ({ ...validFull, manifest: manifestNested, }); const endpoint = ( overrides: Partial<{ path: string; method: string; handler: string; description: string; }> = {}, ) => ({ path: '/r', method: 'GET', handler: 'handler', ...overrides, }); const expectResolvedNestedThrows = (manifestNested: Record) => { expect(() => validateUserApiFullManifest(wrapResolved(manifestNested))).toThrow(SchemaValidationError); }; const expectFullThrows = (input: unknown) => { expect(() => validateUserApiFullManifest(input)).toThrow(SchemaValidationError); }; it('surfaces bundled full manifest schema for CDN / tooling', () => { expect(USER_API_FULL_MANIFEST_SCHEMA.type).toBe('object'); expect(typeof USER_API_MANIFEST_SCHEMA_URL).toBe('string'); expect(USER_API_MANIFEST_SCHEMA_URL).toContain('dx-json-schema-lib'); expect(USER_API_MANIFEST_SCHEMA_URL).toContain('manifest/userApiManifest'); }); describe('nested resolvedApiManifest rules (validated only through validateUserApiFullManifest)', () => { it('accepts canonical resolved manifest when nested under manifest', () => { expect(validateUserApiFullManifest(wrapResolved(validResolved))).toBe(true); }); it.each(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const)( 'accepts HTTP method %s', (method) => { expect( validateUserApiFullManifest( wrapResolved({ endpoints: [endpoint({ path: `/m-${method}`, method })], }), ), ).toBe(true); }, ); it('accepts multiple endpoints without extra endpoint fields', () => { expect( validateUserApiFullManifest( wrapResolved({ endpoints: [ { path: '/a', method: 'GET', handler: 'a' }, { path: '/b', method: 'POST', handler: 'mod.b' }, ], }), ), ).toBe(true); }); it('rejects handlerMap on nested manifest (endpoints only)', () => { expectResolvedNestedThrows({ endpoints: [endpoint()], handlerMap: { 'index.hello': './handlers/hello.ts' }, }); }); it('accepts handlers using $ and nested module paths', () => { expect( validateUserApiFullManifest( wrapResolved({ endpoints: [ endpoint({ handler: '$default' }), endpoint({ handler: 'ns.deep_export' }), endpoint({ handler: '_private' }), ], }), ), ).toBe(true); }); it('accepts path with only slash', () => { expect(validateUserApiFullManifest(wrapResolved({ endpoints: [endpoint({ path: '/' })] }))).toBe(true); }); it('accepts api-builder prototype document (alternate $schema, endpoints-only nested manifest)', () => { expect( validateUserApiFullManifest({ $schema: 'https://raw.githubusercontent.com/user/api-builder-prototype/main/schemas/api-builder.schema.json', name: 'test-api', displayName: 'Test Api', description: 'An API to test the proxy endpoint', entry: 'index.ts', filesDir: 'src', manifest: { endpoints: [ { path: '/hello/*', method: 'GET', handler: 'hello' }, { path: '/add', method: 'GET', handler: 'add' }, { path: '/user/:id/:type', method: 'GET', handler: 'user' }, { path: '/proxy', method: 'GET', handler: 'proxy' }, { path: '/health', method: 'GET', handler: 'health' }, ], }, }), ).toBe(true); }); it('rejects empty endpoints array', () => { expectResolvedNestedThrows({ endpoints: [] }); expectFullThrows({ ...validFull, manifest: { endpoints: [] }, }); }); it('accepts nested manifest with only endpoints (no nested name)', () => { expect( validateUserApiFullManifest( wrapResolved({ endpoints: [endpoint()], }), ), ).toBe(true); }); it('rejects nested manifest name property (nested root allows only endpoints)', () => { expectResolvedNestedThrows({ name: 'nested-id', endpoints: [endpoint()] }); expectResolvedNestedThrows({ name: '', endpoints: [endpoint()] }); }); it('rejects missing endpoints on nested manifest', () => { expectResolvedNestedThrows({ name: 'only-name' }); }); it('rejects non-array endpoints on nested manifest', () => { expectResolvedNestedThrows({ endpoints: {} }); }); it('rejects nested manifest root additionalProperties', () => { expectResolvedNestedThrows({ ...validResolved, extraKey: true }); }); it('rejects extra properties on an endpoint object', () => { expectResolvedNestedThrows({ endpoints: [{ ...endpoint(), unknownFlag: true }], }); }); it.each(['path', 'method', 'handler'] as const)('rejects endpoint missing required field %s', (field) => { const ep = endpoint(); const partial = Object.fromEntries(Object.entries(ep).filter(([k]) => k !== field)) as Record; expectResolvedNestedThrows({ endpoints: [partial], }); }); it('accepts minimal endpoint objects (only path/method/handler)', () => { expect( validateUserApiFullManifest( wrapResolved({ endpoints: [{ path: '/x', method: 'GET', handler: 'h' }], }), ), ).toBe(true); }); it('accepts optional endpoint description (string)', () => { expect( validateUserApiFullManifest( wrapResolved({ endpoints: [endpoint({ description: 'Says hello' })], }), ), ).toBe(true); }); it.each([{ description: 1 }, { description: {} }, { description: ['a'] }])( 'rejects non-string endpoint description (%s)', ({ description }) => { expectResolvedNestedThrows({ endpoints: [endpoint({ description } as any)], }); }, ); it('rejects paths without a leading slash', () => { expectResolvedNestedThrows({ endpoints: [endpoint({ path: 'no-leading-slash' })], }); }); it('rejects invalid HTTP methods', () => { expectResolvedNestedThrows({ endpoints: [endpoint({ method: 'TRACE' })], }); expectResolvedNestedThrows({ endpoints: [endpoint({ method: 'get' })], }); }); it.each([ { handler: '', reason: 'empty handler' }, { handler: '9start', reason: 'leading digit' }, { handler: 'bad-handler', reason: 'hyphen' }, { handler: 'a b', reason: 'space' }, { handler: '😀', reason: 'non-ASCII' }, ])('rejects invalid handler ($reason)', ({ handler }) => { expectResolvedNestedThrows({ endpoints: [endpoint({ handler })], }); }); it('rejects invalid nested manifest object shapes', () => { expectFullThrows(wrapResolved({} as Record)); expectFullThrows({ ...validFull, manifest: [] as unknown, }); expectFullThrows({ ...validFull, manifest: 'nested' as unknown, }); }); }); describe('validateUserApiFullManifest (root document)', () => { it('accepts canonical full manifest', () => { expect(validateUserApiFullManifest(validFull)).toBe(true); expect(validateUserApiFullManifest({ ...validFull, name: 'my_api' })).toBe(true); expect(validateUserApiFullManifest({ ...validFull, name: 'my_api_v2' })).toBe(true); }); it('accepts omission of optional description', () => { const { description: _d, ...noDesc } = validFull; expect(validateUserApiFullManifest(noDesc)).toBe(true); }); it('accepts endpoints-only nested manifest when root declares name', () => { expect( validateUserApiFullManifest({ ...validFull, name: 'root-name', manifest: { endpoints: [{ path: '/e', method: 'GET', handler: 'eh' }], }, }), ).toBe(true); }); it('rejects names that do not match the slug pattern', () => { expectFullThrows({ ...validFull, name: '9bad-name' }); expectFullThrows({ ...validFull, name: 'bad name' }); expectFullThrows({ ...validFull, name: 'Test-api-fixed' }); expectFullThrows({ ...validFull, name: 'bad__name' }); expectFullThrows({ ...validFull, name: 'bad___name' }); }); it('rejects missing root $schema', () => { const { $schema: _drop, ...withoutSchema } = validFull; expectFullThrows(withoutSchema); }); it('rejects empty $schema string', () => { expectFullThrows({ ...validFull, $schema: '' }); }); it.each([['name'], ['displayName'], ['filesDir'], ['entry'], ['manifest']] as const)( 'rejects full manifest missing required field %s', (field) => { const { [field]: _removed, ...rest } = validFull as Record; expectFullThrows(rest); }, ); it.each([ ['name', ''], ['displayName', ''], ['filesDir', ''], ['entry', ''], ] as const)('rejects empty string for %s (minLength 1)', (field, value) => { expectFullThrows({ ...validFull, [field]: value, }); }); it('rejects root additionalProperties on full manifest', () => { expectFullThrows({ ...validFull, version: '1' }); }); it('rejects nested manifest validation failures (delegates to resolved definition)', () => { expectFullThrows({ ...validFull, manifest: { endpoints: [endpoint(), endpoint({ handler: '$default', path: '/other' })], badRootKey: true, }, }); expectFullThrows({ ...validFull, manifest: { endpoints: [] }, }); }); it('rejects primitives instead of manifest object root', () => { expectFullThrows(null); expectFullThrows(undefined); expectFullThrows([]); expectFullThrows('manifest'); expectFullThrows({}); }); }); });