@tumbaland/backend-core 1.16.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 (113) hide show
  1. package/.versionrc.json +7 -0
  2. package/README.md +179 -0
  3. package/__mocks__/uuid.js +8 -0
  4. package/dist/app/createBaseApp.d.ts +44 -0
  5. package/dist/app/createBaseApp.d.ts.map +1 -0
  6. package/dist/app/createBaseApp.js +54 -0
  7. package/dist/app/createBaseApp.js.map +1 -0
  8. package/dist/config/env.d.ts +8 -0
  9. package/dist/config/env.d.ts.map +1 -0
  10. package/dist/config/env.js +17 -0
  11. package/dist/config/env.js.map +1 -0
  12. package/dist/database/connection.d.ts +3 -0
  13. package/dist/database/connection.d.ts.map +1 -0
  14. package/dist/database/connection.js +49 -0
  15. package/dist/database/connection.js.map +1 -0
  16. package/dist/errors/HttpError.d.ts +32 -0
  17. package/dist/errors/HttpError.d.ts.map +1 -0
  18. package/dist/errors/HttpError.js +61 -0
  19. package/dist/errors/HttpError.js.map +1 -0
  20. package/dist/health/healthController.d.ts +21 -0
  21. package/dist/health/healthController.d.ts.map +1 -0
  22. package/dist/health/healthController.js +56 -0
  23. package/dist/health/healthController.js.map +1 -0
  24. package/dist/index.d.ts +20 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +81 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/logging/logger.d.ts +4 -0
  29. package/dist/logging/logger.d.ts.map +1 -0
  30. package/dist/logging/logger.js +78 -0
  31. package/dist/logging/logger.js.map +1 -0
  32. package/dist/metrics/index.d.ts +17 -0
  33. package/dist/metrics/index.d.ts.map +1 -0
  34. package/dist/metrics/index.js +99 -0
  35. package/dist/metrics/index.js.map +1 -0
  36. package/dist/middleware/authMiddleware.d.ts +9 -0
  37. package/dist/middleware/authMiddleware.d.ts.map +1 -0
  38. package/dist/middleware/authMiddleware.js +32 -0
  39. package/dist/middleware/authMiddleware.js.map +1 -0
  40. package/dist/middleware/corsMiddleware.d.ts +20 -0
  41. package/dist/middleware/corsMiddleware.d.ts.map +1 -0
  42. package/dist/middleware/corsMiddleware.js +33 -0
  43. package/dist/middleware/corsMiddleware.js.map +1 -0
  44. package/dist/middleware/errorHandler.d.ts +14 -0
  45. package/dist/middleware/errorHandler.d.ts.map +1 -0
  46. package/dist/middleware/errorHandler.js +88 -0
  47. package/dist/middleware/errorHandler.js.map +1 -0
  48. package/dist/middleware/requestLogger.d.ts +16 -0
  49. package/dist/middleware/requestLogger.d.ts.map +1 -0
  50. package/dist/middleware/requestLogger.js +49 -0
  51. package/dist/middleware/requestLogger.js.map +1 -0
  52. package/dist/middleware/security.d.ts +26 -0
  53. package/dist/middleware/security.d.ts.map +1 -0
  54. package/dist/middleware/security.js +47 -0
  55. package/dist/middleware/security.js.map +1 -0
  56. package/dist/middleware/validate.d.ts +11 -0
  57. package/dist/middleware/validate.d.ts.map +1 -0
  58. package/dist/middleware/validate.js +24 -0
  59. package/dist/middleware/validate.js.map +1 -0
  60. package/dist/tracing/index.d.ts +13 -0
  61. package/dist/tracing/index.d.ts.map +1 -0
  62. package/dist/tracing/index.js +89 -0
  63. package/dist/tracing/index.js.map +1 -0
  64. package/dist/types/auth.d.ts +32 -0
  65. package/dist/types/auth.d.ts.map +1 -0
  66. package/dist/types/auth.js +3 -0
  67. package/dist/types/auth.js.map +1 -0
  68. package/dist/utils/correlation.d.ts +11 -0
  69. package/dist/utils/correlation.d.ts.map +1 -0
  70. package/dist/utils/correlation.js +23 -0
  71. package/dist/utils/correlation.js.map +1 -0
  72. package/dist/utils/response.d.ts +24 -0
  73. package/dist/utils/response.d.ts.map +1 -0
  74. package/dist/utils/response.js +35 -0
  75. package/dist/utils/response.js.map +1 -0
  76. package/jest.config.js +14 -0
  77. package/package.json +61 -0
  78. package/src/app/createBaseApp.test.ts +69 -0
  79. package/src/app/createBaseApp.ts +75 -0
  80. package/src/config/env.test.ts +28 -0
  81. package/src/config/env.ts +13 -0
  82. package/src/database/connection.test.ts +142 -0
  83. package/src/database/connection.ts +46 -0
  84. package/src/errors/HttpError.test.ts +38 -0
  85. package/src/errors/HttpError.ts +57 -0
  86. package/src/health/healthController.test.ts +91 -0
  87. package/src/health/healthController.ts +56 -0
  88. package/src/index.ts +60 -0
  89. package/src/logging/logger.test.ts +76 -0
  90. package/src/logging/logger.ts +103 -0
  91. package/src/metrics/index.test.ts +91 -0
  92. package/src/metrics/index.ts +110 -0
  93. package/src/middleware/authMiddleware.test.ts +104 -0
  94. package/src/middleware/authMiddleware.ts +29 -0
  95. package/src/middleware/corsMiddleware.test.ts +58 -0
  96. package/src/middleware/corsMiddleware.ts +36 -0
  97. package/src/middleware/errorHandler.test.ts +146 -0
  98. package/src/middleware/errorHandler.ts +98 -0
  99. package/src/middleware/requestLogger.test.ts +81 -0
  100. package/src/middleware/requestLogger.ts +47 -0
  101. package/src/middleware/security.test.ts +45 -0
  102. package/src/middleware/security.ts +43 -0
  103. package/src/middleware/validate.test.ts +60 -0
  104. package/src/middleware/validate.ts +23 -0
  105. package/src/tracing/index.test.ts +250 -0
  106. package/src/tracing/index.ts +97 -0
  107. package/src/types/auth.ts +33 -0
  108. package/src/utils/correlation.test.ts +47 -0
  109. package/src/utils/correlation.ts +22 -0
  110. package/src/utils/response.test.ts +64 -0
  111. package/src/utils/response.ts +60 -0
  112. package/tsconfig.build.json +4 -0
  113. package/tsconfig.json +20 -0
@@ -0,0 +1,98 @@
1
+ import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
2
+ import logger from '../logging/logger';
3
+ import { HttpError } from '../errors/HttpError';
4
+
5
+ interface KnownError {
6
+ statusCode: number;
7
+ name: string;
8
+ message: string;
9
+ }
10
+
11
+ /**
12
+ * Recognizes error shapes that map to an obvious HTTP status without a
13
+ * controller having to catch and re-throw them: our own typed HttpError
14
+ * hierarchy, plus Mongoose's ValidationError (failed schema validation,
15
+ * e.g. a missing required field) and CastError (e.g. an invalid ObjectId
16
+ * string) — both of which are client mistakes, not server bugs, but throw
17
+ * as plain Mongoose error classes rather than anything of ours.
18
+ */
19
+ function classifyError(error: Error): KnownError | null {
20
+ if (error instanceof HttpError) {
21
+ return { statusCode: error.statusCode, name: error.name, message: error.message };
22
+ }
23
+ if (error.name === 'ValidationError' || error.name === 'CastError') {
24
+ return { statusCode: 400, name: error.name, message: error.message };
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * Global error handler middleware.
31
+ *
32
+ * Controllers throw a typed `HttpError` (BadRequestError, NotFoundError,
33
+ * ForbiddenError, ...) instead of hand-rolling try/catch + string-matching
34
+ * on `error.message` to pick a status code — Express 5 forwards rejected
35
+ * promises from async route handlers here automatically. Anything that
36
+ * isn't a recognized error type is treated as an unexpected bug: logged
37
+ * with its full stack and returned as a generic 500 (no message leak
38
+ * outside development), same as before this file knew about HttpError.
39
+ */
40
+ export const errorHandler: ErrorRequestHandler = (
41
+ error: Error,
42
+ req: Request,
43
+ res: Response,
44
+ _next: NextFunction
45
+ ): void => {
46
+ const known = classifyError(error);
47
+
48
+ if (known) {
49
+ // Client errors (4xx) are routine, expected control flow — log without
50
+ // a stack trace at warn level, not as if the service were broken.
51
+ logger.warn('Request failed:', {
52
+ error: known.message,
53
+ statusCode: known.statusCode,
54
+ url: req.url,
55
+ method: req.method,
56
+ correlationId: (req as any).correlationId
57
+ });
58
+ } else {
59
+ logger.error('Unhandled error:', {
60
+ error: error.message,
61
+ stack: error.stack,
62
+ url: req.url,
63
+ method: req.method,
64
+ ip: req.ip,
65
+ userAgent: req.get('User-Agent'),
66
+ correlationId: (req as any).correlationId
67
+ });
68
+ }
69
+
70
+ // Add CORS headers to error responses
71
+ const origin = req.headers.origin;
72
+ if (origin && (origin.includes('localhost') || origin.includes('tumbaland.eu'))) {
73
+ res.header('Access-Control-Allow-Origin', origin);
74
+ res.header('Access-Control-Allow-Credentials', 'true');
75
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
76
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-correlation-id, x-session-id');
77
+ }
78
+
79
+ if (known) {
80
+ res.status(known.statusCode).json({
81
+ success: false,
82
+ error: known.name,
83
+ message: known.message
84
+ });
85
+ return;
86
+ }
87
+
88
+ // Don't leak error details in production for unexpected errors
89
+ const isDevelopment = process.env.NODE_ENV === 'development';
90
+ const errorMessage = isDevelopment ? error.message : 'Something went wrong';
91
+
92
+ res.status(500).json({
93
+ success: false,
94
+ error: 'Internal server error',
95
+ message: errorMessage,
96
+ ...(isDevelopment && { stack: error.stack })
97
+ });
98
+ };
@@ -0,0 +1,81 @@
1
+ jest.mock('../logging/logger', () => ({
2
+ __esModule: true,
3
+ default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
4
+ }));
5
+
6
+ import logger from '../logging/logger';
7
+ import { requestLogger, requestLoggerWithMetrics, simpleRequestLogger } from './requestLogger';
8
+
9
+ function mockReqRes() {
10
+ const req: any = {
11
+ method: 'GET',
12
+ url: '/albums/123',
13
+ correlationId: 'corr-1',
14
+ ip: '127.0.0.1',
15
+ get: jest.fn().mockReturnValue('test-agent')
16
+ };
17
+ const res: any = { statusCode: 200, on: jest.fn() };
18
+ return { req, res };
19
+ }
20
+
21
+ describe('simpleRequestLogger', () => {
22
+ beforeEach(() => jest.clearAllMocks());
23
+
24
+ it('logs an inbound request line with correlation id, ip, and user agent', () => {
25
+ const { req, res } = mockReqRes();
26
+ const next = jest.fn();
27
+
28
+ simpleRequestLogger(req, res, next);
29
+
30
+ expect(logger.info).toHaveBeenCalledWith('→ GET /albums/123', {
31
+ correlationId: 'corr-1',
32
+ ip: '127.0.0.1',
33
+ userAgent: 'test-agent'
34
+ });
35
+ expect(next).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('falls back to "unknown" correlation id when none was set on the request', () => {
39
+ const { req, res } = mockReqRes();
40
+ delete req.correlationId;
41
+ const next = jest.fn();
42
+
43
+ simpleRequestLogger(req, res, next);
44
+
45
+ expect(logger.info).toHaveBeenCalledWith(
46
+ expect.any(String),
47
+ expect.objectContaining({ correlationId: 'unknown' })
48
+ );
49
+ });
50
+
51
+ it('logs the outbound response line with status and duration once the response finishes', () => {
52
+ const { req, res } = mockReqRes();
53
+ const next = jest.fn();
54
+
55
+ simpleRequestLogger(req, res, next);
56
+
57
+ expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
58
+ const finishHandler = res.on.mock.calls.find((c: any[]) => c[0] === 'finish')[1];
59
+ res.statusCode = 200;
60
+ finishHandler();
61
+
62
+ expect(logger.info).toHaveBeenCalledWith(
63
+ expect.stringMatching(/^← GET \/albums\/123 200 \d+ms$/),
64
+ expect.objectContaining({ correlationId: 'corr-1', statusCode: 200 })
65
+ );
66
+ });
67
+ });
68
+
69
+ describe('requestLogger', () => {
70
+ it('is a Morgan middleware function', () => {
71
+ expect(typeof requestLogger).toBe('function');
72
+ });
73
+ });
74
+
75
+ describe('requestLoggerWithMetrics', () => {
76
+ it('bundles requestLogger and metricsMiddleware as a two-item array', () => {
77
+ expect(requestLoggerWithMetrics).toHaveLength(2);
78
+ expect(requestLoggerWithMetrics[0]).toBe(requestLogger);
79
+ expect(typeof requestLoggerWithMetrics[1]).toBe('function');
80
+ });
81
+ });
@@ -0,0 +1,47 @@
1
+ import morgan from 'morgan';
2
+ import logger from '../logging/logger';
3
+ import { metricsMiddleware } from '../metrics';
4
+
5
+ /**
6
+ * Morgan middleware configured to use Winston logger
7
+ * Logs HTTP requests in structured format
8
+ */
9
+ export const requestLogger = morgan('combined', {
10
+ stream: {
11
+ write: (message: string) => {
12
+ logger.http(message.trim());
13
+ }
14
+ }
15
+ });
16
+
17
+ /**
18
+ * Combined middleware that includes both logging and metrics
19
+ * Use this instead of separate requestLogger and metricsMiddleware
20
+ */
21
+ export const requestLoggerWithMetrics = [requestLogger, metricsMiddleware];
22
+
23
+ /**
24
+ * Simple request logger for development
25
+ * Logs basic request info with correlation ID
26
+ */
27
+ export const simpleRequestLogger = (req: any, res: any, next: any) => {
28
+ const start = Date.now();
29
+ const correlationId = req.correlationId || 'unknown';
30
+
31
+ logger.info(`→ ${req.method} ${req.url}`, {
32
+ correlationId,
33
+ ip: req.ip,
34
+ userAgent: req.get('User-Agent')
35
+ });
36
+
37
+ res.on('finish', () => {
38
+ const duration = Date.now() - start;
39
+ logger.info(`← ${req.method} ${req.url} ${res.statusCode} ${duration}ms`, {
40
+ correlationId,
41
+ statusCode: res.statusCode,
42
+ duration
43
+ });
44
+ });
45
+
46
+ next();
47
+ };
@@ -0,0 +1,45 @@
1
+ import express from 'express';
2
+ import request from 'supertest';
3
+ import { securityHeaders, createRateLimiter } from './security';
4
+
5
+ describe('securityHeaders', () => {
6
+ const app = express();
7
+ app.use(securityHeaders);
8
+ app.get('/ping', (_req, res) => res.json({ ok: true }));
9
+
10
+ it('sets standard helmet protections', async () => {
11
+ const res = await request(app).get('/ping');
12
+
13
+ expect(res.headers['x-content-type-options']).toBe('nosniff');
14
+ expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
15
+ });
16
+
17
+ it('relaxes cross-origin-resource-policy so other-origin frontends can read the response', async () => {
18
+ const res = await request(app).get('/ping');
19
+
20
+ expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin');
21
+ });
22
+
23
+ it('does not set a Content-Security-Policy header (JSON API, not HTML)', async () => {
24
+ const res = await request(app).get('/ping');
25
+
26
+ expect(res.headers['content-security-policy']).toBeUndefined();
27
+ });
28
+ });
29
+
30
+ describe('createRateLimiter', () => {
31
+ it('allows requests under the limit and blocks once the limit is exceeded', async () => {
32
+ const app = express();
33
+ app.use(createRateLimiter({ windowMs: 60_000, max: 2 }));
34
+ app.get('/ping', (_req, res) => res.json({ ok: true }));
35
+
36
+ const first = await request(app).get('/ping');
37
+ const second = await request(app).get('/ping');
38
+ const third = await request(app).get('/ping');
39
+
40
+ expect(first.status).toBe(200);
41
+ expect(second.status).toBe(200);
42
+ expect(third.status).toBe(429);
43
+ expect(third.body).toEqual({ success: false, message: 'Too many requests, please try again later' });
44
+ });
45
+ });
@@ -0,0 +1,43 @@
1
+ import helmet from 'helmet';
2
+ import rateLimit, { Options as RateLimitOptions } from 'express-rate-limit';
3
+
4
+ /**
5
+ * Standard security headers (helmet) for all services.
6
+ *
7
+ * - `contentSecurityPolicy` is off: these are JSON APIs, not HTML apps, and a
8
+ * couple of services serve a static Swagger docs page under `/api/docs`
9
+ * whose inline scripts a default CSP would break.
10
+ * - `crossOriginResourcePolicy` is relaxed to `cross-origin`: helmet's default
11
+ * (`same-origin`) makes browsers refuse to read the response from a
12
+ * different origin — which every frontend is, since each front and backend
13
+ * service runs on its own port. CORS (see `createCorsMiddleware`) already
14
+ * does origin enforcement; this header must not fight it.
15
+ */
16
+ export const securityHeaders = helmet({
17
+ contentSecurityPolicy: false,
18
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
19
+ crossOriginEmbedderPolicy: false
20
+ });
21
+
22
+ /**
23
+ * Build a rate limiter with sane JSON error output. Counts per client IP.
24
+ */
25
+ export function createRateLimiter(options: Partial<RateLimitOptions> = {}) {
26
+ return rateLimit({
27
+ windowMs: 15 * 60 * 1000,
28
+ max: 300,
29
+ standardHeaders: true,
30
+ legacyHeaders: false,
31
+ message: { success: false, message: 'Too many requests, please try again later' },
32
+ ...options
33
+ });
34
+ }
35
+
36
+ /** Baseline limiter for authenticated/general routes. */
37
+ export const standardRateLimiter = createRateLimiter();
38
+
39
+ /**
40
+ * Tighter limiter for brute-force-prone unauthenticated routes (token
41
+ * issuance, public endpoints with no auth in front of them).
42
+ */
43
+ export const strictRateLimiter = createRateLimiter({ max: 20 });
@@ -0,0 +1,60 @@
1
+ import express from 'express';
2
+ import request from 'supertest';
3
+ import { z } from 'zod';
4
+ import { validate } from './validate';
5
+ import { errorHandler } from './errorHandler';
6
+
7
+ function buildApp(schema: z.ZodType) {
8
+ const app = express();
9
+ app.use(express.json());
10
+ app.post('/things', validate(schema), (req, res) => {
11
+ res.json({ success: true, data: req.body });
12
+ });
13
+ app.use(errorHandler);
14
+ return app;
15
+ }
16
+
17
+ describe('validate', () => {
18
+ const schema = z.object({
19
+ name: z.string().min(1),
20
+ age: z.number().int().positive().optional()
21
+ });
22
+
23
+ it('calls the handler with the parsed body when validation passes', async () => {
24
+ const app = buildApp(schema);
25
+
26
+ const res = await request(app).post('/things').send({ name: 'Alice', age: 30 });
27
+
28
+ expect(res.status).toBe(200);
29
+ expect(res.body).toEqual({ success: true, data: { name: 'Alice', age: 30 } });
30
+ });
31
+
32
+ it('responds 400 via the shared errorHandler when a required field is missing', async () => {
33
+ const app = buildApp(schema);
34
+
35
+ const res = await request(app).post('/things').send({});
36
+
37
+ expect(res.status).toBe(400);
38
+ expect(res.body.success).toBe(false);
39
+ expect(res.body.error).toBe('BadRequestError');
40
+ expect(res.body.message).toContain('name');
41
+ });
42
+
43
+ it('responds 400 when a field has the wrong type', async () => {
44
+ const app = buildApp(schema);
45
+
46
+ const res = await request(app).post('/things').send({ name: 'Bob', age: 'not-a-number' });
47
+
48
+ expect(res.status).toBe(400);
49
+ expect(res.body.error).toBe('BadRequestError');
50
+ });
51
+
52
+ it('strips unknown fields not declared in the schema (zod default behavior)', async () => {
53
+ const app = buildApp(schema);
54
+
55
+ const res = await request(app).post('/things').send({ name: 'Carol', extra: 'nope' });
56
+
57
+ expect(res.status).toBe(200);
58
+ expect(res.body.data).toEqual({ name: 'Carol' });
59
+ });
60
+ });
@@ -0,0 +1,23 @@
1
+ import { Request, Response, NextFunction, RequestHandler } from 'express';
2
+ import { ZodType } from 'zod';
3
+ import { BadRequestError } from '../errors/HttpError';
4
+
5
+ /**
6
+ * Validates `req.body` against a zod schema before the route handler runs.
7
+ * On failure, throws a `BadRequestError` (caught by the shared errorHandler)
8
+ * instead of letting controllers hand-roll their own field checks. On
9
+ * success, `req.body` is replaced with the parsed result so handlers get
10
+ * zod's inferred, coerced types rather than raw untyped input.
11
+ */
12
+ export function validate(schema: ZodType): RequestHandler {
13
+ return (req: Request, _res: Response, next: NextFunction): void => {
14
+ const result = schema.safeParse(req.body);
15
+ if (!result.success) {
16
+ const issue = result.error.issues[0];
17
+ const field = issue.path.length ? `${issue.path.join('.')}: ` : '';
18
+ throw new BadRequestError(`${field}${issue.message}`);
19
+ }
20
+ req.body = result.data;
21
+ next();
22
+ };
23
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The real OpenTelemetry SDK/exporter/provider are mocked out — they're
3
+ * network- and global-registry-heavy, and createBaseApp.test.ts already
4
+ * exercises the real tracingMiddleware happy path end-to-end via supertest.
5
+ * This file focuses on this module's own branching logic (env-var
6
+ * resolution, parent-span-vs-context handling, the helper wrappers), using
7
+ * the real (side-effect-free) @opentelemetry/api for trace/context/propagation.
8
+ */
9
+ const mockSpan = () => ({
10
+ setAttribute: jest.fn(),
11
+ addEvent: jest.fn(),
12
+ end: jest.fn(),
13
+ spanContext: jest.fn(() => ({ traceId: 't', spanId: 's', traceFlags: 1 }))
14
+ });
15
+
16
+ const mockTracer = { startSpan: jest.fn(() => mockSpan()) };
17
+ const registerMock = jest.fn();
18
+ const getTracerMock = jest.fn(() => mockTracer);
19
+ const providerCtorMock = jest.fn().mockImplementation((opts: unknown) => ({
20
+ __opts: opts,
21
+ register: registerMock,
22
+ getTracer: getTracerMock
23
+ }));
24
+ const exporterCtorMock = jest.fn().mockImplementation((opts: unknown) => ({ __opts: opts }));
25
+ const spanProcessorCtorMock = jest.fn();
26
+ const resourceFromAttributesMock = jest.fn((attrs: unknown) => attrs);
27
+
28
+ jest.mock('@opentelemetry/sdk-trace-node', () => ({
29
+ NodeTracerProvider: providerCtorMock,
30
+ BatchSpanProcessor: spanProcessorCtorMock
31
+ }));
32
+
33
+ jest.mock('@opentelemetry/exporter-trace-otlp-http', () => ({
34
+ OTLPTraceExporter: exporterCtorMock
35
+ }));
36
+
37
+ jest.mock('@opentelemetry/resources', () => ({
38
+ resourceFromAttributes: resourceFromAttributesMock
39
+ }));
40
+
41
+ import { context } from '@opentelemetry/api';
42
+
43
+ async function loadTracing() {
44
+ let tracing!: typeof import('./index');
45
+ await jest.isolateModulesAsync(async () => {
46
+ tracing = await import('./index');
47
+ });
48
+ return tracing;
49
+ }
50
+
51
+ beforeEach(() => {
52
+ jest.clearAllMocks();
53
+ });
54
+
55
+ describe('initTracer', () => {
56
+ const ORIGINAL_ENV = process.env;
57
+
58
+ beforeEach(() => {
59
+ process.env = { ...ORIGINAL_ENV };
60
+ delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
61
+ delete process.env.JAEGER_ENDPOINT;
62
+ });
63
+
64
+ afterAll(() => {
65
+ process.env = ORIGINAL_ENV;
66
+ });
67
+
68
+ it('defaults to the in-cluster collector endpoint when no env var is set', async () => {
69
+ const { initTracer } = await loadTracing();
70
+
71
+ initTracer('album-service');
72
+
73
+ expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://jaeger-collector:4318/v1/traces' });
74
+ });
75
+
76
+ it('prefers OTEL_EXPORTER_OTLP_TRACES_ENDPOINT over JAEGER_ENDPOINT', async () => {
77
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://otel-endpoint/v1/traces';
78
+ process.env.JAEGER_ENDPOINT = 'http://jaeger-endpoint/v1/traces';
79
+ const { initTracer } = await loadTracing();
80
+
81
+ initTracer('album-service');
82
+
83
+ expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://otel-endpoint/v1/traces' });
84
+ });
85
+
86
+ it('falls back to JAEGER_ENDPOINT when OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is unset', async () => {
87
+ process.env.JAEGER_ENDPOINT = 'http://jaeger-endpoint/v1/traces';
88
+ const { initTracer } = await loadTracing();
89
+
90
+ initTracer('album-service');
91
+
92
+ expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://jaeger-endpoint/v1/traces' });
93
+ });
94
+
95
+ it('registers the provider and returns a tracer scoped to the given service name', async () => {
96
+ const { initTracer } = await loadTracing();
97
+
98
+ const tracer = initTracer('album-service');
99
+
100
+ expect(registerMock).toHaveBeenCalledTimes(1);
101
+ expect(getTracerMock).toHaveBeenCalledWith('album-service');
102
+ expect(tracer).toBe(mockTracer);
103
+ });
104
+ });
105
+
106
+ describe('getTracer', () => {
107
+ it('lazily initializes on first call using SERVICE_NAME (or "unknown-service")', async () => {
108
+ const { getTracer } = await loadTracing();
109
+
110
+ getTracer();
111
+
112
+ expect(getTracerMock).toHaveBeenCalledWith('unknown-service');
113
+ });
114
+
115
+ it('caches the tracer across calls instead of re-initializing', async () => {
116
+ const { getTracer } = await loadTracing();
117
+
118
+ getTracer();
119
+ getTracer();
120
+
121
+ expect(providerCtorMock).toHaveBeenCalledTimes(1);
122
+ });
123
+ });
124
+
125
+ describe('startSpan', () => {
126
+ it('starts a root span when no parent is given', async () => {
127
+ const { startSpan } = await loadTracing();
128
+
129
+ startSpan('op');
130
+
131
+ expect(mockTracer.startSpan).toHaveBeenCalledWith('op');
132
+ });
133
+
134
+ it('starts a child span from a real Span parent (has spanContext()), deriving a parent context from it', async () => {
135
+ const { startSpan } = await loadTracing();
136
+ const parentSpan = mockSpan();
137
+
138
+ startSpan('op', parentSpan);
139
+
140
+ const [name, undef, parentContext] = mockTracer.startSpan.mock.calls[0] as unknown as [string, undefined, any];
141
+ expect(name).toBe('op');
142
+ expect(undef).toBeUndefined();
143
+ // trace.setSpan(context.active(), parentSpan) returns a real Context, not the bare parentSpan.
144
+ expect(parentContext).not.toBe(parentSpan);
145
+ expect(typeof parentContext.getValue).toBe('function');
146
+ });
147
+
148
+ it('starts a span from a bare Context parent (no spanContext())', async () => {
149
+ const { startSpan } = await loadTracing();
150
+ const parentContext = context.active();
151
+
152
+ startSpan('op', parentContext);
153
+
154
+ expect(mockTracer.startSpan).toHaveBeenCalledWith('op', undefined, parentContext);
155
+ });
156
+ });
157
+
158
+ describe('createChildSpan', () => {
159
+ it('delegates to startSpan with the given parent', async () => {
160
+ const { createChildSpan } = await loadTracing();
161
+ const parentSpan = mockSpan();
162
+
163
+ createChildSpan('child-op', parentSpan);
164
+
165
+ expect(mockTracer.startSpan).toHaveBeenCalledWith('child-op', undefined, expect.anything());
166
+ });
167
+ });
168
+
169
+ describe('logToSpan', () => {
170
+ it('adds an event with data to the given span', async () => {
171
+ const { logToSpan } = await loadTracing();
172
+ const span = mockSpan();
173
+
174
+ logToSpan(span, 'cache-miss', { key: 'abc' });
175
+
176
+ expect(span.addEvent).toHaveBeenCalledWith('cache-miss', { key: 'abc' });
177
+ });
178
+ });
179
+
180
+ describe('setSpanTag', () => {
181
+ it('sets an attribute on the given span', async () => {
182
+ const { setSpanTag } = await loadTracing();
183
+ const span = mockSpan();
184
+
185
+ setSpanTag(span, 'user.id', '42');
186
+
187
+ expect(span.setAttribute).toHaveBeenCalledWith('user.id', '42');
188
+ });
189
+ });
190
+
191
+ describe('injectHeaders', () => {
192
+ it('returns a headers object without throwing', async () => {
193
+ const { injectHeaders } = await loadTracing();
194
+ const span = mockSpan();
195
+
196
+ const headers = injectHeaders(span);
197
+
198
+ expect(typeof headers).toBe('object');
199
+ });
200
+ });
201
+
202
+ describe('extractSpanContext', () => {
203
+ it('returns a Context derived from the given headers', async () => {
204
+ const { extractSpanContext } = await loadTracing();
205
+
206
+ const extracted = extractSpanContext({ traceparent: '00-abc-def-01' });
207
+
208
+ expect(typeof (extracted as any).getValue).toBe('function');
209
+ });
210
+ });
211
+
212
+ describe('tracingMiddleware', () => {
213
+ function mockReqRes() {
214
+ const req: any = { method: 'GET', path: '/albums', url: '/albums?x=1', headers: {} };
215
+ const handlers: Record<string, () => void> = {};
216
+ const res: any = {
217
+ statusCode: 200,
218
+ on: jest.fn((event: string, handler: () => void) => {
219
+ handlers[event] = handler;
220
+ })
221
+ };
222
+ return { req, res, handlers };
223
+ }
224
+
225
+ it('starts a server span, attaches it to the request, and calls next', async () => {
226
+ const { tracingMiddleware } = await loadTracing();
227
+ const { req, res } = mockReqRes();
228
+ const next = jest.fn();
229
+
230
+ tracingMiddleware(req, res, next);
231
+
232
+ expect(req.span).toBeDefined();
233
+ expect(req.span.setAttribute).toHaveBeenCalledWith('http.method', 'GET');
234
+ expect(req.span.setAttribute).toHaveBeenCalledWith('http.url', '/albums?x=1');
235
+ expect(next).toHaveBeenCalledTimes(1);
236
+ });
237
+
238
+ it('records the response status code and ends the span on finish', async () => {
239
+ const { tracingMiddleware } = await loadTracing();
240
+ const { req, res, handlers } = mockReqRes();
241
+ const next = jest.fn();
242
+
243
+ tracingMiddleware(req, res, next);
244
+ res.statusCode = 404;
245
+ handlers.finish();
246
+
247
+ expect(req.span.setAttribute).toHaveBeenCalledWith('http.status_code', 404);
248
+ expect(req.span.end).toHaveBeenCalledTimes(1);
249
+ });
250
+ });