import { describe, it, expect, beforeEach } from 'vitest'; import { ToolRegistry, defineTool } from './registry.js'; import { generateOpenAPIPaths } from './openapi.js'; import type { OASPathsResult } from './openapi.js'; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const lookupWorkOrder = defineTool({ name: 'lookup-work-order', description: 'Look up a work order by ID.', tier: 'ops', requiredScope: 'ops:read', inputSchema: { type: 'object', required: ['workOrderId'], properties: { workOrderId: { type: 'string', description: 'The work order ID.' }, }, additionalProperties: false, }, handler: async (_input, _ctx) => ({ id: 'WO-001', status: 'open' }), }); const searchVendors = defineTool({ name: 'search-vendors', description: 'Search vendors in QuickBooks Online.', tier: 'finance', requiredScope: 'finance:read', inputSchema: { type: 'object', required: ['query'], properties: { query: { type: 'string', description: 'Vendor name or keyword.' }, limit: { type: 'integer', default: 10 }, }, additionalProperties: false, }, handler: async (_input, _ctx) => ({ vendors: [] }), }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function buildRegistry(...tools: ReturnType[]): ToolRegistry { const reg = new ToolRegistry(); for (const t of tools) reg.register(t); return reg; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('generateOpenAPIPaths', () => { let result: OASPathsResult; beforeEach(() => { const registry = buildRegistry(lookupWorkOrder, searchVendors); result = generateOpenAPIPaths(registry); }); // ------------------------------------------------------------------------- // Basic structure // ------------------------------------------------------------------------- it('produces a paths object', () => { expect(result).toHaveProperty('paths'); expect(typeof result.paths).toBe('object'); }); it('produces one path per registered tool', () => { expect(Object.keys(result.paths)).toHaveLength(2); }); it('generates the correct path key for each tool', () => { expect(result.paths).toHaveProperty('/tools/lookup-work-order'); expect(result.paths).toHaveProperty('/tools/search-vendors'); }); // ------------------------------------------------------------------------- // POST operation structure // ------------------------------------------------------------------------- it('wraps each tool in a POST operation', () => { const pathItem = result.paths['/tools/lookup-work-order']!; expect(pathItem).toHaveProperty('post'); expect(pathItem).not.toHaveProperty('get'); }); it('sets operationId to the tool name', () => { expect(result.paths['/tools/lookup-work-order']!.post.operationId).toBe('lookup-work-order'); }); it('sets summary to the tool description', () => { expect(result.paths['/tools/lookup-work-order']!.post.summary).toBe( 'Look up a work order by ID.', ); }); // ------------------------------------------------------------------------- // Tags and security // ------------------------------------------------------------------------- it('tags an ops tool with ["ops"]', () => { expect(result.paths['/tools/lookup-work-order']!.post.tags).toEqual(['ops']); }); it('tags a finance tool with ["finance"]', () => { expect(result.paths['/tools/search-vendors']!.post.tags).toEqual(['finance']); }); it('adds bearerAuth security requirement to every operation', () => { const op = result.paths['/tools/lookup-work-order']!.post; expect(op.security).toEqual([{ bearerAuth: [] }]); }); it('sets x-required-scope extension from the tool definition', () => { expect(result.paths['/tools/lookup-work-order']!.post['x-required-scope']).toBe('ops:read'); expect(result.paths['/tools/search-vendors']!.post['x-required-scope']).toBe('finance:read'); }); // ------------------------------------------------------------------------- // Request body // ------------------------------------------------------------------------- it('marks requestBody as required', () => { const op = result.paths['/tools/lookup-work-order']!.post; expect(op.requestBody.required).toBe(true); }); it('uses application/json for the requestBody media type', () => { const op = result.paths['/tools/lookup-work-order']!.post; expect(op.requestBody.content).toHaveProperty('application/json'); }); it('round-trips the tool inputSchema verbatim into the requestBody', () => { const op = result.paths['/tools/lookup-work-order']!.post; expect(op.requestBody.content['application/json'].schema).toEqual(lookupWorkOrder.inputSchema); }); it('round-trips the finance tool inputSchema verbatim', () => { const op = result.paths['/tools/search-vendors']!.post; expect(op.requestBody.content['application/json'].schema).toEqual(searchVendors.inputSchema); }); // ------------------------------------------------------------------------- // Responses // ------------------------------------------------------------------------- it('includes a 200 response', () => { const responses = result.paths['/tools/lookup-work-order']!.post.responses; expect(responses).toHaveProperty('200'); }); it('200 response has application/json content', () => { const r200 = result.paths['/tools/lookup-work-order']!.post.responses['200']!; expect(r200.content).toHaveProperty('application/json'); }); it('includes a 403 response for scope errors', () => { const responses = result.paths['/tools/lookup-work-order']!.post.responses; expect(responses).toHaveProperty('403'); }); it('403 response schema has requiredScope property', () => { const r403 = result.paths['/tools/lookup-work-order']!.post.responses['403']!; const schema = r403.content!['application/json'].schema as { properties: Record; }; expect(schema.properties).toHaveProperty('requiredScope'); }); it('includes a 401 response for missing/invalid token', () => { const responses = result.paths['/tools/lookup-work-order']!.post.responses; expect(responses).toHaveProperty('401'); }); // ------------------------------------------------------------------------- // Components / security schemes // ------------------------------------------------------------------------- it('includes a bearerAuth security scheme in components', () => { expect(result.components.securitySchemes).toHaveProperty('bearerAuth'); expect(result.components.securitySchemes.bearerAuth.type).toBe('http'); expect(result.components.securitySchemes.bearerAuth.scheme).toBe('bearer'); expect(result.components.securitySchemes.bearerAuth.bearerFormat).toBe('JWT'); }); // ------------------------------------------------------------------------- // Empty registry // ------------------------------------------------------------------------- it('produces an empty paths object for an empty registry', () => { const empty = generateOpenAPIPaths(new ToolRegistry()); expect(Object.keys(empty.paths)).toHaveLength(0); }); // ------------------------------------------------------------------------- // Round-trip: registry → paths → verify all tools represented // ------------------------------------------------------------------------- it('round-trip: every registered tool appears exactly once in paths', () => { const tools = [ defineTool({ name: 'create-task', description: 'Create a task.', tier: 'ops', requiredScope: 'ops:tasks', inputSchema: { type: 'object', required: ['title'], properties: { title: { type: 'string' } }, }, handler: async () => ({ id: 'T-1' }), }), defineTool({ name: 'lookup-payment-by-vendor', description: 'Look up payments by vendor.', tier: 'finance', requiredScope: 'finance:read', inputSchema: { type: 'object', required: ['vendorName'], properties: { vendorName: { type: 'string' } }, }, handler: async () => ({ payments: [] }), }), ]; const reg = buildRegistry(...tools); const out = generateOpenAPIPaths(reg); const paths = Object.keys(out.paths); expect(paths).toContain('/tools/create-task'); expect(paths).toContain('/tools/lookup-payment-by-vendor'); expect(paths).toHaveLength(2); }); }); // --------------------------------------------------------------------------- // ToolRegistry — defineTool and registry unit tests // --------------------------------------------------------------------------- describe('ToolRegistry', () => { it('registers and lists a tool', () => { const reg = new ToolRegistry(); reg.register(lookupWorkOrder); expect(reg.list()).toHaveLength(1); expect(reg.list()[0]!.name).toBe('lookup-work-order'); }); it('get() returns a registered tool by name', () => { const reg = new ToolRegistry(); reg.register(lookupWorkOrder); const found = reg.get('lookup-work-order'); expect(found).toBeDefined(); expect(found!.name).toBe('lookup-work-order'); }); it('get() returns undefined for unknown tool', () => { const reg = new ToolRegistry(); expect(reg.get('nonexistent')).toBeUndefined(); }); it('throws on duplicate tool name', () => { const reg = new ToolRegistry(); reg.register(lookupWorkOrder); expect(() => reg.register(lookupWorkOrder)).toThrow(/duplicate tool name/); }); it('register() is chainable', () => { const reg = new ToolRegistry(); reg.register(lookupWorkOrder).register(searchVendors); expect(reg.size).toBe(2); }); it('size reflects the number of registered tools', () => { const reg = new ToolRegistry(); expect(reg.size).toBe(0); reg.register(lookupWorkOrder); expect(reg.size).toBe(1); }); }); describe('defineTool', () => { it('returns the definition unchanged', () => { const def = defineTool(lookupWorkOrder); expect(def).toBe(lookupWorkOrder); }); });