421 lines
11 KiB
TypeScript
421 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' })
|
|
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();
|