@martel/calyx 1.11.0 → 1.12.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 +7 -0
- package/package.json +1 -1
- package/src/cache/cache.interceptor.ts +4 -2
- package/src/cache/decorators.ts +4 -0
- package/src/cache/index.ts +1 -0
- package/src/core/container.ts +242 -9
- package/src/core/index.ts +2 -0
- package/src/core/lazy-module-loader.ts +29 -0
- package/src/core/metadata.ts +6 -1
- package/src/core/testing-module.ts +119 -0
- package/src/cqrs/cqrs.ts +175 -0
- package/src/graphql/decorators.ts +16 -0
- package/src/graphql/graphql.module.ts +16 -0
- package/src/http/application.ts +128 -17
- package/src/http/decorators.ts +4 -0
- package/src/index.ts +2 -0
- package/src/microservices/clients.module.ts +47 -0
- package/src/microservices/index.ts +1 -0
- package/src/microservices/microservice.ts +1 -1
- package/src/schedule/decorators.ts +10 -6
- package/src/schedule/index.ts +1 -0
- package/src/schedule/schedule.module.ts +3 -2
- package/src/schedule/scheduler-registry.ts +50 -0
- package/src/security/index.ts +1 -0
- package/src/security/throttler.module.ts +108 -0
- package/src/terminus/terminus.ts +61 -0
- package/src/validation/http-pipes.ts +128 -0
- package/src/validation/index.ts +1 -0
- package/src/websockets/decorators.ts +12 -2
- package/tests/nestjs-parity.test.ts +272 -0
package/src/security/index.ts
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Module, DynamicModule, Injectable, Inject } from '../core/decorators.ts';
|
|
2
|
+
import { CanActivate, ExecutionContext } from '../lifecycle/interfaces.ts';
|
|
3
|
+
import { HttpException } from '../http/exceptions.ts';
|
|
4
|
+
|
|
5
|
+
export const THROTTLE_LIMIT_KEY = 'throttler:limit';
|
|
6
|
+
export const THROTTLE_SKIP_KEY = 'throttler:skip';
|
|
7
|
+
|
|
8
|
+
export const Throttle = (limit: number, ttl: number) => (target: any, key?: string | symbol, descriptor?: any) => {
|
|
9
|
+
if (descriptor) {
|
|
10
|
+
Reflect.defineMetadata(THROTTLE_LIMIT_KEY, { limit, ttl }, descriptor.value);
|
|
11
|
+
return descriptor;
|
|
12
|
+
}
|
|
13
|
+
Reflect.defineMetadata(THROTTLE_LIMIT_KEY, { limit, ttl }, target);
|
|
14
|
+
return target;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const SkipThrottle = (skip = true) => (target: any, key?: string | symbol, descriptor?: any) => {
|
|
18
|
+
if (descriptor) {
|
|
19
|
+
Reflect.defineMetadata(THROTTLE_SKIP_KEY, skip, descriptor.value);
|
|
20
|
+
return descriptor;
|
|
21
|
+
}
|
|
22
|
+
Reflect.defineMetadata(THROTTLE_SKIP_KEY, skip, target);
|
|
23
|
+
return target;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface ThrottlerModuleOptions {
|
|
27
|
+
limit?: number;
|
|
28
|
+
ttl?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Injectable()
|
|
32
|
+
export class ThrottlerStorage {
|
|
33
|
+
private records = new Map<string, { count: number; expiresAt: number }>();
|
|
34
|
+
|
|
35
|
+
increment(key: string, ttlSeconds: number): { count: number; expiresAt: number } {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const record = this.records.get(key);
|
|
38
|
+
if (!record || record.expiresAt < now) {
|
|
39
|
+
const newRecord = { count: 1, expiresAt: now + ttlSeconds * 1000 };
|
|
40
|
+
this.records.set(key, newRecord);
|
|
41
|
+
return newRecord;
|
|
42
|
+
}
|
|
43
|
+
record.count++;
|
|
44
|
+
return record;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Injectable()
|
|
49
|
+
export class ThrottlerGuard implements CanActivate {
|
|
50
|
+
constructor(
|
|
51
|
+
@Inject('THROTTLER_OPTIONS')
|
|
52
|
+
private readonly options: ThrottlerModuleOptions,
|
|
53
|
+
private readonly storage: ThrottlerStorage
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
57
|
+
const handler = context.getHandler();
|
|
58
|
+
const controller = context.getClass();
|
|
59
|
+
|
|
60
|
+
const skipHandler = Reflect.getMetadata(THROTTLE_SKIP_KEY, handler);
|
|
61
|
+
if (skipHandler === true) return true;
|
|
62
|
+
const skipController = Reflect.getMetadata(THROTTLE_SKIP_KEY, controller);
|
|
63
|
+
if (skipController === true) return true;
|
|
64
|
+
|
|
65
|
+
const limitMeta = Reflect.getMetadata(THROTTLE_LIMIT_KEY, handler) ||
|
|
66
|
+
Reflect.getMetadata(THROTTLE_LIMIT_KEY, controller) ||
|
|
67
|
+
this.options;
|
|
68
|
+
|
|
69
|
+
const limit = limitMeta.limit ?? 10;
|
|
70
|
+
const ttl = limitMeta.ttl ?? 60;
|
|
71
|
+
|
|
72
|
+
const type = context.getType();
|
|
73
|
+
if (type !== 'http') return true;
|
|
74
|
+
|
|
75
|
+
const req = context.switchToHttp().getRequest<Request>();
|
|
76
|
+
const ip = req.headers.get('x-forwarded-for') || '';
|
|
77
|
+
const url = new URL(req.url);
|
|
78
|
+
const key = `throttle::${ip}::${url.pathname}`;
|
|
79
|
+
|
|
80
|
+
const record = this.storage.increment(key, ttl);
|
|
81
|
+
if (record.count > limit) {
|
|
82
|
+
throw new HttpException({
|
|
83
|
+
statusCode: 429,
|
|
84
|
+
message: 'ThrottlerException: Too Many Requests',
|
|
85
|
+
}, 429);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Module({})
|
|
93
|
+
export class ThrottlerModule {
|
|
94
|
+
static register(options: ThrottlerModuleOptions = {}): DynamicModule {
|
|
95
|
+
return {
|
|
96
|
+
module: ThrottlerModule,
|
|
97
|
+
providers: [
|
|
98
|
+
{
|
|
99
|
+
provide: 'THROTTLER_OPTIONS',
|
|
100
|
+
useValue: options,
|
|
101
|
+
},
|
|
102
|
+
ThrottlerStorage,
|
|
103
|
+
ThrottlerGuard,
|
|
104
|
+
],
|
|
105
|
+
exports: [ThrottlerStorage, ThrottlerGuard],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Injectable, Module, SetMetadata } from '../core/decorators.ts';
|
|
2
|
+
import { HttpException } from '../http/exceptions.ts';
|
|
3
|
+
|
|
4
|
+
export const HEALTH_CHECK_KEY = 'terminus:healthcheck';
|
|
5
|
+
export const HealthCheck = () => SetMetadata(HEALTH_CHECK_KEY, true);
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class HealthCheckService {
|
|
9
|
+
async check(indicators: (() => Promise<any> | any)[]): Promise<any> {
|
|
10
|
+
const info: Record<string, any> = {};
|
|
11
|
+
const error: Record<string, any> = {};
|
|
12
|
+
let isHealthy = true;
|
|
13
|
+
|
|
14
|
+
for (const indicator of indicators) {
|
|
15
|
+
try {
|
|
16
|
+
const result = await indicator();
|
|
17
|
+
Object.assign(info, result);
|
|
18
|
+
} catch (err: any) {
|
|
19
|
+
isHealthy = false;
|
|
20
|
+
Object.assign(error, err.payload || { [err.message || 'unknown']: { status: 'down' } });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const status = isHealthy ? 'ok' : 'error';
|
|
25
|
+
const responsePayload = {
|
|
26
|
+
status,
|
|
27
|
+
info: isHealthy ? info : {},
|
|
28
|
+
error: !isHealthy ? error : {},
|
|
29
|
+
details: { ...info, ...error },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (!isHealthy) {
|
|
33
|
+
throw new HttpException(responsePayload, 503);
|
|
34
|
+
}
|
|
35
|
+
return responsePayload;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Injectable()
|
|
40
|
+
export class HttpHealthIndicator {
|
|
41
|
+
async pingCheck(key: string, url: string): Promise<any> {
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(url);
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
throw new Error(`Response status code ${res.status}`);
|
|
46
|
+
}
|
|
47
|
+
return { [key]: { status: 'up' } };
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
const payload = { [key]: { status: 'down', message: err.message } };
|
|
50
|
+
const error: any = new Error(`Health check failed for URL: ${url}`);
|
|
51
|
+
error.payload = payload;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Module({
|
|
58
|
+
providers: [HealthCheckService, HttpHealthIndicator],
|
|
59
|
+
exports: [HealthCheckService, HttpHealthIndicator],
|
|
60
|
+
})
|
|
61
|
+
export class TerminusModule {}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { PipeTransform, ArgumentMetadata } from '../lifecycle/interfaces.ts';
|
|
2
|
+
import { Injectable } from '../core/decorators.ts';
|
|
3
|
+
import { HttpException } from '../http/exceptions.ts';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class ParseIntPipe implements PipeTransform<string | number, number> {
|
|
7
|
+
transform(value: string | number, metadata: ArgumentMetadata): number {
|
|
8
|
+
const val = typeof value === 'string' ? parseInt(value, 10) : value;
|
|
9
|
+
if (isNaN(val)) {
|
|
10
|
+
throw new HttpException(`Validation failed (numeric string is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
11
|
+
}
|
|
12
|
+
return val;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Injectable()
|
|
17
|
+
export class ParseFloatPipe implements PipeTransform<string | number, number> {
|
|
18
|
+
transform(value: string | number, metadata: ArgumentMetadata): number {
|
|
19
|
+
const val = typeof value === 'string' ? parseFloat(value) : value;
|
|
20
|
+
if (isNaN(val)) {
|
|
21
|
+
throw new HttpException(`Validation failed (float string is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
22
|
+
}
|
|
23
|
+
return val;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class ParseBoolPipe implements PipeTransform<string | boolean, boolean> {
|
|
29
|
+
transform(value: string | boolean, metadata: ArgumentMetadata): boolean {
|
|
30
|
+
if (value === true || value === 'true') return true;
|
|
31
|
+
if (value === false || value === 'false') return false;
|
|
32
|
+
throw new HttpException(`Validation failed (boolean string is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ParseArrayOptions {
|
|
37
|
+
items?: any;
|
|
38
|
+
separator?: string;
|
|
39
|
+
optional?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Injectable()
|
|
43
|
+
export class ParseArrayPipe implements PipeTransform {
|
|
44
|
+
constructor(private readonly options: ParseArrayOptions = {}) {}
|
|
45
|
+
|
|
46
|
+
transform(value: any, metadata: ArgumentMetadata): any[] {
|
|
47
|
+
if (value === undefined || value === null) {
|
|
48
|
+
if (this.options.optional) return [];
|
|
49
|
+
throw new HttpException(`Validation failed (array is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
50
|
+
}
|
|
51
|
+
const separator = this.options.separator ?? ',';
|
|
52
|
+
let arr: any[] = [];
|
|
53
|
+
if (typeof value === 'string') {
|
|
54
|
+
arr = value.split(separator);
|
|
55
|
+
} else if (Array.isArray(value)) {
|
|
56
|
+
arr = value;
|
|
57
|
+
} else {
|
|
58
|
+
throw new HttpException(`Validation failed (array is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (this.options.items) {
|
|
62
|
+
const type = this.options.items;
|
|
63
|
+
arr = arr.map((item) => {
|
|
64
|
+
if (type === Number) {
|
|
65
|
+
const val = Number(item);
|
|
66
|
+
if (isNaN(val)) throw new HttpException(`Validation failed (array of numbers is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
67
|
+
return val;
|
|
68
|
+
}
|
|
69
|
+
if (type === Boolean) {
|
|
70
|
+
if (item === true || item === 'true') return true;
|
|
71
|
+
if (item === false || item === 'false') return false;
|
|
72
|
+
throw new HttpException(`Validation failed (array of booleans is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
73
|
+
}
|
|
74
|
+
return item;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return arr;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Injectable()
|
|
82
|
+
export class ParseUUIDPipe implements PipeTransform<string, string> {
|
|
83
|
+
private static readonly uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
84
|
+
|
|
85
|
+
transform(value: string, metadata: ArgumentMetadata): string {
|
|
86
|
+
if (typeof value !== 'string' || !ParseUUIDPipe.uuidRegex.test(value)) {
|
|
87
|
+
throw new HttpException(`Validation failed (uuid string is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Injectable()
|
|
94
|
+
export class ParseEnumPipe implements PipeTransform {
|
|
95
|
+
constructor(private readonly enumType: any) {}
|
|
96
|
+
|
|
97
|
+
transform(value: any, metadata: ArgumentMetadata): any {
|
|
98
|
+
const values = Object.values(this.enumType);
|
|
99
|
+
if (!values.includes(value)) {
|
|
100
|
+
throw new HttpException(`Validation failed (enum value is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Injectable()
|
|
107
|
+
export class ParseFilePipe implements PipeTransform {
|
|
108
|
+
constructor(private readonly validators: any[] = []) {}
|
|
109
|
+
|
|
110
|
+
transform(value: any, metadata: ArgumentMetadata): any {
|
|
111
|
+
if (!value || (typeof value === 'object' && !value.buffer)) {
|
|
112
|
+
throw new HttpException(`Validation failed (file is expected) for parameter "${metadata.data ?? ''}"`, 400);
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Injectable()
|
|
119
|
+
export class DefaultValuePipe implements PipeTransform {
|
|
120
|
+
constructor(private readonly defaultValue: any) {}
|
|
121
|
+
|
|
122
|
+
transform(value: any, metadata: ArgumentMetadata): any {
|
|
123
|
+
if (value === undefined || value === null || value === '') {
|
|
124
|
+
return this.defaultValue;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/validation/index.ts
CHANGED
|
@@ -28,12 +28,22 @@ export function SubscribeMessage(event: string): MethodDecorator {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function MessageBody(): ParameterDecorator {
|
|
31
|
+
export function MessageBody(first?: any, ...pipes: any[]): ParameterDecorator {
|
|
32
32
|
return (target, propertyKey, parameterIndex) => {
|
|
33
33
|
if (!propertyKey) return;
|
|
34
34
|
const constructor = target.constructor;
|
|
35
|
+
|
|
36
|
+
let name: string | undefined = undefined;
|
|
37
|
+
let parsedPipes: any[] = [];
|
|
38
|
+
if (typeof first === 'string') {
|
|
39
|
+
name = first;
|
|
40
|
+
parsedPipes = pipes;
|
|
41
|
+
} else if (first !== undefined) {
|
|
42
|
+
parsedPipes = [first, ...pipes];
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
const existing = Reflect.getOwnMetadata('calyx:message_body', constructor) || [];
|
|
36
|
-
existing.push({ propertyKey, parameterIndex });
|
|
46
|
+
existing.push({ propertyKey, parameterIndex, name, pipes: parsedPipes });
|
|
37
47
|
Reflect.defineMetadata('calyx:message_body', existing, constructor);
|
|
38
48
|
};
|
|
39
49
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { expect, test, describe } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Test,
|
|
4
|
+
LazyModuleLoader,
|
|
5
|
+
Injectable,
|
|
6
|
+
Module,
|
|
7
|
+
ParseIntPipe,
|
|
8
|
+
ParseBoolPipe,
|
|
9
|
+
CalyxResponse,
|
|
10
|
+
SchedulerRegistry,
|
|
11
|
+
ScheduleModule,
|
|
12
|
+
Cron,
|
|
13
|
+
Interval,
|
|
14
|
+
CacheKey,
|
|
15
|
+
CacheTTL,
|
|
16
|
+
Client,
|
|
17
|
+
ClientsModule,
|
|
18
|
+
HealthCheckService,
|
|
19
|
+
TerminusModule,
|
|
20
|
+
CqrsModule,
|
|
21
|
+
CommandBus,
|
|
22
|
+
QueryBus,
|
|
23
|
+
EventBus,
|
|
24
|
+
CommandHandler,
|
|
25
|
+
QueryHandler,
|
|
26
|
+
EventsHandler,
|
|
27
|
+
ICommand,
|
|
28
|
+
IQuery,
|
|
29
|
+
IEvent,
|
|
30
|
+
ICommandHandler,
|
|
31
|
+
IQueryHandler,
|
|
32
|
+
IEventHandler,
|
|
33
|
+
} from '../src/index.ts';
|
|
34
|
+
|
|
35
|
+
@Injectable()
|
|
36
|
+
class DummyService {
|
|
37
|
+
getValue() { return 'dummy'; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Injectable()
|
|
41
|
+
class AliasService {
|
|
42
|
+
constructor(public readonly dummy: DummyService) {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Module({
|
|
46
|
+
providers: [
|
|
47
|
+
DummyService,
|
|
48
|
+
{
|
|
49
|
+
provide: 'ALIAS_TOKEN',
|
|
50
|
+
useExisting: DummyService,
|
|
51
|
+
},
|
|
52
|
+
AliasService,
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
class TestParityModule {}
|
|
56
|
+
|
|
57
|
+
describe('NestJS Parity Extensions', () => {
|
|
58
|
+
|
|
59
|
+
test('DI: useExisting alias provider resolution', async () => {
|
|
60
|
+
const moduleRef = await Test.createTestingModule({
|
|
61
|
+
imports: [TestParityModule],
|
|
62
|
+
}).compile();
|
|
63
|
+
|
|
64
|
+
const dummy = moduleRef.get(DummyService);
|
|
65
|
+
const alias = moduleRef.get('ALIAS_TOKEN');
|
|
66
|
+
expect(dummy).toBe(alias);
|
|
67
|
+
expect(dummy.getValue()).toBe('dummy');
|
|
68
|
+
await moduleRef.close();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('DI: Test/TestingModule overrides', async () => {
|
|
72
|
+
const moduleRef = await Test.createTestingModule({
|
|
73
|
+
imports: [TestParityModule],
|
|
74
|
+
})
|
|
75
|
+
.overrideProvider(DummyService)
|
|
76
|
+
.useValue({ getValue: () => 'mocked' })
|
|
77
|
+
.compile();
|
|
78
|
+
|
|
79
|
+
const dummy = moduleRef.get(DummyService);
|
|
80
|
+
expect(dummy.getValue()).toBe('mocked');
|
|
81
|
+
await moduleRef.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('DI: LazyModuleLoader', async () => {
|
|
85
|
+
const moduleRef = await Test.createTestingModule({
|
|
86
|
+
imports: [],
|
|
87
|
+
providers: [LazyModuleLoader],
|
|
88
|
+
}).compile();
|
|
89
|
+
|
|
90
|
+
const loader = moduleRef.get(LazyModuleLoader);
|
|
91
|
+
const lazyModuleRef = await loader.load(() => Promise.resolve(TestParityModule));
|
|
92
|
+
expect(lazyModuleRef).toBeDefined();
|
|
93
|
+
|
|
94
|
+
const dummy = lazyModuleRef.get(DummyService);
|
|
95
|
+
expect(dummy.getValue()).toBe('dummy');
|
|
96
|
+
await moduleRef.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('Pipes: built-in parsing pipes', () => {
|
|
100
|
+
const intPipe = new ParseIntPipe();
|
|
101
|
+
expect(intPipe.transform('42', { type: 'query', data: 'id' })).toBe(42);
|
|
102
|
+
expect(() => intPipe.transform('abc', { type: 'query', data: 'id' })).toThrow();
|
|
103
|
+
|
|
104
|
+
const boolPipe = new ParseBoolPipe();
|
|
105
|
+
expect(boolPipe.transform('true', { type: 'query', data: 'flag' })).toBe(true);
|
|
106
|
+
expect(boolPipe.transform('false', { type: 'query', data: 'flag' })).toBe(false);
|
|
107
|
+
expect(() => boolPipe.transform('abc', { type: 'query', data: 'flag' })).toThrow();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('CalyxResponse compatibility methods', () => {
|
|
111
|
+
const res = new CalyxResponse();
|
|
112
|
+
res.header('X-Test', 'value')
|
|
113
|
+
.type('text/html')
|
|
114
|
+
.cookie('cookie_name', 'cookie_val')
|
|
115
|
+
.append('X-Test', 'another');
|
|
116
|
+
|
|
117
|
+
expect(res.get('x-test')).toBe('value, another');
|
|
118
|
+
expect(res.get('content-type')).toBe('text/html');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('SchedulerRegistry named dynamic tasks', async () => {
|
|
122
|
+
@Injectable()
|
|
123
|
+
class ScheduledTasks {
|
|
124
|
+
@Cron('* * * * * *', { name: 'my-cron' })
|
|
125
|
+
runCron() {}
|
|
126
|
+
|
|
127
|
+
@Interval('my-interval', 1000)
|
|
128
|
+
runInterval() {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Module({
|
|
132
|
+
imports: [ScheduleModule.forRoot()],
|
|
133
|
+
providers: [ScheduledTasks],
|
|
134
|
+
})
|
|
135
|
+
class RootScheduleModule {}
|
|
136
|
+
|
|
137
|
+
const moduleRef = await Test.createTestingModule({
|
|
138
|
+
imports: [RootScheduleModule],
|
|
139
|
+
}).compile();
|
|
140
|
+
|
|
141
|
+
const app = moduleRef.createCalyxApplication();
|
|
142
|
+
await app.init();
|
|
143
|
+
|
|
144
|
+
const registry = moduleRef.get(SchedulerRegistry);
|
|
145
|
+
expect(registry.getCronJob('my-cron')).toBeDefined();
|
|
146
|
+
expect(registry.getInterval('my-interval')).toBeDefined();
|
|
147
|
+
|
|
148
|
+
registry.deleteCronJob('my-cron');
|
|
149
|
+
registry.deleteInterval('my-interval');
|
|
150
|
+
|
|
151
|
+
expect(() => registry.getCronJob('my-cron')).toThrow();
|
|
152
|
+
expect(() => registry.getInterval('my-interval')).toThrow();
|
|
153
|
+
|
|
154
|
+
await app.close();
|
|
155
|
+
await moduleRef.close();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('CacheInterceptor key/ttl retrieval', () => {
|
|
159
|
+
class Target {
|
|
160
|
+
@CacheKey('custom_key')
|
|
161
|
+
@CacheTTL(100)
|
|
162
|
+
handler() {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const t = new Target();
|
|
166
|
+
const key = Reflect.getMetadata('cache_metadata_key', t.handler);
|
|
167
|
+
const ttl = Reflect.getMetadata('cache_metadata_ttl', t.handler);
|
|
168
|
+
|
|
169
|
+
expect(key).toBe('custom_key');
|
|
170
|
+
expect(ttl).toBe(100);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('Microservice clients: @Client and ClientsModule', async () => {
|
|
174
|
+
class Target {
|
|
175
|
+
@Client({ options: { host: '127.0.0.1', port: 1234 } })
|
|
176
|
+
client: any;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const target = new Target();
|
|
180
|
+
expect(target.client).toBeDefined();
|
|
181
|
+
|
|
182
|
+
const moduleRef = await Test.createTestingModule({
|
|
183
|
+
imports: [
|
|
184
|
+
ClientsModule.register([
|
|
185
|
+
{ name: 'TEST_SERVICE', options: { host: 'localhost', port: 5000 } },
|
|
186
|
+
]),
|
|
187
|
+
],
|
|
188
|
+
}).compile();
|
|
189
|
+
|
|
190
|
+
const service = moduleRef.get('TEST_SERVICE');
|
|
191
|
+
expect(service).toBeDefined();
|
|
192
|
+
await moduleRef.close();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('CQRS command/query/event buses', async () => {
|
|
196
|
+
class MyCommand implements ICommand {}
|
|
197
|
+
class MyQuery implements IQuery {}
|
|
198
|
+
class MyEvent implements IEvent {}
|
|
199
|
+
|
|
200
|
+
let commandHandled = false;
|
|
201
|
+
let queryHandled = false;
|
|
202
|
+
let eventHandledCount = 0;
|
|
203
|
+
|
|
204
|
+
@CommandHandler(MyCommand)
|
|
205
|
+
class MyCommandHandler implements ICommandHandler<MyCommand> {
|
|
206
|
+
async execute(command: MyCommand) {
|
|
207
|
+
commandHandled = true;
|
|
208
|
+
return 'command-result';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@QueryHandler(MyQuery)
|
|
213
|
+
class MyQueryHandler implements IQueryHandler<MyQuery> {
|
|
214
|
+
async execute(query: MyQuery) {
|
|
215
|
+
queryHandled = true;
|
|
216
|
+
return 'query-result';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@EventsHandler(MyEvent)
|
|
221
|
+
class MyEventHandler implements IEventHandler<MyEvent> {
|
|
222
|
+
handle(event: MyEvent) {
|
|
223
|
+
eventHandledCount++;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const moduleRef = await Test.createTestingModule({
|
|
228
|
+
imports: [CqrsModule],
|
|
229
|
+
providers: [MyCommandHandler, MyQueryHandler, MyEventHandler],
|
|
230
|
+
}).compile();
|
|
231
|
+
|
|
232
|
+
const commandBus = moduleRef.get(CommandBus);
|
|
233
|
+
const queryBus = moduleRef.get(QueryBus);
|
|
234
|
+
const eventBus = moduleRef.get(EventBus);
|
|
235
|
+
|
|
236
|
+
const cqrsModule = moduleRef.get(CqrsModule);
|
|
237
|
+
(cqrsModule as any).onModuleInit();
|
|
238
|
+
|
|
239
|
+
const cmdRes = await commandBus.execute(new MyCommand());
|
|
240
|
+
expect(cmdRes).toBe('command-result');
|
|
241
|
+
expect(commandHandled).toBe(true);
|
|
242
|
+
|
|
243
|
+
const qryRes = await queryBus.execute(new MyQuery());
|
|
244
|
+
expect(qryRes).toBe('query-result');
|
|
245
|
+
expect(queryHandled).toBe(true);
|
|
246
|
+
|
|
247
|
+
eventBus.publish(new MyEvent());
|
|
248
|
+
expect(eventHandledCount).toBe(1);
|
|
249
|
+
|
|
250
|
+
await moduleRef.close();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('Terminus health check service', async () => {
|
|
254
|
+
const moduleRef = await Test.createTestingModule({
|
|
255
|
+
imports: [TerminusModule],
|
|
256
|
+
}).compile();
|
|
257
|
+
|
|
258
|
+
const health = moduleRef.get(HealthCheckService);
|
|
259
|
+
const res = await health.check([
|
|
260
|
+
() => ({ db: { status: 'up' } }),
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
expect(res.status).toBe('ok');
|
|
264
|
+
expect(res.details.db.status).toBe('up');
|
|
265
|
+
|
|
266
|
+
expect(health.check([
|
|
267
|
+
() => { throw new Error('DB connection lost'); }
|
|
268
|
+
])).rejects.toThrow();
|
|
269
|
+
|
|
270
|
+
await moduleRef.close();
|
|
271
|
+
});
|
|
272
|
+
});
|