import { expect, tap } from '@git.zone/tstest/tapbundle'; import { SmartServe, Route, Get, Post, Delete, ApiTag, ApiOperation, ApiParam, ApiQuery, ApiRequestBody, ApiResponseBody, OpenApiGenerator, ControllerRegistry, type IRequestContext, } from '../ts/index.js'; // Clean up registry before tests tap.test('setup - clear controller registry', async () => { ControllerRegistry.clear(); }); // ============================================================================= // OpenAPI Test Controllers // ============================================================================= const UserSchema = { type: 'object' as const, properties: { id: { type: 'string' as const, format: 'uuid' }, name: { type: 'string' as const, minLength: 1 }, email: { type: 'string' as const, format: 'email' }, }, required: ['id', 'name', 'email'], }; const CreateUserSchema = { type: 'object' as const, properties: { name: { type: 'string' as const, minLength: 1 }, email: { type: 'string' as const, format: 'email' }, }, required: ['name', 'email'], }; @Route('/api/users') @ApiTag('Users') class OpenApiUserController { @Get('/') @ApiOperation({ summary: 'List all users' }) @ApiQuery('limit', { description: 'Maximum number of results', schema: { type: 'integer', default: 10, minimum: 1, maximum: 100 }, }) @ApiQuery('offset', { description: 'Offset for pagination', schema: { type: 'integer', default: 0 }, }) @ApiResponseBody(200, { description: 'List of users', schema: { type: 'array', items: UserSchema }, }) listUsers(ctx: IRequestContext) { // Query params should be coerced to numbers const limit = ctx.query.limit as unknown as number; const offset = ctx.query.offset as unknown as number; return { users: [], pagination: { limit, offset }, }; } @Get('/:id') @ApiOperation({ summary: 'Get user by ID' }) @ApiParam('id', { description: 'User UUID', schema: { type: 'string', format: 'uuid' }, }) @ApiResponseBody(200, { description: 'User found', schema: UserSchema }) @ApiResponseBody(404, { description: 'User not found' }) getUser(ctx: IRequestContext) { return { id: ctx.params.id, name: 'Test User', email: 'test@example.com', }; } @Post('/') @ApiOperation({ summary: 'Create a new user' }) @ApiRequestBody({ description: 'User creation payload', schema: CreateUserSchema, }) @ApiResponseBody(201, { description: 'User created', schema: UserSchema }) @ApiResponseBody(400, { description: 'Validation error' }) createUser(ctx: IRequestContext<{ name: string; email: string }>) { return { id: 'new-uuid', name: ctx.body.name, email: ctx.body.email, }; } @Delete('/:id') @ApiOperation({ summary: 'Delete user', deprecated: true }) @ApiParam('id', { description: 'User UUID' }) @ApiResponseBody(204, { description: 'User deleted' }) deleteUser(ctx: IRequestContext) { return null; } } // ============================================================================= // OpenAPI Spec Generation Tests // ============================================================================= tap.test('OpenAPI spec should be generated from controllers', async () => { // Register controller ControllerRegistry.clear(); const instance = new OpenApiUserController(); ControllerRegistry.registerInstance(instance); // Generate spec const generator = new OpenApiGenerator({ info: { title: 'Test API', version: '1.0.0', description: 'A test API', }, servers: [{ url: 'http://localhost:3000' }], }); const spec = generator.generate(); // Check basic structure expect(spec.openapi).toEqual('3.1.0'); expect(spec.info.title).toEqual('Test API'); expect(spec.info.version).toEqual('1.0.0'); // Check paths expect(spec.paths['/api/users']).toBeDefined(); expect(spec.paths['/api/users/{id}']).toBeDefined(); // Check GET /api/users operation const listUsersOp = spec.paths['/api/users'].get; expect(listUsersOp).toBeDefined(); expect(listUsersOp!.summary).toEqual('List all users'); expect(listUsersOp!.tags).toInclude('Users'); // Check query parameters const queryParams = listUsersOp!.parameters?.filter((p: any) => p.in === 'query'); expect(queryParams?.length).toEqual(2); const limitParam = queryParams?.find((p: any) => p.name === 'limit'); expect(limitParam).toBeDefined(); expect(limitParam?.schema?.type).toEqual('integer'); expect(limitParam?.schema?.default).toEqual(10); // Check GET /api/users/{id} operation const getUserOp = spec.paths['/api/users/{id}'].get; expect(getUserOp).toBeDefined(); expect(getUserOp!.summary).toEqual('Get user by ID'); // Check path parameters const pathParams = getUserOp!.parameters?.filter((p: any) => p.in === 'path'); expect(pathParams?.length).toEqual(1); expect(pathParams?.[0].name).toEqual('id'); expect(pathParams?.[0].required).toBeTrue(); // Check POST /api/users operation const createUserOp = spec.paths['/api/users'].post; expect(createUserOp).toBeDefined(); expect(createUserOp!.requestBody).toBeDefined(); expect(createUserOp!.requestBody?.content['application/json']).toBeDefined(); // Check responses expect(createUserOp!.responses['201']).toBeDefined(); expect(createUserOp!.responses['400']).toBeDefined(); // Check DELETE operation deprecation const deleteUserOp = spec.paths['/api/users/{id}'].delete; expect(deleteUserOp).toBeDefined(); expect(deleteUserOp!.deprecated).toBeTrue(); }); // ============================================================================= // Server Integration Tests // ============================================================================= tap.test('SmartServe should serve OpenAPI spec endpoint', async () => { ControllerRegistry.clear(); const server = new SmartServe({ port: 3500, openapi: { info: { title: 'OpenAPI Test Server', version: '1.0.0', }, }, }); server.register(OpenApiUserController); await server.start(); try { // Fetch the OpenAPI spec const response = await fetch('http://localhost:3500/openapi.json'); expect(response.status).toEqual(200); expect(response.headers.get('content-type')).toInclude('application/json'); const spec = await response.json(); expect(spec.openapi).toEqual('3.1.0'); expect(spec.info.title).toEqual('OpenAPI Test Server'); expect(spec.paths['/api/users']).toBeDefined(); } finally { await server.stop(); } }); tap.test('SmartServe should serve Swagger UI', async () => { ControllerRegistry.clear(); const server = new SmartServe({ port: 3501, openapi: { info: { title: 'Swagger UI Test', version: '1.0.0', }, }, }); server.register(OpenApiUserController); await server.start(); try { // Fetch the Swagger UI const response = await fetch('http://localhost:3501/docs'); expect(response.status).toEqual(200); expect(response.headers.get('content-type')).toInclude('text/html'); const html = await response.text(); expect(html).toInclude('swagger-ui'); expect(html).toInclude('/openapi.json'); } finally { await server.stop(); } }); // ============================================================================= // Validation Tests // ============================================================================= tap.test('OpenAPI validation should reject invalid request body', async () => { ControllerRegistry.clear(); const server = new SmartServe({ port: 3502, openapi: { info: { title: 'Validation Test', version: '1.0.0', }, }, }); server.register(OpenApiUserController); await server.start(); try { // Send invalid body (missing required fields) const response = await fetch('http://localhost:3502/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Test' }), // Missing email }); expect(response.status).toEqual(400); const error = await response.json(); expect(error.error).toEqual('Validation failed'); expect(error.details).toBeDefined(); } finally { await server.stop(); } }); tap.test('OpenAPI validation should accept valid request body', async () => { ControllerRegistry.clear(); const server = new SmartServe({ port: 3503, openapi: { info: { title: 'Validation Test', version: '1.0.0', }, }, }); server.register(OpenApiUserController); await server.start(); try { // Send valid body const response = await fetch('http://localhost:3503/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'John Doe', email: 'john@example.com', }), }); expect(response.status).toEqual(200); const data = await response.json(); expect(data.name).toEqual('John Doe'); expect(data.email).toEqual('john@example.com'); } finally { await server.stop(); } }); tap.test('OpenAPI should coerce query parameters', async () => { ControllerRegistry.clear(); const server = new SmartServe({ port: 3504, openapi: { info: { title: 'Coercion Test', version: '1.0.0', }, }, }); server.register(OpenApiUserController); await server.start(); try { // Query params should be coerced from strings to numbers const response = await fetch('http://localhost:3504/api/users?limit=25&offset=50'); expect(response.status).toEqual(200); const data = await response.json(); // The handler returns the limit/offset values expect(data.pagination.limit).toEqual(25); expect(data.pagination.offset).toEqual(50); // They should be numbers, not strings expect(typeof data.pagination.limit).toEqual('number'); expect(typeof data.pagination.offset).toEqual('number'); } finally { await server.stop(); } }); tap.test('OpenAPI should apply default values to query parameters', async () => { ControllerRegistry.clear(); const server = new SmartServe({ port: 3505, openapi: { info: { title: 'Default Values Test', version: '1.0.0', }, }, }); server.register(OpenApiUserController); await server.start(); try { // No query params - should get defaults const response = await fetch('http://localhost:3505/api/users'); expect(response.status).toEqual(200); const data = await response.json(); // Default values from schema expect(data.pagination.limit).toEqual(10); expect(data.pagination.offset).toEqual(0); } finally { await server.stop(); } }); tap.test('OpenAPI can be disabled', async () => { ControllerRegistry.clear(); const server = new SmartServe({ port: 3506, openapi: { enabled: false, info: { title: 'Disabled Test', version: '1.0.0', }, }, }); server.register(OpenApiUserController); await server.start(); try { // OpenAPI endpoints should not be registered const response = await fetch('http://localhost:3506/openapi.json'); expect(response.status).toEqual(404); } finally { await server.stop(); } }); // Cleanup tap.test('cleanup - clear controller registry', async () => { ControllerRegistry.clear(); }); export default tap.start();