@martel/calyx 1.10.1 → 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 +14 -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 +103 -3
- package/src/http/application.ts +160 -19
- 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/openapi/swagger.module.ts +29 -8
- 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/graphql.test.ts +101 -0
- package/tests/nestjs-parity.test.ts +272 -0
- package/tests/openapi.test.ts +41 -0
|
@@ -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
|
+
});
|
package/tests/openapi.test.ts
CHANGED
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
DocumentBuilder,
|
|
20
20
|
SwaggerModule,
|
|
21
21
|
PartialType,
|
|
22
|
+
IsString,
|
|
23
|
+
IsNumber,
|
|
24
|
+
IsOptional,
|
|
25
|
+
IsEmail,
|
|
22
26
|
} from '../src/index.ts';
|
|
23
27
|
|
|
24
28
|
// 1. DTO Model
|
|
@@ -42,6 +46,22 @@ class CreateItemDto {
|
|
|
42
46
|
// 3. Partial DTO using mapped type
|
|
43
47
|
class UpdateItemDto extends PartialType(CreateItemDto) {}
|
|
44
48
|
|
|
49
|
+
// 4. Validation-rich DTO (without explicit @ApiProperty)
|
|
50
|
+
class ValidationRichDto {
|
|
51
|
+
@IsString()
|
|
52
|
+
title!: string;
|
|
53
|
+
|
|
54
|
+
@IsNumber()
|
|
55
|
+
amount!: number;
|
|
56
|
+
|
|
57
|
+
@IsOptional()
|
|
58
|
+
@IsString()
|
|
59
|
+
note?: string;
|
|
60
|
+
|
|
61
|
+
@IsEmail()
|
|
62
|
+
contactEmail!: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
@ApiTags('Items')
|
|
46
66
|
@ApiBearerAuth('jwt')
|
|
47
67
|
@Controller('items')
|
|
@@ -66,6 +86,12 @@ class ItemsController {
|
|
|
66
86
|
updateItem(@Body() body: UpdateItemDto) {
|
|
67
87
|
return { id: 3, ...body };
|
|
68
88
|
}
|
|
89
|
+
|
|
90
|
+
@Post('validate')
|
|
91
|
+
@ApiOperation({ summary: 'Validation endpoint' })
|
|
92
|
+
validateDto(@Body() body: ValidationRichDto) {
|
|
93
|
+
return body;
|
|
94
|
+
}
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
@Module({
|
|
@@ -150,6 +176,21 @@ describe('OpenAPI (Swagger) Generation', () => {
|
|
|
150
176
|
expect(spec.components.schemas.UpdateItemDto).toBeDefined();
|
|
151
177
|
expect(spec.components.schemas.UpdateItemDto.required).toBeUndefined(); // all optional
|
|
152
178
|
expect(spec.components.schemas.UpdateItemDto.properties.name.type).toBe('string');
|
|
179
|
+
|
|
180
|
+
// 6. Validation Decorator Schema Auto-enrichment
|
|
181
|
+
expect(spec.components.schemas.ValidationRichDto).toBeDefined();
|
|
182
|
+
const richSchema = spec.components.schemas.ValidationRichDto;
|
|
183
|
+
expect(richSchema.properties.title.type).toBe('string');
|
|
184
|
+
expect(richSchema.properties.amount.type).toBe('number');
|
|
185
|
+
expect(richSchema.properties.note.type).toBe('string');
|
|
186
|
+
expect(richSchema.properties.contactEmail.type).toBe('string');
|
|
187
|
+
expect(richSchema.properties.contactEmail.format).toBe('email');
|
|
188
|
+
|
|
189
|
+
// Ensure note is optional, but others are required
|
|
190
|
+
expect(richSchema.required).toContain('title');
|
|
191
|
+
expect(richSchema.required).toContain('amount');
|
|
192
|
+
expect(richSchema.required).toContain('contactEmail');
|
|
193
|
+
expect(richSchema.required).not.toContain('note');
|
|
153
194
|
});
|
|
154
195
|
|
|
155
196
|
test('should serve Swagger UI html wrapper', async () => {
|