@martel/calyx 1.8.0 → 1.10.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 +15 -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 +401 -57
  20. package/src/http/application.ts +434 -74
  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 +245 -6
  40. package/tests/openapi.test.ts +78 -11
  41. package/tests/techniques.test.ts +471 -0
@@ -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
+ });