@martel/calyx 1.8.0 → 1.9.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +71 -27
  3. package/benchmarks/graphql-benchmark.ts +81 -0
  4. package/benchmarks/index.ts +32 -0
  5. package/benchmarks/openapi-benchmark.ts +168 -0
  6. package/benchmarks/serialization-benchmark.ts +52 -0
  7. package/benchmarks/techniques-benchmark.ts +84 -0
  8. package/benchmarks/validation-benchmark.ts +74 -0
  9. package/bun.lock +11 -0
  10. package/package.json +7 -6
  11. package/src/cli/index.ts +19 -3
  12. package/src/compression/compression.middleware.ts +7 -0
  13. package/src/cookies/cookies.ts +69 -0
  14. package/src/database/mongoose.module.ts +250 -0
  15. package/src/database/typeorm.module.ts +276 -0
  16. package/src/file-upload/file-upload.interceptor.ts +93 -0
  17. package/src/file-upload/index.ts +1 -0
  18. package/src/graphql/decorators.ts +70 -0
  19. package/src/graphql/graphql.module.ts +197 -47
  20. package/src/http/application.ts +330 -70
  21. package/src/http-client/http-client.module.ts +124 -0
  22. package/src/http-client/index.ts +1 -0
  23. package/src/index.ts +14 -0
  24. package/src/logger/index.ts +1 -0
  25. package/src/logger/logger.service.ts +118 -0
  26. package/src/mvc/index.ts +1 -0
  27. package/src/mvc/mvc.ts +22 -0
  28. package/src/openapi/decorators.ts +154 -0
  29. package/src/openapi/swagger.module.ts +172 -20
  30. package/src/queue/queue.module.ts +174 -0
  31. package/src/session/index.ts +1 -0
  32. package/src/session/session.middleware.ts +82 -0
  33. package/src/sse/index.ts +1 -0
  34. package/src/sse/sse.ts +18 -0
  35. package/src/streaming/index.ts +1 -0
  36. package/src/streaming/streamable-file.ts +32 -0
  37. package/src/validation/pipe.ts +79 -10
  38. package/src/versioning/versioning.ts +46 -0
  39. package/tests/graphql.test.ts +68 -4
  40. package/tests/openapi.test.ts +78 -11
  41. package/tests/techniques.test.ts +471 -0
@@ -3,17 +3,25 @@ import {
3
3
  Module,
4
4
  Controller,
5
5
  Get,
6
+ Post,
6
7
  Param,
8
+ Query,
9
+ Body,
7
10
  CalyxFactory,
8
11
  ApiTags,
9
12
  ApiOperation,
10
13
  ApiResponse,
11
14
  ApiProperty,
15
+ ApiBody,
16
+ ApiQuery,
17
+ ApiHeader,
18
+ ApiBearerAuth,
12
19
  DocumentBuilder,
13
20
  SwaggerModule,
21
+ PartialType,
14
22
  } from '../src/index.ts';
15
23
 
16
- // 1. Model class
24
+ // 1. DTO Model
17
25
  class Item {
18
26
  @ApiProperty({ description: 'The unique identifier', type: Number })
19
27
  id!: number;
@@ -22,15 +30,42 @@ class Item {
22
30
  name!: string;
23
31
  }
24
32
 
33
+ // 2. Request Body DTO
34
+ class CreateItemDto {
35
+ @ApiProperty({ description: 'The name of the item', type: String, required: true })
36
+ name!: string;
37
+
38
+ @ApiProperty({ description: 'The item price', type: Number, required: true })
39
+ price!: number;
40
+ }
41
+
42
+ // 3. Partial DTO using mapped type
43
+ class UpdateItemDto extends PartialType(CreateItemDto) {}
44
+
25
45
  @ApiTags('Items')
46
+ @ApiBearerAuth('jwt')
26
47
  @Controller('items')
27
48
  class ItemsController {
28
49
  @Get(':id')
29
50
  @ApiOperation({ summary: 'Get item by id', description: 'Returns a single item' })
30
51
  @ApiResponse({ status: 200, description: 'Item found successfully', type: Item })
31
- getItem(@Param('id') id: string) {
52
+ @ApiHeader({ name: 'x-request-id', description: 'Correlation identifier' })
53
+ getItem(@Param('id') id: string, @Query('fields') fields?: string) {
32
54
  return { id: 1, name: 'Gadget' };
33
55
  }
56
+
57
+ @Post()
58
+ @ApiOperation({ summary: 'Create a new item' })
59
+ @ApiResponse({ status: 201, description: 'Item created successfully', type: Item })
60
+ createItem(@Body() body: CreateItemDto) {
61
+ return { id: 2, ...body };
62
+ }
63
+
64
+ @Post('update')
65
+ @ApiOperation({ summary: 'Partial update' })
66
+ updateItem(@Body() body: UpdateItemDto) {
67
+ return { id: 3, ...body };
68
+ }
34
69
  }
35
70
 
36
71
  @Module({
@@ -46,11 +81,12 @@ describe('OpenAPI (Swagger) Generation', () => {
46
81
  beforeAll(async () => {
47
82
  app = await CalyxFactory.create(TestApp);
48
83
 
49
- // Build OpenAPI config
84
+ // Build OpenAPI config with bearer authentication
50
85
  const config = new DocumentBuilder()
51
86
  .setTitle('My Test API')
52
87
  .setDescription('OpenAPI description')
53
88
  .setVersion('2.0.0')
89
+ .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt')
54
90
  .build();
55
91
 
56
92
  const document = SwaggerModule.createDocument(app, config);
@@ -64,25 +100,56 @@ describe('OpenAPI (Swagger) Generation', () => {
64
100
  await app.close();
65
101
  });
66
102
 
67
- test('should serve OpenAPI JSON specification with paths and components', async () => {
103
+ test('should serve OpenAPI JSON specification with correct types and schemas', async () => {
68
104
  const res = await fetch(`${baseUrl}/api-json`);
69
105
  expect(res.status).toBe(200);
70
106
  const spec = await res.json();
71
107
 
108
+ // 1. Basic Specs
72
109
  expect(spec.openapi).toBe('3.0.0');
73
110
  expect(spec.info.title).toBe('My Test API');
74
111
  expect(spec.info.version).toBe('2.0.0');
112
+
113
+ // 2. Security Setup
114
+ expect(spec.components.securitySchemes.jwt).toBeDefined();
115
+ expect(spec.components.securitySchemes.jwt.scheme).toBe('bearer');
116
+
117
+ // 3. GET /items/{id} endpoint parameters (path, query, header)
75
118
  expect(spec.paths['/items/{id}']).toBeDefined();
76
-
77
119
  const getOp = spec.paths['/items/{id}'].get;
78
120
  expect(getOp.summary).toBe('Get item by id');
79
121
  expect(getOp.tags).toContain('Items');
80
- expect(getOp.parameters[0].name).toBe('id');
81
- expect(getOp.parameters[0].in).toBe('path');
82
-
83
- expect(spec.components.schemas.Item).toBeDefined();
84
- expect(spec.components.schemas.Item.properties.name.type).toBe('string');
85
- expect(spec.components.schemas.Item.properties.id.type).toBe('number');
122
+ expect(getOp.security).toEqual([{ jwt: [] }]);
123
+
124
+ const params = getOp.parameters;
125
+ const pathP = params.find((p: any) => p.in === 'path');
126
+ const queryP = params.find((p: any) => p.in === 'query');
127
+ const headerP = params.find((p: any) => p.in === 'header');
128
+
129
+ expect(pathP).toBeDefined();
130
+ expect(pathP.name).toBe('id');
131
+
132
+ expect(queryP).toBeDefined();
133
+ expect(queryP.name).toBe('fields');
134
+
135
+ expect(headerP).toBeDefined();
136
+ expect(headerP.name).toBe('x-request-id');
137
+
138
+ // 4. POST /items request body and schema DTO definition
139
+ expect(spec.paths['/items']).toBeDefined();
140
+ const postOp = spec.paths['/items'].post;
141
+ expect(postOp.requestBody).toBeDefined();
142
+ expect(postOp.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/CreateItemDto');
143
+
144
+ expect(spec.components.schemas.CreateItemDto).toBeDefined();
145
+ expect(spec.components.schemas.CreateItemDto.required).toContain('name');
146
+ expect(spec.components.schemas.CreateItemDto.properties.name.type).toBe('string');
147
+ expect(spec.components.schemas.CreateItemDto.properties.price.type).toBe('number');
148
+
149
+ // 5. Mapped Type (PartialType)
150
+ expect(spec.components.schemas.UpdateItemDto).toBeDefined();
151
+ expect(spec.components.schemas.UpdateItemDto.required).toBeUndefined(); // all optional
152
+ expect(spec.components.schemas.UpdateItemDto.properties.name.type).toBe('string');
86
153
  });
87
154
 
88
155
  test('should serve Swagger UI html wrapper', async () => {
@@ -0,0 +1,471 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { Observable, interval, of } from 'rxjs';
5
+ import { map, take } from 'rxjs/operators';
6
+ import {
7
+ CalyxFactory,
8
+ Module,
9
+ Controller,
10
+ Get,
11
+ Post,
12
+ Body,
13
+ Injectable,
14
+ Inject,
15
+ TypeOrmModule,
16
+ MongooseModule,
17
+ InjectRepository,
18
+ InjectModel,
19
+ Req,
20
+ Res,
21
+ Repository,
22
+ Model,
23
+ Version,
24
+ VersioningType,
25
+ QueueModule,
26
+ InjectQueue,
27
+ Queue,
28
+ Processor,
29
+ Process,
30
+ type Job,
31
+ Logger,
32
+ Cookies,
33
+ compression,
34
+ FileInterceptor,
35
+ UploadedFile,
36
+ UploadedFiles,
37
+ UseInterceptors,
38
+ StreamableFile,
39
+ HttpModule,
40
+ HttpService,
41
+ session,
42
+ Session,
43
+ Render,
44
+ Sse,
45
+ } from '../src/index.ts';
46
+
47
+ // 1. Entities & Schemas
48
+ class DBUser {
49
+ id?: number;
50
+ name!: string;
51
+ role!: string;
52
+ }
53
+
54
+ class MongoCat {
55
+ _id?: any;
56
+ name!: string;
57
+ age!: number;
58
+ }
59
+
60
+ // 2. Queue Processor
61
+ @Processor('test-queue')
62
+ class TestQueueProcessor {
63
+ static processedJobs: any[] = [];
64
+
65
+ @Process('test-job')
66
+ handleTestJob(job: Job) {
67
+ TestQueueProcessor.processedJobs.push(job.data);
68
+ return { status: 'ok', handled: true };
69
+ }
70
+ }
71
+
72
+ // 3. Controller under Test
73
+ @Controller('test')
74
+ class TechniquesController {
75
+ constructor(
76
+ @InjectRepository(DBUser)
77
+ private readonly userRepo: Repository<DBUser>,
78
+ @InjectModel('Cat')
79
+ private readonly catModel: Model<MongoCat>,
80
+ @InjectQueue('test-queue')
81
+ private readonly queue: Queue,
82
+ private readonly httpService: HttpService
83
+ ) {}
84
+
85
+ // DB Tests
86
+ @Post('db/user')
87
+ async createUser(@Body() body: { name: string; role: string }) {
88
+ const user = new DBUser();
89
+ user.name = body.name;
90
+ user.role = body.role;
91
+ return await this.userRepo.save(user);
92
+ }
93
+
94
+ @Get('db/users')
95
+ async getUsers() {
96
+ return await this.userRepo.find();
97
+ }
98
+
99
+ @Post('mongo/cat')
100
+ async createCat(@Body() body: { name: string; age: number }) {
101
+ return await this.catModel.create(body);
102
+ }
103
+
104
+ @Get('mongo/cats')
105
+ async getCats() {
106
+ return await this.catModel.find().exec();
107
+ }
108
+
109
+ // Versioning Tests
110
+ @Version('1')
111
+ @Get('versioned')
112
+ getVersion1() {
113
+ return 'version 1';
114
+ }
115
+
116
+ @Version('2')
117
+ @Get('versioned')
118
+ getVersion2() {
119
+ return 'version 2';
120
+ }
121
+
122
+ // Queue Tests
123
+ @Post('queue/job')
124
+ async addJob(@Body() body: any) {
125
+ const job = await this.queue.add('test-job', body);
126
+ return { id: job.id, status: job.status };
127
+ }
128
+
129
+ // Cookies
130
+ @Get('cookies')
131
+ getCookies(@Cookies('test_cookie') val: string) {
132
+ return { test_cookie: val };
133
+ }
134
+
135
+ @Post('cookies')
136
+ setCookies(@Req() req: any, @Res() res: any) {
137
+ res.cookie('test_cookie', 'calyx_val', { httpOnly: true });
138
+ res.send({ status: 'cookie_set' });
139
+ }
140
+
141
+ // Compression & Files
142
+ @Get('long-text')
143
+ getLongText() {
144
+ return 'A'.repeat(5000); // long string to trigger compression
145
+ }
146
+
147
+ @Post('upload')
148
+ @UseInterceptors(new FileInterceptor('file'))
149
+ uploadFile(@UploadedFile() file: any) {
150
+ return {
151
+ fieldname: file.fieldname,
152
+ originalname: file.originalname,
153
+ size: file.size,
154
+ content: file.buffer.toString(),
155
+ };
156
+ }
157
+
158
+ @Get('stream')
159
+ streamFile() {
160
+ const buffer = Buffer.from('streamable file contents');
161
+ return new StreamableFile(buffer, { type: 'text/plain', disposition: 'attachment; filename="test.txt"' });
162
+ }
163
+
164
+ // Http Service
165
+ @Get('fetch-local')
166
+ fetchLocal() {
167
+ return this.httpService.get('http://localhost:3999/test/long-text').pipe(
168
+ map((res) => ({ size: res.data.length, status: res.status }))
169
+ );
170
+ }
171
+
172
+ // Session
173
+ @Get('session')
174
+ getSession(@Session() sessionData: any) {
175
+ sessionData.counter = (sessionData.counter || 0) + 1;
176
+ return { counter: sessionData.counter };
177
+ }
178
+
179
+ // MVC Render
180
+ @Get('render')
181
+ @Render('test-template')
182
+ renderView() {
183
+ return { title: 'Calyx Framework', message: 'Hello MVC!' };
184
+ }
185
+
186
+ // SSE
187
+ @Sse('sse')
188
+ sseStream(): Observable<any> {
189
+ return interval(10).pipe(
190
+ take(3),
191
+ map((num) => ({ data: { count: num }, id: String(num), type: 'message' }))
192
+ );
193
+ }
194
+ }
195
+
196
+ @Module({
197
+ imports: [
198
+ TypeOrmModule.forRoot({ type: 'sqlite', database: ':memory:' }),
199
+ TypeOrmModule.forFeature([DBUser]),
200
+ MongooseModule.forRoot('mongodb://localhost/test_db'),
201
+ MongooseModule.forFeature([{ name: 'Cat' }]),
202
+ QueueModule.registerQueue({ name: 'test-queue' }),
203
+ HttpModule.register(),
204
+ ],
205
+ controllers: [TechniquesController],
206
+ providers: [TestQueueProcessor],
207
+ })
208
+ class TechniquesTestApp {}
209
+
210
+ describe('Calyx NestJS Techniques Integration Tests', () => {
211
+ let app: any;
212
+ let baseUrl: string;
213
+ const PORT = 3999;
214
+ const viewsDir = join(import.meta.dirname, '../views');
215
+
216
+ beforeAll(async () => {
217
+ // Write a mock view template
218
+ if (!existsSync(viewsDir)) {
219
+ mkdirSync(viewsDir, { recursive: true });
220
+ }
221
+ writeFileSync(
222
+ join(viewsDir, 'test-template.html'),
223
+ '<html><body><h1>{{ title }}</h1><p>{{ message }}</p></body></html>'
224
+ );
225
+
226
+ app = await CalyxFactory.create(TechniquesTestApp);
227
+ app.enableVersioning({
228
+ type: VersioningType.HEADER,
229
+ header: 'X-API-Version',
230
+ });
231
+ app.setViewsDir(viewsDir);
232
+ app.enableCompression();
233
+ app.use(session({ name: 'test_session', ttl: 100 }));
234
+
235
+ await app.listen(PORT);
236
+ baseUrl = `http://localhost:${PORT}`;
237
+ });
238
+
239
+ afterAll(async () => {
240
+ await app.close();
241
+ // Clean up template
242
+ try {
243
+ unlinkSync(join(viewsDir, 'test-template.html'));
244
+ rmdirSync(viewsDir);
245
+ } catch {
246
+ // ignore
247
+ }
248
+ });
249
+
250
+ test('Database Module: should save and find entity successfully', async () => {
251
+ const createRes = await fetch(`${baseUrl}/test/db/user`, {
252
+ method: 'POST',
253
+ headers: { 'content-type': 'application/json' },
254
+ body: JSON.stringify({ name: 'Alice', role: 'admin' }),
255
+ });
256
+ expect(createRes.status).toBe(201);
257
+ const user = await createRes.json();
258
+ expect(user.id).toBeDefined();
259
+ expect(user.name).toBe('Alice');
260
+
261
+ const getRes = await fetch(`${baseUrl}/test/db/users`);
262
+ expect(getRes.status).toBe(200);
263
+ const users = await getRes.json();
264
+ expect(users.length).toBeGreaterThanOrEqual(1);
265
+ expect(users[0].name).toBe('Alice');
266
+ });
267
+
268
+ test('Mongo Module: should create and find document successfully', async () => {
269
+ const createRes = await fetch(`${baseUrl}/test/mongo/cat`, {
270
+ method: 'POST',
271
+ headers: { 'content-type': 'application/json' },
272
+ body: JSON.stringify({ name: 'Garfield', age: 5 }),
273
+ });
274
+ expect(createRes.status).toBe(201);
275
+ const cat = await createRes.json();
276
+ expect(cat._id).toBeDefined();
277
+ expect(cat.name).toBe('Garfield');
278
+
279
+ const getRes = await fetch(`${baseUrl}/test/mongo/cats`);
280
+ expect(getRes.status).toBe(200);
281
+ const cats = await getRes.json();
282
+ expect(cats.length).toBeGreaterThanOrEqual(1);
283
+ expect(cats[0].name).toBe('Garfield');
284
+ });
285
+
286
+ test('Versioning Module: should match route version based on Header', async () => {
287
+ const resV1 = await fetch(`${baseUrl}/test/versioned`, {
288
+ headers: { 'X-API-Version': '1' },
289
+ });
290
+ expect(resV1.status).toBe(200);
291
+ expect(await resV1.text()).toBe('version 1');
292
+
293
+ const resV2 = await fetch(`${baseUrl}/test/versioned`, {
294
+ headers: { 'X-API-Version': '2' },
295
+ });
296
+ expect(resV2.status).toBe(200);
297
+ expect(await resV2.text()).toBe('version 2');
298
+ });
299
+
300
+ test('Queue Module: should execute task queue job asynchronously', async () => {
301
+ TestQueueProcessor.processedJobs = [];
302
+ const res = await fetch(`${baseUrl}/test/queue/job`, {
303
+ method: 'POST',
304
+ headers: { 'content-type': 'application/json' },
305
+ body: JSON.stringify({ message: 'hello queue' }),
306
+ });
307
+ expect(res.status).toBe(201);
308
+
309
+ // Wait a brief moment for the queue microtask to process the job
310
+ await new Promise((resolve) => setTimeout(resolve, 50));
311
+ expect(TestQueueProcessor.processedJobs.length).toBe(1);
312
+ expect(TestQueueProcessor.processedJobs[0]).toEqual({ message: 'hello queue' });
313
+ });
314
+
315
+ test('Cookies: should parse request cookies and set response cookies', async () => {
316
+ const setRes = await fetch(`${baseUrl}/test/cookies`, { method: 'POST' });
317
+ expect(setRes.status).toBe(200);
318
+ const cookieHeader = setRes.headers.get('set-cookie');
319
+ expect(cookieHeader).toContain('test_cookie=calyx_val');
320
+
321
+ // Get cookies using client cookie header
322
+ const getRes = await fetch(`${baseUrl}/test/cookies`, {
323
+ headers: { cookie: 'test_cookie=calyx_val' },
324
+ });
325
+ const body = await getRes.json();
326
+ expect(body.test_cookie).toBe('calyx_val');
327
+ });
328
+
329
+ test('Compression Middleware: should compress response body when accepted', async () => {
330
+ const normalRes = await fetch(`${baseUrl}/test/long-text`, {
331
+ headers: { 'accept-encoding': 'identity' },
332
+ });
333
+ expect(normalRes.status).toBe(200);
334
+ expect(normalRes.headers.get('content-encoding')).toBeNull();
335
+
336
+ const compressedRes = await fetch(`${baseUrl}/test/long-text`, {
337
+ headers: { 'accept-encoding': 'gzip' },
338
+ });
339
+ expect(compressedRes.status).toBe(200);
340
+ expect(compressedRes.headers.get('content-encoding')).toBe('gzip');
341
+ });
342
+
343
+ test('File Upload: should parse multipart/form-data body and extract file properties', async () => {
344
+ const formData = new FormData();
345
+ const blob = new Blob(['uploaded file data'], { type: 'text/plain' });
346
+ formData.append('file', blob, 'hello.txt');
347
+
348
+ const res = await fetch(`${baseUrl}/test/upload`, {
349
+ method: 'POST',
350
+ body: formData,
351
+ });
352
+ expect(res.status).toBe(201);
353
+ const data = await res.json();
354
+ expect(data.originalname).toBe('hello.txt');
355
+ expect(data.content).toBe('uploaded file data');
356
+ });
357
+
358
+ test('Streaming Files: should stream response with correct headers', async () => {
359
+ const res = await fetch(`${baseUrl}/test/stream`);
360
+ expect(res.status).toBe(200);
361
+ expect(res.headers.get('content-type')).toBe('text/plain');
362
+ expect(res.headers.get('content-disposition')).toBe('attachment; filename="test.txt"');
363
+ const content = await res.text();
364
+ expect(content).toBe('streamable file contents');
365
+ });
366
+
367
+ test('HTTP Client Module: should make HTTP request using HttpService', async () => {
368
+ const res = await fetch(`${baseUrl}/test/fetch-local`);
369
+ expect(res.status).toBe(200);
370
+ const data = await res.json();
371
+ expect(data.status).toBe(200);
372
+ expect(data.size).toBe(5000);
373
+ });
374
+
375
+ test('Session: should persist session state across requests', async () => {
376
+ const res1 = await fetch(`${baseUrl}/test/session`);
377
+ expect(res1.status).toBe(200);
378
+ const body1 = await res1.json();
379
+ expect(body1.counter).toBe(1);
380
+
381
+ const cookie = res1.headers.get('set-cookie');
382
+ expect(cookie).toContain('test_session=');
383
+
384
+ // Extract session ID
385
+ const sid = cookie!.split(';')[0];
386
+
387
+ const res2 = await fetch(`${baseUrl}/test/session`, {
388
+ headers: { cookie: sid },
389
+ });
390
+ expect(res2.status).toBe(200);
391
+ const body2 = await res2.json();
392
+ expect(body2.counter).toBe(2);
393
+ });
394
+
395
+ test('MVC Rendering: should compile and render template', async () => {
396
+ const res = await fetch(`${baseUrl}/test/render`);
397
+ expect(res.status).toBe(200);
398
+ expect(res.headers.get('content-type')).toBe('text/html');
399
+ const html = await res.text();
400
+ expect(html).toContain('<h1>Calyx Framework</h1>');
401
+ expect(html).toContain('<p>Hello MVC!</p>');
402
+ });
403
+
404
+ test('Server-Sent Events (SSE): should stream events as text/event-stream', async () => {
405
+ const res = await fetch(`${baseUrl}/test/sse`);
406
+ expect(res.status).toBe(200);
407
+ expect(res.headers.get('content-type')).toBe('text/event-stream');
408
+
409
+ const reader = res.body?.getReader();
410
+ expect(reader).toBeDefined();
411
+
412
+ const decoder = new TextDecoder();
413
+ let text = '';
414
+ while (true) {
415
+ const { done, value } = await reader!.read();
416
+ if (done) break;
417
+ text += decoder.decode(value);
418
+ }
419
+
420
+ expect(text).toContain('id: 0');
421
+ expect(text).toContain('event: message');
422
+ expect(text).toContain('data: {"count":0}');
423
+
424
+ expect(text).toContain('id: 1');
425
+ expect(text).toContain('data: {"count":1}');
426
+
427
+ expect(text).toContain('id: 2');
428
+ expect(text).toContain('data: {"count":2}');
429
+ });
430
+
431
+ test('Database Module: should delegate to real TypeORM repository when available', async () => {
432
+ const mockRepo = {
433
+ save: async (entity: any) => ({ ...entity, id: 999, delegated: true }),
434
+ find: async () => [{ id: 999, name: 'Delegated User', delegated: true }],
435
+ };
436
+ const mockDataSource = {
437
+ getRepository: () => mockRepo,
438
+ };
439
+
440
+ const repo = new Repository(Promise.resolve(mockDataSource), DBUser, false);
441
+
442
+ const saved = await repo.save({ name: 'Bob' } as any);
443
+ expect(saved.id).toBe(999);
444
+ expect(saved.delegated).toBe(true);
445
+
446
+ const list = await repo.find();
447
+ expect(list[0].name).toBe('Delegated User');
448
+ });
449
+
450
+ test('Mongo Module: should delegate to real Mongoose Model when available', async () => {
451
+ const mockMongooseModel = {
452
+ create: async (doc: any) => ({ ...doc, _id: 'mock_mongo_id', delegated: true }),
453
+ find: (conds: any) => ({
454
+ exec: async () => [{ _id: 'mock_mongo_id', name: 'Delegated Cat', delegated: true }]
455
+ }),
456
+ };
457
+ const mockConnection = {
458
+ models: {},
459
+ model: () => mockMongooseModel,
460
+ };
461
+
462
+ const model = new Model(Promise.resolve(mockConnection), 'Cat', {}, false);
463
+
464
+ const created = await model.create({ name: 'Tom' });
465
+ expect(created._id).toBe('mock_mongo_id');
466
+ expect(created.delegated).toBe(true);
467
+
468
+ const list = await model.find().exec();
469
+ expect(list[0].name).toBe('Delegated Cat');
470
+ });
471
+ });