Files
smartserve/test/test.openapi.ts

422 lines
11 KiB
TypeScript

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' })
async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return {
id: 'new-uuid',
name: body.name,
email: 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();