@geekmidas/services 9.0.2 → 10.0.0-alpha.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.
@@ -1,485 +0,0 @@
1
- import type { Logger } from '@geekmidas/logger';
2
- import { ConsoleLogger } from '@geekmidas/logger/console';
3
- import { describe, expect, it, vi } from 'vitest';
4
- import { runWithRequestContext, serviceContext } from '../context';
5
-
6
- /** Minimal spy logger whose `child()` returns itself for easy assertions. */
7
- function makeSpyLogger(): Logger {
8
- const logger: Logger = {
9
- trace: vi.fn(),
10
- debug: vi.fn(),
11
- info: vi.fn(),
12
- warn: vi.fn(),
13
- error: vi.fn(),
14
- fatal: vi.fn(),
15
- child: vi.fn(() => logger),
16
- };
17
- return logger;
18
- }
19
-
20
- /**
21
- * A logger that carries MORE than the base `Logger` interface: an extra method
22
- * (`flush`) and a data property (`level`). Models a richer real-world logger
23
- * (e.g. pino) so we can assert the request-scoped proxy forwards the full
24
- * surface, not just the known log methods.
25
- */
26
- type ExtendedLogger = Logger & {
27
- flush: ReturnType<typeof vi.fn>;
28
- level: string;
29
- };
30
-
31
- function makeExtendedSpyLogger(level = 'info'): ExtendedLogger {
32
- const logger = makeSpyLogger() as ExtendedLogger;
33
- logger.flush = vi.fn();
34
- logger.level = level;
35
- return logger;
36
- }
37
-
38
- describe('Request Context', () => {
39
- const logger = new ConsoleLogger({ app: 'test' });
40
-
41
- describe('serviceContext', () => {
42
- describe('hasContext', () => {
43
- it('should return false outside request context', () => {
44
- expect(serviceContext.hasContext()).toBe(false);
45
- });
46
-
47
- it('should return true inside request context', async () => {
48
- await runWithRequestContext(
49
- { logger, requestId: 'test-id', startTime: Date.now() },
50
- async () => {
51
- expect(serviceContext.hasContext()).toBe(true);
52
- },
53
- );
54
- });
55
- });
56
-
57
- describe('getLogger', () => {
58
- it('should throw outside request context', () => {
59
- expect(() => serviceContext.getLogger()).toThrow(
60
- 'ServiceContext.getLogger() called outside request context',
61
- );
62
- });
63
-
64
- it('should delegate to the current request logger', async () => {
65
- const requestLogger = makeSpyLogger();
66
- await runWithRequestContext(
67
- {
68
- logger: requestLogger,
69
- requestId: 'test-id',
70
- startTime: Date.now(),
71
- },
72
- async () => {
73
- serviceContext.getLogger().info('hello');
74
- },
75
- );
76
- expect(requestLogger.info).toHaveBeenCalledWith('hello');
77
- });
78
-
79
- it('captured-once logger follows each request (singleton service fix)', async () => {
80
- // Mimic a singleton service that grabs the logger ONCE (during its
81
- // one-time register) and reuses that reference for every request.
82
- let captured: Logger | undefined;
83
- const handle = (requestLogger: Logger) =>
84
- runWithRequestContext(
85
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
86
- async () => {
87
- captured ??= serviceContext.getLogger();
88
- captured.info('handled');
89
- },
90
- );
91
-
92
- const first = makeSpyLogger();
93
- const second = makeSpyLogger();
94
- await handle(first);
95
- await handle(second);
96
-
97
- // Before the fix, the captured logger stayed bound to `first`, so
98
- // `second` never saw the call.
99
- expect(first.info).toHaveBeenCalledTimes(1);
100
- expect(second.info).toHaveBeenCalledTimes(1);
101
- });
102
-
103
- it('child loggers also follow the current request', async () => {
104
- let capturedChild: Logger | undefined;
105
- const handle = (requestLogger: Logger) =>
106
- runWithRequestContext(
107
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
108
- async () => {
109
- capturedChild ??= serviceContext
110
- .getLogger()
111
- .child({ scope: 'svc' });
112
- capturedChild.info('scoped');
113
- },
114
- );
115
-
116
- const first = makeSpyLogger();
117
- const second = makeSpyLogger();
118
- await handle(first);
119
- await handle(second);
120
-
121
- expect(first.child).toHaveBeenCalledWith({ scope: 'svc' });
122
- expect(second.child).toHaveBeenCalledWith({ scope: 'svc' });
123
- expect(first.info).toHaveBeenCalledWith('scoped');
124
- expect(second.info).toHaveBeenCalledWith('scoped');
125
- });
126
-
127
- describe('forwards the full logger surface (logger with more)', () => {
128
- it('forwards an extra method beyond the Logger interface', async () => {
129
- const requestLogger = makeExtendedSpyLogger();
130
- await runWithRequestContext(
131
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
132
- async () => {
133
- (serviceContext.getLogger() as ExtendedLogger).flush();
134
- },
135
- );
136
- expect(requestLogger.flush).toHaveBeenCalledTimes(1);
137
- });
138
-
139
- it('re-resolves an extra method per request when captured once', async () => {
140
- let captured: ExtendedLogger | undefined;
141
- const handle = (requestLogger: Logger) =>
142
- runWithRequestContext(
143
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
144
- async () => {
145
- captured ??= serviceContext.getLogger() as ExtendedLogger;
146
- captured.flush();
147
- },
148
- );
149
-
150
- const first = makeExtendedSpyLogger();
151
- const second = makeExtendedSpyLogger();
152
- await handle(first);
153
- await handle(second);
154
-
155
- expect(first.flush).toHaveBeenCalledTimes(1);
156
- expect(second.flush).toHaveBeenCalledTimes(1);
157
- });
158
-
159
- it('forwards a data property as the current request logger value', async () => {
160
- const captureLevel = (requestLogger: Logger) =>
161
- runWithRequestContext(
162
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
163
- async () => (serviceContext.getLogger() as ExtendedLogger).level,
164
- );
165
-
166
- const debugLogger = makeExtendedSpyLogger('debug');
167
- const warnLogger = makeExtendedSpyLogger('warn');
168
-
169
- expect(await captureLevel(debugLogger)).toBe('debug');
170
- expect(await captureLevel(warnLogger)).toBe('warn');
171
- });
172
-
173
- it('detached method reference still targets the current request', async () => {
174
- const requestLogger = makeExtendedSpyLogger();
175
- await runWithRequestContext(
176
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
177
- async () => {
178
- const { info } = serviceContext.getLogger();
179
- info('detached');
180
- },
181
- );
182
- expect(requestLogger.info).toHaveBeenCalledWith('detached');
183
- });
184
-
185
- it('invokes log methods with the logger as `this` (pino receiver)', async () => {
186
- // Real pino reads internal state off the receiver, e.g.
187
- // `this[Symbol(pino.msgPrefix)]`. A logger whose methods depend
188
- // on `this` must still work through the proxy — calling them
189
- // unbound throws "Cannot read properties of undefined".
190
- const received: unknown[] = [];
191
- const requestLogger = {
192
- secret: 'pino-state',
193
- info(this: { secret: string }, msg: string) {
194
- // Throws if `this` is undefined (the original bug).
195
- received.push(`${this.secret}:${msg}`);
196
- },
197
- child() {
198
- return requestLogger;
199
- },
200
- } as unknown as Logger;
201
-
202
- await runWithRequestContext(
203
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
204
- async () => {
205
- // Both direct and detached calls must keep the receiver.
206
- serviceContext.getLogger().info('direct');
207
- const { info } = serviceContext.getLogger();
208
- info('detached');
209
- },
210
- );
211
-
212
- expect(received).toEqual([
213
- 'pino-state:direct',
214
- 'pino-state:detached',
215
- ]);
216
- });
217
-
218
- it('reflects underlying membership via the `in` operator', async () => {
219
- const requestLogger = makeExtendedSpyLogger();
220
- await runWithRequestContext(
221
- { logger: requestLogger, requestId: 'r', startTime: Date.now() },
222
- async () => {
223
- const proxy = serviceContext.getLogger();
224
- expect('flush' in proxy).toBe(true);
225
- expect('child' in proxy).toBe(true);
226
- expect('nope' in proxy).toBe(false);
227
- },
228
- );
229
- });
230
-
231
- it('is not thenable (safe to return from async / await)', async () => {
232
- await runWithRequestContext(
233
- {
234
- logger: makeExtendedSpyLogger(),
235
- requestId: 'r',
236
- startTime: Date.now(),
237
- },
238
- async () => {
239
- const proxy = serviceContext.getLogger();
240
- expect((proxy as { then?: unknown }).then).toBeUndefined();
241
- // Awaiting a non-thenable yields the value itself rather than
242
- // hanging or invoking a spurious `then`.
243
- expect(await proxy).toBe(proxy);
244
- },
245
- );
246
- });
247
- });
248
- });
249
-
250
- describe('getRequestId', () => {
251
- it('should throw outside request context', () => {
252
- expect(() => serviceContext.getRequestId()).toThrow(
253
- 'ServiceContext.getRequestId() called outside request context',
254
- );
255
- });
256
-
257
- it('should return requestId inside request context', async () => {
258
- await runWithRequestContext(
259
- { logger, requestId: 'my-request-id-123', startTime: Date.now() },
260
- async () => {
261
- expect(serviceContext.getRequestId()).toBe('my-request-id-123');
262
- },
263
- );
264
- });
265
- });
266
-
267
- describe('getRequestStartTime', () => {
268
- it('should throw outside request context', () => {
269
- expect(() => serviceContext.getRequestStartTime()).toThrow(
270
- 'ServiceContext.getRequestStartTime() called outside request context',
271
- );
272
- });
273
-
274
- it('should return startTime inside request context', async () => {
275
- const startTime = Date.now();
276
- await runWithRequestContext(
277
- { logger, requestId: 'test-id', startTime },
278
- async () => {
279
- expect(serviceContext.getRequestStartTime()).toBe(startTime);
280
- },
281
- );
282
- });
283
- });
284
- });
285
-
286
- describe('runWithRequestContext', () => {
287
- it('should return synchronous value', () => {
288
- const result = runWithRequestContext(
289
- { logger, requestId: 'test-id', startTime: Date.now() },
290
- () => 'sync-result',
291
- );
292
-
293
- expect(result).toBe('sync-result');
294
- });
295
-
296
- it('should return async value', async () => {
297
- const result = await runWithRequestContext(
298
- { logger, requestId: 'test-id', startTime: Date.now() },
299
- async () => {
300
- await new Promise((resolve) => setTimeout(resolve, 10));
301
- return 'async-result';
302
- },
303
- );
304
-
305
- expect(result).toBe('async-result');
306
- });
307
-
308
- it('should provide isolated context per run', async () => {
309
- const results: string[] = [];
310
-
311
- await Promise.all([
312
- runWithRequestContext(
313
- { logger, requestId: 'request-1', startTime: Date.now() },
314
- async () => {
315
- await new Promise((resolve) => setTimeout(resolve, 10));
316
- results.push(`1:${serviceContext.getRequestId()}`);
317
- },
318
- ),
319
- runWithRequestContext(
320
- { logger, requestId: 'request-2', startTime: Date.now() },
321
- async () => {
322
- await new Promise((resolve) => setTimeout(resolve, 5));
323
- results.push(`2:${serviceContext.getRequestId()}`);
324
- },
325
- ),
326
- runWithRequestContext(
327
- { logger, requestId: 'request-3', startTime: Date.now() },
328
- async () => {
329
- results.push(`3:${serviceContext.getRequestId()}`);
330
- },
331
- ),
332
- ]);
333
-
334
- // Each request should have its own isolated context
335
- expect(results).toContain('1:request-1');
336
- expect(results).toContain('2:request-2');
337
- expect(results).toContain('3:request-3');
338
- });
339
-
340
- it('should propagate context through async operations', async () => {
341
- const capturedIds: string[] = [];
342
-
343
- async function nestedOperation() {
344
- // Should still have access to context from parent
345
- capturedIds.push(serviceContext.getRequestId());
346
- await new Promise((resolve) => setTimeout(resolve, 1));
347
- capturedIds.push(serviceContext.getRequestId());
348
- }
349
-
350
- await runWithRequestContext(
351
- { logger, requestId: 'parent-context', startTime: Date.now() },
352
- async () => {
353
- capturedIds.push(serviceContext.getRequestId());
354
- await nestedOperation();
355
- capturedIds.push(serviceContext.getRequestId());
356
- },
357
- );
358
-
359
- // All operations should see the same context
360
- expect(capturedIds).toEqual([
361
- 'parent-context',
362
- 'parent-context',
363
- 'parent-context',
364
- 'parent-context',
365
- ]);
366
- });
367
-
368
- it('should handle errors while preserving context', async () => {
369
- let capturedIdBeforeError: string | undefined;
370
- let capturedIdInCatch: string | undefined;
371
-
372
- await runWithRequestContext(
373
- { logger, requestId: 'error-context', startTime: Date.now() },
374
- async () => {
375
- capturedIdBeforeError = serviceContext.getRequestId();
376
- try {
377
- throw new Error('Test error');
378
- } catch {
379
- capturedIdInCatch = serviceContext.getRequestId();
380
- }
381
- },
382
- );
383
-
384
- expect(capturedIdBeforeError).toBe('error-context');
385
- expect(capturedIdInCatch).toBe('error-context');
386
- });
387
-
388
- it('should propagate exceptions', async () => {
389
- await expect(
390
- runWithRequestContext(
391
- { logger, requestId: 'test-id', startTime: Date.now() },
392
- async () => {
393
- throw new Error('Test exception');
394
- },
395
- ),
396
- ).rejects.toThrow('Test exception');
397
- });
398
-
399
- it('should allow nested runWithRequestContext calls', async () => {
400
- const capturedIds: string[] = [];
401
-
402
- await runWithRequestContext(
403
- { logger, requestId: 'outer', startTime: Date.now() },
404
- async () => {
405
- capturedIds.push(serviceContext.getRequestId());
406
-
407
- // Nested context should override
408
- await runWithRequestContext(
409
- { logger, requestId: 'inner', startTime: Date.now() },
410
- async () => {
411
- capturedIds.push(serviceContext.getRequestId());
412
- },
413
- );
414
-
415
- // Should return to outer context
416
- capturedIds.push(serviceContext.getRequestId());
417
- },
418
- );
419
-
420
- expect(capturedIds).toEqual(['outer', 'inner', 'outer']);
421
- });
422
- });
423
-
424
- describe('Integration with services', () => {
425
- it('should allow services to access context', async () => {
426
- // Simulating a service that accesses context
427
- const myService = {
428
- getRequestInfo() {
429
- if (!serviceContext.hasContext()) {
430
- return null;
431
- }
432
- return {
433
- requestId: serviceContext.getRequestId(),
434
- startTime: serviceContext.getRequestStartTime(),
435
- };
436
- },
437
- };
438
-
439
- // Outside context
440
- expect(myService.getRequestInfo()).toBeNull();
441
-
442
- // Inside context
443
- const startTime = Date.now();
444
- await runWithRequestContext(
445
- { logger, requestId: 'service-request', startTime },
446
- async () => {
447
- const info = myService.getRequestInfo();
448
- expect(info).not.toBeNull();
449
- expect(info?.requestId).toBe('service-request');
450
- expect(info?.startTime).toBe(startTime);
451
- },
452
- );
453
- });
454
-
455
- it('should allow logger access for request-scoped logging', async () => {
456
- const logMessages: string[] = [];
457
- const requestLogger = new ConsoleLogger({
458
- app: 'test',
459
- });
460
-
461
- // Override info method to capture logs
462
- const originalInfo = requestLogger.info.bind(requestLogger);
463
- requestLogger.info = (...args: Parameters<typeof requestLogger.info>) => {
464
- logMessages.push(
465
- typeof args[0] === 'string' ? args[0] : JSON.stringify(args[0]),
466
- );
467
- return originalInfo(...args);
468
- };
469
-
470
- await runWithRequestContext(
471
- {
472
- logger: requestLogger,
473
- requestId: 'log-test',
474
- startTime: Date.now(),
475
- },
476
- async () => {
477
- const contextLogger = serviceContext.getLogger();
478
- contextLogger.info('Request started');
479
- },
480
- );
481
-
482
- expect(logMessages).toContain('Request started');
483
- });
484
- });
485
- });