/** * Smoke tests for metrics.js * Verifies the Metrics singleton exposes the expected interface, accumulates * request/error/business counters, normalizes paths, formats uptime, and resets. * * The module exports a singleton instance, so we import it once and mutate its * state in beforeEach. */ const metrics = require('../metrics'); describe('Metrics (singleton)', () => { beforeEach(() => { metrics.reset(); }); test('exposes the documented public API', () => { expect(typeof metrics.recordRequest).toBe('function'); expect(typeof metrics.recordError).toBe('function'); expect(typeof metrics.recordBusinessEvent).toBe('function'); expect(typeof metrics.normalizePath).toBe('function'); expect(typeof metrics.getSummary).toBe('function'); expect(typeof metrics.formatUptime).toBe('function'); expect(typeof metrics.reset).toBe('function'); }); describe('recordRequest', () => { test('increments total request count', () => { metrics.recordRequest('GET', '/api/services', 200, 12); metrics.recordRequest('GET', '/api/services', 200, 8); expect(metrics.requests.total).toBe(2); }); test('aggregates by status code', () => { metrics.recordRequest('GET', '/a', 200, 5); metrics.recordRequest('GET', '/b', 200, 5); metrics.recordRequest('POST', '/c', 500, 5); expect(metrics.requests.byStatus[200]).toBe(2); expect(metrics.requests.byStatus[500]).toBe(1); }); test('aggregates by HTTP method', () => { metrics.recordRequest('GET', '/a', 200, 1); metrics.recordRequest('GET', '/b', 200, 1); metrics.recordRequest('DELETE', '/c', 200, 1); expect(metrics.requests.byMethod.GET).toBe(2); expect(metrics.requests.byMethod.DELETE).toBe(1); }); test('aggregates by normalized path with totalDuration', () => { // Real-looking UUID and long hex hash; both should normalize to /:id const id1 = '550e8400-e29b-41d4-a716-446655440000'; const id2 = 'abcdef0123456789abcdef0123456789'; metrics.recordRequest('GET', `/api/services/${id1}`, 200, 10); metrics.recordRequest('GET', `/api/services/${id2}`, 200, 20); const entry = metrics.requests.byPath['/api/services/:id']; expect(entry).toBeDefined(); expect(entry.count).toBe(2); expect(entry.totalDuration).toBe(30); }); }); describe('recordError', () => { test('increments total error count and per-type counts', () => { metrics.recordError('ValidationError'); metrics.recordError('ValidationError'); metrics.recordError('DockerError'); expect(metrics.errors.total).toBe(3); expect(metrics.errors.byType.ValidationError).toBe(2); expect(metrics.errors.byType.DockerError).toBe(1); }); }); describe('recordBusinessEvent', () => { test('increments known business counters', () => { metrics.recordBusinessEvent('containersDeployed'); metrics.recordBusinessEvent('containersDeployed'); metrics.recordBusinessEvent('dnsRecordsCreated'); expect(metrics.business.containersDeployed).toBe(2); expect(metrics.business.dnsRecordsCreated).toBe(1); }); test('ignores unknown event types without throwing', () => { expect(() => metrics.recordBusinessEvent('not-a-real-event')).not.toThrow(); expect(metrics.business.notARealEvent).toBeUndefined(); }); }); describe('normalizePath', () => { test('replaces UUIDs with /:id', () => { const normalized = metrics.normalizePath('/api/services/550e8400-e29b-41d4-a716-446655440000'); expect(normalized).toBe('/api/services/:id'); }); test('replaces long hex segments with /:id', () => { expect(metrics.normalizePath('/api/containers/abc123def4567890')) .toBe('/api/containers/:id'); }); test('replaces numeric path segments with /:n', () => { expect(metrics.normalizePath('/api/services/42/edit')) .toBe('/api/services/:n/edit'); }); test('leaves static paths unchanged', () => { expect(metrics.normalizePath('/api/health')).toBe('/api/health'); expect(metrics.normalizePath('/')).toBe('/'); }); }); describe('getSummary', () => { test('returns an object with the documented top-level shape', () => { const summary = metrics.getSummary(); expect(summary).toHaveProperty('uptime'); expect(summary.uptime).toHaveProperty('ms'); expect(summary.uptime).toHaveProperty('human'); expect(summary).toHaveProperty('requests'); expect(summary.requests).toHaveProperty('total'); expect(summary.requests).toHaveProperty('perSecond'); expect(summary.requests).toHaveProperty('byStatus'); expect(summary.requests).toHaveProperty('byMethod'); expect(summary.requests).toHaveProperty('topEndpoints'); expect(Array.isArray(summary.requests.topEndpoints)).toBe(true); expect(summary).toHaveProperty('errors'); expect(summary.errors).toHaveProperty('total'); expect(summary.errors).toHaveProperty('rate'); expect(summary.errors).toHaveProperty('byType'); expect(summary).toHaveProperty('business'); expect(summary).toHaveProperty('process'); expect(summary.process).toHaveProperty('pid'); }); test('reflects recorded activity', () => { metrics.recordRequest('GET', '/api/foo', 200, 10); metrics.recordError('BoomError'); const summary = metrics.getSummary(); expect(summary.requests.total).toBe(1); expect(summary.requests.byStatus[200]).toBe(1); expect(summary.errors.total).toBe(1); expect(summary.errors.byType.BoomError).toBe(1); // 1 error / 1 request = 100% error rate expect(summary.errors.rate).toBe(100); }); test('topEndpoints is sorted by count descending and capped at 15', () => { // /a gets 3 hits, /b gets 1, /c gets 2 metrics.recordRequest('GET', '/a', 200, 1); metrics.recordRequest('GET', '/a', 200, 2); metrics.recordRequest('GET', '/a', 200, 3); metrics.recordRequest('GET', '/b', 200, 1); metrics.recordRequest('GET', '/c', 200, 1); metrics.recordRequest('GET', '/c', 200, 2); const top = metrics.getSummary().requests.topEndpoints; expect(top[0].path).toBe('/a'); expect(top[0].count).toBe(3); expect(top[0].avgMs).toBe(2); }); }); describe('formatUptime', () => { test('formats seconds-only when under a minute', () => { expect(metrics.formatUptime(0)).toBe('0s'); expect(metrics.formatUptime(45)).toBe('45s'); }); test('formats minutes and seconds when under an hour', () => { expect(metrics.formatUptime(60)).toBe('1m 0s'); expect(metrics.formatUptime(125)).toBe('2m 5s'); }); test('formats hours/minutes/seconds when under a day', () => { expect(metrics.formatUptime(3600)).toBe('1h 0m 0s'); expect(metrics.formatUptime(3725)).toBe('1h 2m 5s'); }); test('formats days/hours/minutes when over a day', () => { expect(metrics.formatUptime(86400)).toBe('1d 0h 0m'); // 1 day, 2 hours, 5 minutes, 0 seconds expect(metrics.formatUptime(86400 + 2 * 3600 + 5 * 60)).toBe('1d 2h 5m'); }); }); describe('reset', () => { test('clears request counters and error counters', () => { metrics.recordRequest('GET', '/x', 200, 1); metrics.recordError('E'); metrics.reset(); expect(metrics.requests.total).toBe(0); expect(metrics.errors.total).toBe(0); expect(metrics.requests.byStatus).toEqual({}); expect(metrics.requests.byMethod).toEqual({}); expect(metrics.requests.byPath).toEqual({}); expect(metrics.errors.byType).toEqual({}); }); test('resets startTime so uptime is small after reset', () => { const before = metrics.startTime; // Sleep a tick so Date.now() moves forward const start = Date.now(); while (Date.now() - start < 5) {} // ~5ms busy-wait metrics.reset(); expect(metrics.startTime).toBeGreaterThanOrEqual(before); const summary = metrics.getSummary(); expect(summary.uptime.ms).toBeLessThan(5000); }); }); });