@martel/calyx 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cache/cache.interceptor.ts +32 -0
- package/src/cache/cache.module.ts +31 -0
- package/src/cache/cache.service.ts +86 -0
- package/src/cache/index.ts +3 -0
- package/src/http/application.ts +231 -3
- package/src/http/factory.ts +7 -0
- package/src/index.ts +4 -0
- package/src/lifecycle/context.ts +75 -0
- package/src/lifecycle/interfaces.ts +3 -0
- package/src/microservices/client-proxy.ts +7 -0
- package/src/microservices/client-tcp.ts +127 -0
- package/src/microservices/decorators.ts +19 -0
- package/src/microservices/index.ts +6 -0
- package/src/microservices/interfaces.ts +11 -0
- package/src/microservices/microservice.ts +114 -0
- package/src/microservices/server-tcp.ts +210 -0
- package/src/validation/compiler.ts +124 -0
- package/src/validation/decorators.ts +47 -0
- package/src/validation/index.ts +3 -0
- package/src/validation/pipe.ts +31 -0
- package/src/websockets/decorators.ts +49 -0
- package/src/websockets/gateway.ts +11 -0
- package/src/websockets/index.ts +2 -0
- package/tests/cache.test.ts +93 -0
- package/tests/microservices.test.ts +105 -0
- package/tests/rpc-ws-context.test.ts +135 -0
- package/tests/validation-serialization.test.ts +134 -0
- package/tests/websockets.test.ts +125 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
export interface GatewayMetadata {
|
|
4
|
+
port?: number;
|
|
5
|
+
namespace?: string;
|
|
6
|
+
cors?: any;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function WebSocketGateway(portOrOptions?: number | GatewayMetadata): ClassDecorator {
|
|
10
|
+
return (target) => {
|
|
11
|
+
let metadata: GatewayMetadata = {};
|
|
12
|
+
if (typeof portOrOptions === 'number') {
|
|
13
|
+
metadata.port = portOrOptions;
|
|
14
|
+
} else if (portOrOptions) {
|
|
15
|
+
metadata = portOrOptions;
|
|
16
|
+
}
|
|
17
|
+
Reflect.defineMetadata('calyx:websocket_gateway', metadata, target);
|
|
18
|
+
Reflect.defineMetadata('calyx:injectable', true, target);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SubscribeMessage(event: string): MethodDecorator {
|
|
23
|
+
return (target, propertyKey) => {
|
|
24
|
+
const constructor = target.constructor;
|
|
25
|
+
const existing = Reflect.getOwnMetadata('calyx:subscribe_message', constructor) || [];
|
|
26
|
+
existing.push({ event, propertyKey });
|
|
27
|
+
Reflect.defineMetadata('calyx:subscribe_message', existing, constructor);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function MessageBody(): ParameterDecorator {
|
|
32
|
+
return (target, propertyKey, parameterIndex) => {
|
|
33
|
+
if (!propertyKey) return;
|
|
34
|
+
const constructor = target.constructor;
|
|
35
|
+
const existing = Reflect.getOwnMetadata('calyx:message_body', constructor) || [];
|
|
36
|
+
existing.push({ propertyKey, parameterIndex });
|
|
37
|
+
Reflect.defineMetadata('calyx:message_body', existing, constructor);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ConnectedSocket(): ParameterDecorator {
|
|
42
|
+
return (target, propertyKey, parameterIndex) => {
|
|
43
|
+
if (!propertyKey) return;
|
|
44
|
+
const constructor = target.constructor;
|
|
45
|
+
const existing = Reflect.getOwnMetadata('calyx:connected_socket', constructor) || [];
|
|
46
|
+
existing.push({ propertyKey, parameterIndex });
|
|
47
|
+
Reflect.defineMetadata('calyx:connected_socket', existing, constructor);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
CalyxFactory,
|
|
7
|
+
CacheModule,
|
|
8
|
+
CacheService,
|
|
9
|
+
CacheInterceptor,
|
|
10
|
+
UseInterceptors,
|
|
11
|
+
} from '../src/index.ts';
|
|
12
|
+
|
|
13
|
+
let calculateCalls = 0;
|
|
14
|
+
|
|
15
|
+
@Controller('math')
|
|
16
|
+
class MathController {
|
|
17
|
+
constructor(private readonly cache: CacheService) {}
|
|
18
|
+
|
|
19
|
+
@Get('slow-calc')
|
|
20
|
+
@UseInterceptors(CacheInterceptor)
|
|
21
|
+
slowCalculation() {
|
|
22
|
+
calculateCalls++;
|
|
23
|
+
return { result: 42, count: calculateCalls };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Module({
|
|
28
|
+
imports: [CacheModule.register({ defaultTtl: 1 })], // 1 second TTL
|
|
29
|
+
controllers: [MathController],
|
|
30
|
+
})
|
|
31
|
+
class TestApp {}
|
|
32
|
+
|
|
33
|
+
describe('SQLite Caching (CacheModule, CacheService, CacheInterceptor)', () => {
|
|
34
|
+
let app: any;
|
|
35
|
+
let baseUrl: string;
|
|
36
|
+
const PORT = 3918;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
calculateCalls = 0;
|
|
40
|
+
app = await CalyxFactory.create(TestApp);
|
|
41
|
+
await app.listen(PORT);
|
|
42
|
+
baseUrl = `http://localhost:${PORT}`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await app.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('should set, get, and del values directly using CacheService', async () => {
|
|
50
|
+
const cacheService = app.container.getGlobalOrAnyInstance(CacheService);
|
|
51
|
+
|
|
52
|
+
await cacheService.set('foo', 'bar');
|
|
53
|
+
expect(await cacheService.get('foo')).toBe('bar');
|
|
54
|
+
|
|
55
|
+
await cacheService.del('foo');
|
|
56
|
+
expect(await cacheService.get('foo')).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('should expire cached key after TTL expires', async () => {
|
|
60
|
+
const cacheService = app.container.getGlobalOrAnyInstance(CacheService);
|
|
61
|
+
|
|
62
|
+
await cacheService.set('expire_test', { val: 123 }, 1); // 1 second TTL
|
|
63
|
+
expect(await cacheService.get('expire_test')).toEqual({ val: 123 });
|
|
64
|
+
|
|
65
|
+
// Wait 1.1s
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
67
|
+
expect(await cacheService.get('expire_test')).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('should cache GET route response and short-circuit subsequent requests', async () => {
|
|
71
|
+
// 1. First call -> should execute handler
|
|
72
|
+
const res1 = await fetch(`${baseUrl}/math/slow-calc`);
|
|
73
|
+
expect(res1.status).toBe(200);
|
|
74
|
+
const body1 = await res1.json();
|
|
75
|
+
expect(body1).toEqual({ result: 42, count: 1 });
|
|
76
|
+
expect(calculateCalls).toBe(1);
|
|
77
|
+
|
|
78
|
+
// 2. Second call -> should return cached response (calculateCalls remains 1)
|
|
79
|
+
const res2 = await fetch(`${baseUrl}/math/slow-calc`);
|
|
80
|
+
expect(res2.status).toBe(200);
|
|
81
|
+
const body2 = await res2.json();
|
|
82
|
+
expect(body2).toEqual({ result: 42, count: 1 });
|
|
83
|
+
expect(calculateCalls).toBe(1);
|
|
84
|
+
|
|
85
|
+
// 3. Wait 1.1s for cache to expire, then call again -> should execute handler again
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
87
|
+
const res3 = await fetch(`${baseUrl}/math/slow-calc`);
|
|
88
|
+
expect(res3.status).toBe(200);
|
|
89
|
+
const body3 = await res3.json();
|
|
90
|
+
expect(body3).toEqual({ result: 42, count: 2 });
|
|
91
|
+
expect(calculateCalls).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import { of, Observable } from 'rxjs';
|
|
3
|
+
import {
|
|
4
|
+
Module,
|
|
5
|
+
Controller,
|
|
6
|
+
CalyxFactory,
|
|
7
|
+
MessagePattern,
|
|
8
|
+
EventPattern,
|
|
9
|
+
ClientTcp,
|
|
10
|
+
Transport,
|
|
11
|
+
} from '../src/index.ts';
|
|
12
|
+
|
|
13
|
+
let eventReceived = false;
|
|
14
|
+
let eventPayload: any = null;
|
|
15
|
+
|
|
16
|
+
@Controller()
|
|
17
|
+
class MathController {
|
|
18
|
+
@MessagePattern('sum')
|
|
19
|
+
accumulate(data: number[]): number {
|
|
20
|
+
return (data || []).reduce((a, b) => a + b, 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@MessagePattern('stream')
|
|
24
|
+
streamData(data: string): Observable<string> {
|
|
25
|
+
return of(`echo1: ${data}`, `echo2: ${data}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@MessagePattern('error_test')
|
|
29
|
+
throwError(data: any) {
|
|
30
|
+
throw new Error('This is a simulated RPC error');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@EventPattern('log_event')
|
|
34
|
+
handleLogEvent(data: any) {
|
|
35
|
+
eventReceived = true;
|
|
36
|
+
eventPayload = data;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Module({
|
|
41
|
+
controllers: [MathController],
|
|
42
|
+
})
|
|
43
|
+
class MicroserviceModule {}
|
|
44
|
+
|
|
45
|
+
describe('TCP Microservices (ClientTcp, ServerTcp, MessagePattern, EventPattern)', () => {
|
|
46
|
+
let server: any;
|
|
47
|
+
let client: ClientTcp;
|
|
48
|
+
const PORT = 3899;
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
// 1. Create and start microservice server
|
|
52
|
+
server = await CalyxFactory.createMicroservice(MicroserviceModule, {
|
|
53
|
+
transport: Transport.TCP,
|
|
54
|
+
options: { port: PORT },
|
|
55
|
+
});
|
|
56
|
+
await server.listen();
|
|
57
|
+
|
|
58
|
+
// 2. Initialize client
|
|
59
|
+
client = new ClientTcp({ port: PORT });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
client.close();
|
|
64
|
+
await server.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should exchange request-response message and calculate sum', async () => {
|
|
68
|
+
const res = await client.send<number, number[]>('sum', [1, 2, 3, 4]).toPromise();
|
|
69
|
+
expect(res).toBe(10);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should handle streaming response from RxJS Observable', async () => {
|
|
73
|
+
const results: string[] = [];
|
|
74
|
+
await new Promise<void>((resolve, reject) => {
|
|
75
|
+
client.send<string, string>('stream', 'hello').subscribe({
|
|
76
|
+
next: (val) => results.push(val),
|
|
77
|
+
error: (err) => reject(err),
|
|
78
|
+
complete: () => resolve(),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
expect(results).toEqual(['echo1: hello', 'echo2: hello']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('should propagate error message from RPC back to client', async () => {
|
|
85
|
+
try {
|
|
86
|
+
await client.send('error_test', {}).toPromise();
|
|
87
|
+
expect().fail('Expected send to throw error');
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
expect(err.message).toBe('This is a simulated RPC error');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('should dispatch event pattern messages asynchronously', async () => {
|
|
94
|
+
eventReceived = false;
|
|
95
|
+
eventPayload = null;
|
|
96
|
+
|
|
97
|
+
await client.emit('log_event', { status: 'ok', code: 200 }).toPromise();
|
|
98
|
+
|
|
99
|
+
// Wait a tick for TCP event handling
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
101
|
+
|
|
102
|
+
expect(eventReceived).toBe(true);
|
|
103
|
+
expect(eventPayload).toEqual({ status: 'ok', code: 200 });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import { of, Observable } from 'rxjs';
|
|
3
|
+
import {
|
|
4
|
+
Module,
|
|
5
|
+
CalyxFactory,
|
|
6
|
+
WebSocketGateway,
|
|
7
|
+
SubscribeMessage,
|
|
8
|
+
MessageBody,
|
|
9
|
+
UseGuards,
|
|
10
|
+
UseInterceptors,
|
|
11
|
+
CanActivate,
|
|
12
|
+
ExecutionContext,
|
|
13
|
+
NestInterceptor,
|
|
14
|
+
CallHandler,
|
|
15
|
+
ClientTcp,
|
|
16
|
+
Transport,
|
|
17
|
+
MessagePattern,
|
|
18
|
+
Controller,
|
|
19
|
+
} from '../src/index.ts';
|
|
20
|
+
|
|
21
|
+
class SecurityGuard implements CanActivate {
|
|
22
|
+
canActivate(context: ExecutionContext): boolean {
|
|
23
|
+
const type = context.getType();
|
|
24
|
+
if (type === 'ws') {
|
|
25
|
+
const data = context.switchToWs().getData();
|
|
26
|
+
return data.user === 'admin';
|
|
27
|
+
}
|
|
28
|
+
if (type === 'rpc') {
|
|
29
|
+
const data = context.switchToRpc().getData();
|
|
30
|
+
return data.role === 'admin';
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class LoggingInterceptor implements NestInterceptor {
|
|
37
|
+
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
|
|
38
|
+
const type = context.getType();
|
|
39
|
+
const result = await next.handle();
|
|
40
|
+
return { type, original: result };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@WebSocketGateway(3915)
|
|
45
|
+
class SecureGateway {
|
|
46
|
+
@SubscribeMessage('ping')
|
|
47
|
+
@UseGuards(SecurityGuard)
|
|
48
|
+
@UseInterceptors(LoggingInterceptor)
|
|
49
|
+
handlePing(@MessageBody() data: any) {
|
|
50
|
+
return 'pong';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Controller()
|
|
55
|
+
class SecureController {
|
|
56
|
+
@MessagePattern('ping_rpc')
|
|
57
|
+
@UseGuards(SecurityGuard)
|
|
58
|
+
@UseInterceptors(LoggingInterceptor)
|
|
59
|
+
handlePingRpc(data: any) {
|
|
60
|
+
return 'pong_rpc';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Module({
|
|
65
|
+
controllers: [SecureController],
|
|
66
|
+
providers: [SecureGateway],
|
|
67
|
+
})
|
|
68
|
+
class TestApp {}
|
|
69
|
+
|
|
70
|
+
describe('Guards and Interceptors for WS and RPC contexts', () => {
|
|
71
|
+
let wsApp: any;
|
|
72
|
+
let rpcApp: any;
|
|
73
|
+
let client: ClientTcp;
|
|
74
|
+
const PORT = 3889;
|
|
75
|
+
const RPC_PORT = 3916;
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
// 1. Start standard app for HTTP/WS
|
|
79
|
+
wsApp = await CalyxFactory.create(TestApp);
|
|
80
|
+
await wsApp.listen(PORT);
|
|
81
|
+
|
|
82
|
+
// 2. Start dedicated microservice for RPC
|
|
83
|
+
rpcApp = await CalyxFactory.createMicroservice(TestApp, {
|
|
84
|
+
transport: Transport.TCP,
|
|
85
|
+
options: { port: RPC_PORT },
|
|
86
|
+
});
|
|
87
|
+
await rpcApp.listen();
|
|
88
|
+
|
|
89
|
+
client = new ClientTcp({ port: RPC_PORT });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(async () => {
|
|
93
|
+
client.close();
|
|
94
|
+
await rpcApp.close();
|
|
95
|
+
await wsApp.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should pass or deny WS events using context switching in guards/interceptors', async () => {
|
|
99
|
+
const ws = new WebSocket('ws://localhost:3915');
|
|
100
|
+
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
ws.onopen = () => resolve();
|
|
103
|
+
ws.onerror = (err) => reject(err);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
ws.send(JSON.stringify({ event: 'ping', data: { user: 'admin' } }));
|
|
107
|
+
const successRes = await new Promise<any>((resolve) => {
|
|
108
|
+
ws.onmessage = (event) => resolve(JSON.parse(event.data));
|
|
109
|
+
});
|
|
110
|
+
expect(successRes).toEqual({ type: 'ws', original: 'pong' });
|
|
111
|
+
|
|
112
|
+
let receivedMessage = false;
|
|
113
|
+
ws.onmessage = () => {
|
|
114
|
+
receivedMessage = true;
|
|
115
|
+
};
|
|
116
|
+
ws.send(JSON.stringify({ event: 'ping', data: { user: 'guest' } }));
|
|
117
|
+
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
119
|
+
expect(receivedMessage).toBe(false);
|
|
120
|
+
|
|
121
|
+
ws.close();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('should pass or deny RPC messages using context switching in guards/interceptors', async () => {
|
|
125
|
+
const successRes = await client.send('ping_rpc', { role: 'admin' }).toPromise();
|
|
126
|
+
expect(successRes).toEqual({ type: 'rpc', original: 'pong_rpc' });
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await client.send('ping_rpc', { role: 'guest' }).toPromise();
|
|
130
|
+
expect().fail('Expected send to throw error');
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
expect(err.message).toBe('Forbidden resource');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
Controller,
|
|
5
|
+
Post,
|
|
6
|
+
Body,
|
|
7
|
+
CalyxFactory,
|
|
8
|
+
IsString,
|
|
9
|
+
IsNumber,
|
|
10
|
+
IsEmail,
|
|
11
|
+
IsOptional,
|
|
12
|
+
Exclude,
|
|
13
|
+
Expose,
|
|
14
|
+
ValidationPipe,
|
|
15
|
+
UsePipes,
|
|
16
|
+
} from '../src/index.ts';
|
|
17
|
+
|
|
18
|
+
// 1. DTO for Request Validation
|
|
19
|
+
class CreateUserDto {
|
|
20
|
+
@IsString()
|
|
21
|
+
name!: string;
|
|
22
|
+
|
|
23
|
+
@IsNumber()
|
|
24
|
+
age!: number;
|
|
25
|
+
|
|
26
|
+
@IsEmail()
|
|
27
|
+
email!: string;
|
|
28
|
+
|
|
29
|
+
@IsOptional()
|
|
30
|
+
@IsString()
|
|
31
|
+
bio?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. DTO for Response Serialization
|
|
35
|
+
class UserResponseDto {
|
|
36
|
+
@Expose()
|
|
37
|
+
id: number;
|
|
38
|
+
|
|
39
|
+
@Expose()
|
|
40
|
+
username: string;
|
|
41
|
+
|
|
42
|
+
@Exclude()
|
|
43
|
+
passwordHash: string;
|
|
44
|
+
|
|
45
|
+
constructor(id: number, username: string, passwordHash: string) {
|
|
46
|
+
this.id = id;
|
|
47
|
+
this.username = username;
|
|
48
|
+
this.passwordHash = passwordHash;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Controller('users')
|
|
53
|
+
class UsersController {
|
|
54
|
+
@Post('validate')
|
|
55
|
+
@UsePipes(ValidationPipe)
|
|
56
|
+
createUser(@Body() dto: CreateUserDto) {
|
|
57
|
+
return { received: true, dto };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Post('serialize')
|
|
61
|
+
getUserResponse() {
|
|
62
|
+
return new UserResponseDto(123, 'alice', 'super_secret_sha256_hash');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Module({
|
|
67
|
+
controllers: [UsersController],
|
|
68
|
+
})
|
|
69
|
+
class TestApp {}
|
|
70
|
+
|
|
71
|
+
describe('JIT Validation and Response Serialization', () => {
|
|
72
|
+
let app: any;
|
|
73
|
+
let baseUrl: string;
|
|
74
|
+
const PORT = 3922;
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
app = await CalyxFactory.create(TestApp);
|
|
78
|
+
await app.listen(PORT);
|
|
79
|
+
baseUrl = `http://localhost:${PORT}`;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await app.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('should pass validation for valid payload', async () => {
|
|
87
|
+
const res = await fetch(`${baseUrl}/users/validate`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'content-type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
name: 'Bob',
|
|
92
|
+
age: 25,
|
|
93
|
+
email: 'bob@example.com',
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
expect(res.status).toBe(201);
|
|
97
|
+
const body = await res.json();
|
|
98
|
+
expect(body.received).toBe(true);
|
|
99
|
+
expect(body.dto).toEqual({
|
|
100
|
+
name: 'Bob',
|
|
101
|
+
age: 25,
|
|
102
|
+
email: 'bob@example.com',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('should fail validation and return 400 with list of errors for invalid payload', async () => {
|
|
107
|
+
const res = await fetch(`${baseUrl}/users/validate`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'content-type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
name: 123, // Should be string
|
|
112
|
+
email: 'invalid-email', // Missing @
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
expect(res.status).toBe(400);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
expect(body.message).toBe('Validation failed');
|
|
118
|
+
expect(body.errors).toContain('name must be a string');
|
|
119
|
+
expect(body.errors).toContain('age should not be empty');
|
|
120
|
+
expect(body.errors).toContain('email must be a valid email');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('should serialize response using JIT serializer and exclude excluded fields', async () => {
|
|
124
|
+
const res = await fetch(`${baseUrl}/users/serialize`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
});
|
|
127
|
+
expect(res.status).toBe(201);
|
|
128
|
+
const body = await res.json();
|
|
129
|
+
// Excluded fields should be missing, exposed should be present
|
|
130
|
+
expect(body.id).toBe(123);
|
|
131
|
+
expect(body.username).toBe('alice');
|
|
132
|
+
expect(body.passwordHash).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
CalyxFactory,
|
|
5
|
+
WebSocketGateway,
|
|
6
|
+
SubscribeMessage,
|
|
7
|
+
MessageBody,
|
|
8
|
+
ConnectedSocket,
|
|
9
|
+
} from '../src/index.ts';
|
|
10
|
+
|
|
11
|
+
let clientConnected = false;
|
|
12
|
+
let clientDisconnected = false;
|
|
13
|
+
let messageReceived: any = null;
|
|
14
|
+
|
|
15
|
+
// 1. Shared Port Gateway
|
|
16
|
+
@WebSocketGateway()
|
|
17
|
+
class SharedEventsGateway {
|
|
18
|
+
handleConnection(client: any) {
|
|
19
|
+
clientConnected = true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
handleDisconnect(client: any) {
|
|
23
|
+
clientDisconnected = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@SubscribeMessage('events')
|
|
27
|
+
onEvents(@MessageBody() data: any, @ConnectedSocket() client: any) {
|
|
28
|
+
messageReceived = data;
|
|
29
|
+
return { event: 'events', data: `echo: ${data}` };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Dedicated Port Gateway
|
|
34
|
+
let dedicatedConnected = false;
|
|
35
|
+
@WebSocketGateway(3912)
|
|
36
|
+
class DedicatedEventsGateway {
|
|
37
|
+
handleConnection(client: any) {
|
|
38
|
+
dedicatedConnected = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@SubscribeMessage('dedicated_event')
|
|
42
|
+
onDedicatedEvent(@MessageBody() data: any) {
|
|
43
|
+
return { event: 'dedicated_response', data: `dedicated echo: ${data}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Module({
|
|
48
|
+
providers: [SharedEventsGateway, DedicatedEventsGateway],
|
|
49
|
+
})
|
|
50
|
+
class TestApp {}
|
|
51
|
+
|
|
52
|
+
describe('WebSocket Gateways (Bun.serve, SubscribeMessage, MessageBody, ConnectedSocket)', () => {
|
|
53
|
+
let app: any;
|
|
54
|
+
const PORT = 3889;
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
app = await CalyxFactory.create(TestApp);
|
|
58
|
+
await app.listen(PORT);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await app.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('should connect and handle message exchange on shared port', async () => {
|
|
66
|
+
clientConnected = false;
|
|
67
|
+
clientDisconnected = false;
|
|
68
|
+
messageReceived = null;
|
|
69
|
+
|
|
70
|
+
const ws = new WebSocket(`ws://localhost:${PORT}`);
|
|
71
|
+
|
|
72
|
+
// Wait for connection to open
|
|
73
|
+
await new Promise<void>((resolve, reject) => {
|
|
74
|
+
ws.onopen = () => resolve();
|
|
75
|
+
ws.onerror = (err) => reject(err);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(clientConnected).toBe(true);
|
|
79
|
+
|
|
80
|
+
// Send a message
|
|
81
|
+
ws.send(JSON.stringify({ event: 'events', data: 'hello calyx' }));
|
|
82
|
+
|
|
83
|
+
// Wait for response message
|
|
84
|
+
const response = await new Promise<any>((resolve) => {
|
|
85
|
+
ws.onmessage = (event) => {
|
|
86
|
+
resolve(JSON.parse(event.data));
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(messageReceived).toBe('hello calyx');
|
|
91
|
+
expect(response).toEqual({ event: 'events', data: 'echo: hello calyx' });
|
|
92
|
+
|
|
93
|
+
// Close connection
|
|
94
|
+
ws.close();
|
|
95
|
+
|
|
96
|
+
// Wait a tick for disconnect event
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
98
|
+
expect(clientDisconnected).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('should connect and handle message exchange on dedicated port', async () => {
|
|
102
|
+
dedicatedConnected = false;
|
|
103
|
+
|
|
104
|
+
const ws = new WebSocket('ws://localhost:3912');
|
|
105
|
+
|
|
106
|
+
await new Promise<void>((resolve, reject) => {
|
|
107
|
+
ws.onopen = () => resolve();
|
|
108
|
+
ws.onerror = (err) => reject(err);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(dedicatedConnected).toBe(true);
|
|
112
|
+
|
|
113
|
+
ws.send(JSON.stringify({ event: 'dedicated_event', data: 'secure channel' }));
|
|
114
|
+
|
|
115
|
+
const response = await new Promise<any>((resolve) => {
|
|
116
|
+
ws.onmessage = (event) => {
|
|
117
|
+
resolve(JSON.parse(event.data));
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(response).toEqual({ event: 'dedicated_response', data: 'dedicated echo: secure channel' });
|
|
122
|
+
|
|
123
|
+
ws.close();
|
|
124
|
+
});
|
|
125
|
+
});
|