444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import {
|
|
SmartServe,
|
|
Route,
|
|
Get,
|
|
Post,
|
|
Guard,
|
|
Transform,
|
|
Intercept,
|
|
Compress,
|
|
NoCompress,
|
|
HttpError,
|
|
type IRequestContext,
|
|
} from '../ts/index.js';
|
|
import * as zlib from 'zlib';
|
|
import * as http from 'http';
|
|
import { promisify } from 'util';
|
|
|
|
const gunzip = promisify(zlib.gunzip);
|
|
const brotliDecompress = promisify(zlib.brotliDecompress);
|
|
|
|
/**
|
|
* Make a raw HTTP request without automatic decompression
|
|
* (Node.js fetch auto-decompresses, which breaks our tests)
|
|
*/
|
|
function rawRequest(options: {
|
|
port: number;
|
|
path: string;
|
|
headers?: Record<string, string>;
|
|
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{
|
|
hostname: 'localhost',
|
|
port: options.port,
|
|
path: options.path,
|
|
method: 'GET',
|
|
headers: options.headers,
|
|
},
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
resolve({
|
|
status: res.statusCode ?? 200,
|
|
headers: res.headers,
|
|
body: Buffer.concat(chunks),
|
|
});
|
|
});
|
|
res.on('error', reject);
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Test controller
|
|
@Route('/api')
|
|
class TestController {
|
|
@Get('/hello')
|
|
hello() {
|
|
return { message: 'Hello World' };
|
|
}
|
|
|
|
@Get('/users/:id')
|
|
getUser(ctx: IRequestContext) {
|
|
return { id: ctx.params.id, name: 'Test User' };
|
|
}
|
|
|
|
@Post('/echo')
|
|
async echo(ctx: IRequestContext<{ text: string }>) {
|
|
const body = await ctx.json();
|
|
return { echo: body?.text };
|
|
}
|
|
}
|
|
|
|
// Controller with guards
|
|
const isAuthenticated = (ctx: IRequestContext) => {
|
|
return ctx.headers.has('Authorization');
|
|
};
|
|
|
|
@Route('/protected')
|
|
@Guard(isAuthenticated)
|
|
class ProtectedController {
|
|
@Get('/data')
|
|
getData() {
|
|
return { secret: 'protected data' };
|
|
}
|
|
}
|
|
|
|
// Controller with transforms
|
|
const wrapResponse = <T>(data: T) => ({ success: true, data, timestamp: Date.now() });
|
|
|
|
@Route('/wrapped')
|
|
@Transform(wrapResponse)
|
|
class WrappedController {
|
|
@Get('/info')
|
|
getInfo() {
|
|
return { version: '1.0.0' };
|
|
}
|
|
}
|
|
|
|
// Controller with compression decorators
|
|
@Route('/compression')
|
|
class CompressionController {
|
|
@Get('/large')
|
|
getLargeData() {
|
|
// Return data larger than default threshold (1024 bytes)
|
|
return {
|
|
data: 'x'.repeat(2000),
|
|
items: Array.from({ length: 50 }, (_, i) => ({
|
|
id: i,
|
|
name: `Item ${i}`,
|
|
description: `This is a description for item ${i} with some extra text to make it longer`,
|
|
})),
|
|
};
|
|
}
|
|
|
|
@Get('/small')
|
|
getSmallData() {
|
|
// Return data smaller than threshold
|
|
return { ok: true };
|
|
}
|
|
|
|
@Get('/no-compress')
|
|
@NoCompress()
|
|
getNoCompress() {
|
|
// Should never be compressed
|
|
return {
|
|
data: 'x'.repeat(2000),
|
|
compressed: false,
|
|
};
|
|
}
|
|
|
|
@Get('/force-compress')
|
|
@Compress({ level: 9 })
|
|
getForceCompress() {
|
|
return {
|
|
data: 'y'.repeat(2000),
|
|
compressed: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
tap.test('SmartServe should create server instance', async () => {
|
|
const server = new SmartServe({ port: 3456 });
|
|
expect(server).toBeInstanceOf(SmartServe);
|
|
expect(server.isRunning()).toBeFalse();
|
|
});
|
|
|
|
tap.test('SmartServe should register controllers', async () => {
|
|
const server = new SmartServe({ port: 3457 });
|
|
server.register(TestController);
|
|
server.register(ProtectedController);
|
|
server.register(WrappedController);
|
|
expect(server).toBeInstanceOf(SmartServe);
|
|
});
|
|
|
|
tap.test('SmartServe should start and stop', async () => {
|
|
const server = new SmartServe({ port: 3458 });
|
|
server.register(TestController);
|
|
|
|
const instance = await server.start();
|
|
expect(instance.port).toEqual(3458);
|
|
expect(instance.runtime).toEqual('node');
|
|
expect(server.isRunning()).toBeTrue();
|
|
|
|
await server.stop();
|
|
expect(server.isRunning()).toBeFalse();
|
|
});
|
|
|
|
tap.test('SmartServe should handle GET request', async () => {
|
|
const server = new SmartServe({ port: 3459 });
|
|
server.register(TestController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await fetch('http://localhost:3459/api/hello');
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(data.message).toEqual('Hello World');
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('SmartServe should handle path parameters', async () => {
|
|
const server = new SmartServe({ port: 3460 });
|
|
server.register(TestController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await fetch('http://localhost:3460/api/users/123');
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(data.id).toEqual('123');
|
|
expect(data.name).toEqual('Test User');
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('SmartServe should handle POST with body', async () => {
|
|
const server = new SmartServe({ port: 3461 });
|
|
server.register(TestController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await fetch('http://localhost:3461/api/echo', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: 'Hello!' }),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(data.echo).toEqual('Hello!');
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('SmartServe should enforce guards', async () => {
|
|
const server = new SmartServe({ port: 3462 });
|
|
server.register(ProtectedController);
|
|
await server.start();
|
|
|
|
try {
|
|
// Without auth header - should be forbidden
|
|
const response1 = await fetch('http://localhost:3462/protected/data');
|
|
expect(response1.status).toEqual(403);
|
|
|
|
// With auth header - should succeed
|
|
const response2 = await fetch('http://localhost:3462/protected/data', {
|
|
headers: { Authorization: 'Bearer token123' },
|
|
});
|
|
const data = await response2.json();
|
|
|
|
expect(response2.status).toEqual(200);
|
|
expect(data.secret).toEqual('protected data');
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('SmartServe should apply transforms', async () => {
|
|
const server = new SmartServe({ port: 3463 });
|
|
server.register(WrappedController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await fetch('http://localhost:3463/wrapped/info');
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(data.success).toBeTrue();
|
|
expect(data.data.version).toEqual('1.0.0');
|
|
expect(data.timestamp).toBeTypeofNumber();
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('SmartServe should return 404 for unknown routes', async () => {
|
|
const server = new SmartServe({ port: 3464 });
|
|
server.register(TestController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await fetch('http://localhost:3464/unknown/route');
|
|
expect(response.status).toEqual(404);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('HttpError should create proper responses', async () => {
|
|
const error = HttpError.notFound('Resource not found', { id: '123' });
|
|
|
|
expect(error.status).toEqual(404);
|
|
expect(error.message).toEqual('Resource not found');
|
|
expect(error.details).toEqual({ id: '123' });
|
|
|
|
const response = error.toResponse();
|
|
expect(response.status).toEqual(404);
|
|
|
|
const body = await response.json();
|
|
expect(body.error).toEqual('Resource not found');
|
|
expect(body.details.id).toEqual('123');
|
|
});
|
|
|
|
// ============================================================================
|
|
// Compression Tests
|
|
// ============================================================================
|
|
|
|
tap.test('Compression should apply gzip when Accept-Encoding is sent', async () => {
|
|
const server = new SmartServe({ port: 3470 });
|
|
server.register(CompressionController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await rawRequest({
|
|
port: 3470,
|
|
path: '/compression/large',
|
|
headers: { 'Accept-Encoding': 'gzip' },
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['content-encoding']).toEqual('gzip');
|
|
expect(response.headers['vary']).toInclude('Accept-Encoding');
|
|
|
|
// Decompress and verify content
|
|
const decompressed = await gunzip(response.body);
|
|
const data = JSON.parse(decompressed.toString());
|
|
|
|
expect(data.data).toStartWith('xxx');
|
|
expect(data.items.length).toEqual(50);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('Compression should apply brotli when preferred', async () => {
|
|
const server = new SmartServe({ port: 3471 });
|
|
server.register(CompressionController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await rawRequest({
|
|
port: 3471,
|
|
path: '/compression/large',
|
|
headers: { 'Accept-Encoding': 'br, gzip' },
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['content-encoding']).toEqual('br');
|
|
|
|
// Decompress and verify content
|
|
const decompressed = await brotliDecompress(response.body);
|
|
const data = JSON.parse(decompressed.toString());
|
|
|
|
expect(data.data).toStartWith('xxx');
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('Compression should skip small responses', async () => {
|
|
const server = new SmartServe({ port: 3472 });
|
|
server.register(CompressionController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await rawRequest({
|
|
port: 3472,
|
|
path: '/compression/small',
|
|
headers: { 'Accept-Encoding': 'gzip, br' },
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
// Small response should NOT be compressed
|
|
expect(response.headers['content-encoding']).toBeUndefined();
|
|
|
|
const data = JSON.parse(response.body.toString());
|
|
expect(data.ok).toBeTrue();
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('Compression should respect @NoCompress decorator', async () => {
|
|
const server = new SmartServe({ port: 3473 });
|
|
server.register(CompressionController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await rawRequest({
|
|
port: 3473,
|
|
path: '/compression/no-compress',
|
|
headers: { 'Accept-Encoding': 'gzip, br' },
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
// Should NOT be compressed due to @NoCompress
|
|
expect(response.headers['content-encoding']).toBeUndefined();
|
|
|
|
const data = JSON.parse(response.body.toString());
|
|
expect(data.compressed).toBeFalse();
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('Compression should skip when Accept-Encoding not sent', async () => {
|
|
const server = new SmartServe({ port: 3474 });
|
|
server.register(CompressionController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await rawRequest({
|
|
port: 3474,
|
|
path: '/compression/large',
|
|
// No Accept-Encoding header
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
// Should NOT be compressed when client doesn't accept it
|
|
expect(response.headers['content-encoding']).toBeUndefined();
|
|
|
|
const data = JSON.parse(response.body.toString());
|
|
expect(data.data).toStartWith('xxx');
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
tap.test('Compression can be disabled globally', async () => {
|
|
const server = new SmartServe({
|
|
port: 3475,
|
|
compression: false,
|
|
});
|
|
server.register(CompressionController);
|
|
await server.start();
|
|
|
|
try {
|
|
const response = await rawRequest({
|
|
port: 3475,
|
|
path: '/compression/large',
|
|
headers: { 'Accept-Encoding': 'gzip, br' },
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
// Should NOT be compressed when globally disabled
|
|
expect(response.headers['content-encoding']).toBeUndefined();
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
export default tap.start();
|