@tumbaland/backend-core 1.16.0 → 1.16.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -54,6 +54,15 @@ describe('createBaseApp', () => {
54
54
  expect(second.status).toBe(429);
55
55
  });
56
56
 
57
+ it('uses the plain request logger (no metrics histogram) when metrics is false', async () => {
58
+ const app = createBaseApp({ metrics: false });
59
+ app.get('/ping', (_req, res) => res.json({ ok: true }));
60
+
61
+ const res = await request(app).get('/ping');
62
+
63
+ expect(res.status).toBe(200);
64
+ });
65
+
57
66
  it('mounts /health/live ahead of the rate limiter, so it never gets 429s a busy service would', async () => {
58
67
  const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
59
68
  app.get('/ping', (_req, res) => res.json({ ok: true }));
@@ -1,6 +1,6 @@
1
1
  import { Request, Response } from 'express';
2
2
  import mongoose from 'mongoose';
3
- import { healthCheck, healthLive } from './healthController';
3
+ import { healthCheck, healthLive, metricsHandler } from './healthController';
4
4
 
5
5
  function mockRes(): Response {
6
6
  const res: Partial<Response> = {};
@@ -88,4 +88,26 @@ describe('healthCheck', () => {
88
88
  expect(res.status).toHaveBeenCalledWith(503);
89
89
  expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'error', error: 'Health check failed' }));
90
90
  });
91
+
92
+ it('falls back to default service/version/description in the error body when env vars are unset', async () => {
93
+ delete process.env.SERVICE_NAME;
94
+ delete process.env.npm_package_version;
95
+ delete process.env.SERVICE_DESCRIPTION;
96
+ jest.spyOn(mongoose, 'connection', 'get').mockImplementation(() => {
97
+ throw new Error('boom');
98
+ });
99
+ const res = mockRes();
100
+
101
+ await healthCheck({} as Request, res);
102
+
103
+ expect(res.json).toHaveBeenCalledWith(
104
+ expect.objectContaining({ service: 'unknown-service', version: '1.0.0', description: 'Backend service' })
105
+ );
106
+ });
107
+ });
108
+
109
+ describe('metricsHandler re-export', () => {
110
+ it('re-exports the same metricsHandler function from ../metrics', () => {
111
+ expect(typeof metricsHandler).toBe('function');
112
+ });
91
113
  });
@@ -35,6 +35,21 @@ describe('logger', () => {
35
35
  expect(logger.level).toBe('info');
36
36
  });
37
37
 
38
+ it('defaults to development (debug level) when NODE_ENV is unset', async () => {
39
+ const ORIGINAL_ENV = process.env;
40
+ const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
41
+ delete merged.NODE_ENV;
42
+ process.env = merged as NodeJS.ProcessEnv;
43
+
44
+ let loggerModule!: typeof import('./logger').default;
45
+ await jest.isolateModulesAsync(async () => {
46
+ loggerModule = (await import('./logger')).default;
47
+ });
48
+
49
+ process.env = ORIGINAL_ENV;
50
+ expect(loggerModule.level).toBe('debug');
51
+ });
52
+
38
53
  it('does not create a file transport when LOG_TO_FILE is unset', async () => {
39
54
  const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
40
55
  expect(namedTransports(logger, 'file')).toHaveLength(0);
@@ -81,6 +81,31 @@ describe('metricsMiddleware', () => {
81
81
  });
82
82
  });
83
83
 
84
+ describe('default labels', () => {
85
+ it('falls back to app "unknown-service" and version "1.0.0" when their env vars are unset', async () => {
86
+ const ORIGINAL_ENV = process.env;
87
+ const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
88
+ delete merged.SERVICE_NAME;
89
+ delete merged.npm_package_version;
90
+ process.env = merged as NodeJS.ProcessEnv;
91
+
92
+ // isolateModulesAsync sandboxes the whole module registry, including
93
+ // prom-client itself — the spy must target the Registry class required
94
+ // *inside* the sandbox, not the one imported at the top of this file.
95
+ await jest.isolateModulesAsync(async () => {
96
+ const innerPromClient = (await import('prom-client')).default;
97
+ const setDefaultLabelsSpy = jest.spyOn(innerPromClient.Registry.prototype, 'setDefaultLabels');
98
+
99
+ await import('./index');
100
+
101
+ expect(setDefaultLabelsSpy).toHaveBeenCalledWith({ app: 'unknown-service', version: '1.0.0' });
102
+ setDefaultLabelsSpy.mockRestore();
103
+ });
104
+
105
+ process.env = ORIGINAL_ENV;
106
+ });
107
+ });
108
+
84
109
  describe('businessMetrics', () => {
85
110
  it('exposes counters that can be incremented without throwing', () => {
86
111
  expect(() => businessMetrics.albumsCreated.inc()).not.toThrow();
@@ -57,4 +57,16 @@ describe('validate', () => {
57
57
  expect(res.status).toBe(200);
58
58
  expect(res.body.data).toEqual({ name: 'Carol' });
59
59
  });
60
+
61
+ it('omits the field prefix when the zod issue has no path (whole-body validation)', async () => {
62
+ const arraySchema = z.array(z.string());
63
+ const app = buildApp(arraySchema);
64
+ const rawResult = arraySchema.safeParse({ not: 'an array' });
65
+
66
+ const res = await request(app).post('/things').send({ not: 'an array' });
67
+
68
+ expect(res.status).toBe(400);
69
+ expect(rawResult.success).toBe(false);
70
+ expect(res.body.message).toBe(!rawResult.success ? rawResult.error.issues[0].message : undefined);
71
+ });
60
72
  });
@@ -39,6 +39,7 @@ jest.mock('@opentelemetry/resources', () => ({
39
39
  }));
40
40
 
41
41
  import { context } from '@opentelemetry/api';
42
+ import { ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
42
43
 
43
44
  async function loadTracing() {
44
45
  let tracing!: typeof import('./index');
@@ -92,6 +93,23 @@ describe('initTracer', () => {
92
93
  expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://jaeger-endpoint/v1/traces' });
93
94
  });
94
95
 
96
+ it('falls back to version 1.0.0 and environment "development" when their env vars are unset', async () => {
97
+ const merged: Record<string, string | undefined> = { ...process.env };
98
+ delete merged.npm_package_version;
99
+ delete merged.NODE_ENV;
100
+ process.env = merged as NodeJS.ProcessEnv;
101
+
102
+ const { initTracer } = await loadTracing();
103
+ initTracer('album-service');
104
+
105
+ expect(resourceFromAttributesMock).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ [ATTR_SERVICE_VERSION]: '1.0.0',
108
+ 'deployment.environment': 'development'
109
+ })
110
+ );
111
+ });
112
+
95
113
  it('registers the provider and returns a tracer scoped to the given service name', async () => {
96
114
  const { initTracer } = await loadTracing();
97
115
 
@@ -1,4 +1,7 @@
1
1
  {
2
2
  "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": ["node"]
5
+ },
3
6
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
4
7
  }
package/tsconfig.json CHANGED
@@ -2,13 +2,16 @@
2
2
  "compilerOptions": {
3
3
  "target": "es2023",
4
4
  "module": "commonjs",
5
- "lib": ["ES2020"],
6
5
  "outDir": "./dist",
7
6
  "rootDir": "./src",
8
7
  "strict": true,
9
8
  "esModuleInterop": true,
10
9
  "skipLibCheck": true,
11
10
  "forceConsistentCasingInFileNames": true,
11
+ "moduleDetection": "force",
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "noImplicitOverride": true,
12
15
  "declaration": true,
13
16
  "declarationMap": true,
14
17
  "sourceMap": true,