diff --git a/.gitignore b/.gitignore index 8776680..2b1a4a6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ avatar/.DS_Store .env sipp/csv/servers/ + +coverage/ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..077ed4f --- /dev/null +++ b/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + testMatch: ['**/test/**/*.test.js'], + testEnvironment: 'node', + clearMocks: true, + collectCoverageFrom: [ + 'lib/**/*.js', + 'metrics-server.js' + ], + coverageDirectory: 'coverage' +}; diff --git a/package.json b/package.json index 18e1e3d..d33ac3d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,12 @@ }, "scripts": { "start": "node server.js", - "metrics": "node metrics-server.js" + "metrics": "node metrics-server.js", + "test": "jest", + "test:coverage": "jest --coverage" + }, + "devDependencies": { + "jest": "^30.3.0", + "supertest": "^7.2.2" } } diff --git a/test/lib/config.test.js b/test/lib/config.test.js new file mode 100644 index 0000000..7ffa23c --- /dev/null +++ b/test/lib/config.test.js @@ -0,0 +1,187 @@ +const fs = require('fs'); +const path = require('path'); + +describe('ConfigLoader', () => { + let ConfigLoader; + let originalEnv; + let originalArgv; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalArgv = [...process.argv]; + + // Clear relevant env vars + delete process.env.TARGET_SERVER; + delete process.env.APIKEY; + delete process.env.MAX_DOMAIN; + delete process.env.MAX_RESELLERS; + delete process.env.PEAK_CPS; + delete process.env.REGISTRATION_PCT; + delete process.env.SEED; + + // Fresh import each time to avoid state leakage + jest.resetModules(); + ({ ConfigLoader } = require('../../lib/config')); + }); + + afterEach(() => { + process.env = originalEnv; + process.argv = originalArgv; + jest.restoreAllMocks(); + }); + + describe('single server mode', () => { + beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockImplementation((p) => { + if (p.endsWith('servers.json')) return false; + return false; + }); + }); + + test('loads from env when no servers.json', () => { + process.env.TARGET_SERVER = 'test.example.com'; + process.env.APIKEY = 'testkey123'; + + const loader = new ConfigLoader(); + const config = loader.load(); + + expect(config.mode).toBe('single'); + expect(config.servers).toHaveLength(1); + expect(config.selectedServer.hostname).toBe('test.example.com'); + expect(config.selectedServer.apikey).toBe('testkey123'); + expect(config.selectedServer.id).toBe('default'); + }); + + test('throws when TARGET_SERVER missing', () => { + process.env.APIKEY = 'testkey123'; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('TARGET_SERVER'); + }); + + test('throws when APIKEY missing', () => { + process.env.TARGET_SERVER = 'test.example.com'; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('APIKEY'); + }); + + test('applies defaults for optional fields', () => { + process.env.TARGET_SERVER = 'test.example.com'; + process.env.APIKEY = 'testkey123'; + + const loader = new ConfigLoader(); + const config = loader.load(); + + expect(config.selectedServer.maxDomains).toBe(10); + expect(config.selectedServer.peakCps).toBe(10); + expect(config.selectedServer.registrationPct).toBe(0.8); + }); + }); + + describe('multi server mode', () => { + const serversJson = { + servers: [ + { id: 'prod1', hostname: 'prod1.example.com', apikey: 'key1', maxDomains: 20 }, + { id: 'prod2', hostname: 'prod2.example.com', apikey: 'key2', maxDomains: 30 } + ] + }; + + beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockImplementation((p) => { + if (p.endsWith('servers.json')) return true; + return false; + }); + jest.spyOn(fs, 'readFileSync').mockImplementation((p) => { + if (p.endsWith('servers.json')) return JSON.stringify(serversJson); + return ''; + }); + }); + + test('reads and parses servers.json correctly', () => { + process.argv = ['node', 'server.js', '--server', 'prod1']; + + const loader = new ConfigLoader(); + const config = loader.load(); + + expect(config.mode).toBe('multi'); + expect(config.servers).toHaveLength(2); + expect(config.selectedServer.id).toBe('prod1'); + expect(config.selectedServer.hostname).toBe('prod1.example.com'); + }); + + test('throws without --server flag', () => { + process.argv = ['node', 'server.js']; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('--server'); + }); + + test('throws for unknown server ID', () => { + process.argv = ['node', 'server.js', '--server', 'unknown']; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow("Server 'unknown' not found"); + }); + + test('throws on invalid JSON', () => { + jest.spyOn(fs, 'readFileSync').mockReturnValue('not json'); + + process.argv = ['node', 'server.js', '--server', 'prod1']; + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('Failed to load servers.json'); + }); + + test('throws when servers array is missing', () => { + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ notServers: [] })); + + process.argv = ['node', 'server.js', '--server', 'prod1']; + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('servers'); + }); + }); + + describe('validation', () => { + beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockImplementation((p) => { + if (p.endsWith('servers.json')) return false; + return false; + }); + }); + + test('throws for invalid hostname format', () => { + process.env.TARGET_SERVER = 'invalid hostname!'; + process.env.APIKEY = 'testkey'; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('Invalid hostname'); + }); + + test('throws for maxDomains < 1', () => { + process.env.TARGET_SERVER = 'test.example.com'; + process.env.APIKEY = 'testkey'; + process.env.MAX_DOMAIN = '-1'; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('maxDomains'); + }); + + test('throws for peakCps <= 0', () => { + process.env.TARGET_SERVER = 'test.example.com'; + process.env.APIKEY = 'testkey'; + process.env.PEAK_CPS = '-1'; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('peakCps'); + }); + + test('throws for registrationPct out of range', () => { + process.env.TARGET_SERVER = 'test.example.com'; + process.env.APIKEY = 'testkey'; + process.env.REGISTRATION_PCT = '1.5'; + + const loader = new ConfigLoader(); + expect(() => loader.load()).toThrow('registrationPct'); + }); + }); +}); diff --git a/test/lib/nsapi.test.js b/test/lib/nsapi.test.js new file mode 100644 index 0000000..cafaac0 --- /dev/null +++ b/test/lib/nsapi.test.js @@ -0,0 +1,211 @@ +jest.mock('axios'); + +const axios = require('axios'); + +// Mock axios.create to return a mock instance +const mockRequest = jest.fn(); +const mockAxiosInstance = { request: mockRequest }; +axios.create.mockReturnValue(mockAxiosInstance); + +const { ServerApiClient } = require('../../lib/nsapi'); + +describe('ServerApiClient', () => { + let client; + + beforeEach(() => { + jest.clearAllMocks(); + client = new ServerApiClient({ + hostname: 'test.example.com', + apikey: 'testkey123', + id: 'test-server' + }); + }); + + describe('constructor', () => { + test('stores config values', () => { + expect(client.hostname).toBe('test.example.com'); + expect(client.apikey).toBe('testkey123'); + expect(client.serverId).toBe('test-server'); + }); + }); + + describe('_retryApiCall', () => { + test('succeeds on first try', async () => { + const apiCall = jest.fn().mockResolvedValue({ data: 'ok' }); + const result = await client._retryApiCall(apiCall); + expect(result).toEqual({ data: 'ok' }); + expect(apiCall).toHaveBeenCalledTimes(1); + }); + + test('retries on ECONNRESET', async () => { + const error = new Error('connection reset'); + error.code = 'ECONNRESET'; + const apiCall = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ data: 'ok' }); + + const result = await client._retryApiCall(apiCall); + + expect(result).toEqual({ data: 'ok' }); + expect(apiCall).toHaveBeenCalledTimes(2); + }, 15000); + + test('retries on HTTP 500+', async () => { + const error = new Error('server error'); + error.response = { status: 503 }; + const apiCall = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ data: 'ok' }); + + const result = await client._retryApiCall(apiCall); + + expect(result).toEqual({ data: 'ok' }); + expect(apiCall).toHaveBeenCalledTimes(2); + }, 15000); + + test('does NOT retry on HTTP 400', async () => { + const error = new Error('bad request'); + error.response = { status: 400 }; + const apiCall = jest.fn().mockRejectedValue(error); + + await expect(client._retryApiCall(apiCall)).rejects.toThrow('bad request'); + expect(apiCall).toHaveBeenCalledTimes(1); + }); + + test('does NOT retry on HTTP 409', async () => { + const error = new Error('conflict'); + error.response = { status: 409 }; + const apiCall = jest.fn().mockRejectedValue(error); + + await expect(client._retryApiCall(apiCall)).rejects.toThrow('conflict'); + expect(apiCall).toHaveBeenCalledTimes(1); + }); + + test('throws after max retries exhausted', async () => { + const error = new Error('timeout'); + error.code = 'ETIMEDOUT'; + const apiCall = jest.fn().mockRejectedValue(error); + + await expect(client._retryApiCall(apiCall, 1)).rejects.toThrow('timeout'); + expect(apiCall).toHaveBeenCalledTimes(2); // initial + 1 retry + }, 15000); + }); + + describe('apiCreate', () => { + test('calls POST with correct URL and headers', async () => { + mockRequest.mockResolvedValue({ status: 201, statusText: 'Created' }); + + const successFn = jest.fn(); + await client.apiCreate('domains', { name: 'test.com' }, successFn); + + expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ + method: 'POST', + url: 'https://test.example.com/ns-api/v2/domains', + headers: expect.objectContaining({ + authorization: 'Bearer testkey123' + }), + data: { name: 'test.com' } + })); + expect(successFn).toHaveBeenCalledWith({ name: 'test.com' }); + }); + + test('calls duplicateFunction then successFunction on 409', async () => { + const error = new Error('conflict'); + error.response = { status: 409, statusText: 'Conflict' }; + mockRequest.mockRejectedValue(error); + + const successFn = jest.fn(); + const dupFn = jest.fn(); + await client.apiCreate('domains', { name: 'dup.com' }, successFn, dupFn); + + expect(dupFn).toHaveBeenCalledWith({ name: 'dup.com' }); + expect(successFn).toHaveBeenCalledWith({ name: 'dup.com' }); + }); + + test('handles missing callbacks gracefully', async () => { + mockRequest.mockResolvedValue({ status: 201, statusText: 'Created' }); + // Should not throw + await expect(client.apiCreate('domains', { name: 'test.com' })).resolves.toBeUndefined(); + }); + }); + + describe('apiUpdate', () => { + test('calls PUT with correct URL and headers', async () => { + mockRequest.mockResolvedValue({ status: 200, statusText: 'OK' }); + + const successFn = jest.fn(); + await client.apiUpdate('domains/test.com', { description: 'updated' }, successFn); + + expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ + method: 'PUT', + url: 'https://test.example.com/ns-api/v2/domains/test.com', + data: { description: 'updated' } + })); + expect(successFn).toHaveBeenCalled(); + }); + + test('handles errors without throwing', async () => { + const error = new Error('bad request'); + error.response = { status: 400, statusText: 'Bad Request' }; + mockRequest.mockRejectedValue(error); + + // apiUpdate catches errors, should not throw (non-retryable error) + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + await expect(client.apiUpdate('domains/test.com', {})).resolves.toBeUndefined(); + consoleSpy.mockRestore(); + }); + }); + + describe('apiCountSync', () => { + test('returns total on success', async () => { + mockRequest.mockResolvedValue({ + status: 200, + statusText: 'OK', + data: { total: 42 } + }); + + const result = await client.apiCountSync('domains?format=count'); + expect(result).toBe(42); + }); + + test('returns 0 on error', async () => { + mockRequest.mockRejectedValue(new Error('network error')); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await client.apiCountSync('domains?format=count'); + expect(result).toBe(0); + consoleSpy.mockRestore(); + }); + + test('returns 0 when response.data.total is missing', async () => { + mockRequest.mockResolvedValue({ + status: 200, + statusText: 'OK', + data: {} + }); + + const result = await client.apiCountSync('domains?format=count'); + expect(result).toBe(0); + }); + }); + + describe('apiCreateSync', () => { + test('calls successFunction on success', async () => { + mockRequest.mockResolvedValue({ status: 201, statusText: 'Created' }); + + const successFn = jest.fn(); + await client.apiCreateSync('users', { name: 'test' }, successFn); + expect(successFn).toHaveBeenCalled(); + }); + + test('calls duplicateFunction on 409', async () => { + const error = new Error('conflict'); + error.response = { status: 409, statusText: 'Conflict' }; + mockRequest.mockRejectedValue(error); + + const dupFn = jest.fn(); + await client.apiCreateSync('users', { name: 'test' }, jest.fn(), dupFn); + expect(dupFn).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/lib/prometheus-metrics.test.js b/test/lib/prometheus-metrics.test.js new file mode 100644 index 0000000..b124b7d --- /dev/null +++ b/test/lib/prometheus-metrics.test.js @@ -0,0 +1,160 @@ +const { + updateResponseTimeMetrics, + updateCallMetrics, + updateStatsFileCount, + getMetrics, + getContentType, + resetMetrics, + getRegistry +} = require('../../lib/prometheus-metrics'); + +describe('prometheus-metrics', () => { + beforeEach(() => { + resetMetrics(); + }); + + describe('updateResponseTimeMetrics', () => { + test('sets gauge values for percentiles and average', async () => { + updateResponseTimeMetrics('srv1', 'register', 'register', 'u1', { + average: 0.025, + percentiles: { p50: 0.010, p95: 0.050, p99: 0.100 }, + count: 500 + }); + + const output = await getMetrics(); + expect(output).toContain('sipp_response_time_average_seconds'); + expect(output).toContain('sipp_response_time_p50_seconds'); + expect(output).toContain('sipp_response_time_p95_seconds'); + expect(output).toContain('sipp_response_time_p99_seconds'); + expect(output).toContain('sipp_response_samples'); + }); + + test('does not set average when it is 0', async () => { + updateResponseTimeMetrics('srv1', 'register', 'register', 'u1', { + average: 0, + percentiles: { p50: 0.010, p95: 0.050, p99: 0.100 }, + count: 100 + }); + + const output = await getMetrics(); + // Average gauge should not have been set for this label combination + // But other gauges should be present + expect(output).toContain('sipp_response_time_p50_seconds'); + }); + + test('sets last update timestamp', async () => { + updateResponseTimeMetrics('srv1', 'register', 'register', 'u1', { + average: 0.025, + percentiles: { p50: 0.010, p95: 0.050, p99: 0.100 }, + count: 100 + }); + + const output = await getMetrics(); + expect(output).toContain('sipp_metrics_last_update_timestamp_seconds'); + }); + }); + + describe('updateCallMetrics', () => { + test('sets call volume metrics for aggregated stats', async () => { + const aggregated = new Map(); + aggregated.set('srv1:register:u1', { + labels: { server: 'srv1', scenario: 'register', transport: 'u1' }, + currentCalls: 10, + callRate: 2.5, + totalCalls: 1000, + successfulCalls: 950, + failedCalls: 50, + failureBreakdown: { + cannot_send_message: 5, + max_udp_retrans: 10, + unexpected_message: 0, + call_rejected: 0, + regexp_no_match: 0, + regexp_hdr_not_found: 0, + out_of_call: 0 + } + }); + + updateCallMetrics(aggregated); + + const output = await getMetrics(); + expect(output).toContain('sipp_current_calls'); + expect(output).toContain('sipp_call_rate_cps'); + expect(output).toContain('sipp_total_calls'); + expect(output).toContain('sipp_successful_calls_total'); + expect(output).toContain('sipp_failed_calls_total'); + }); + + test('sets failure breakdown only for non-zero counts', async () => { + const aggregated = new Map(); + aggregated.set('srv1:register:u1', { + labels: { server: 'srv1', scenario: 'register', transport: 'u1' }, + currentCalls: 10, + callRate: 2.5, + totalCalls: 1000, + successfulCalls: 950, + failedCalls: 50, + failureBreakdown: { + cannot_send_message: 5, + max_udp_retrans: 0, + unexpected_message: 0, + call_rejected: 0, + regexp_no_match: 0, + regexp_hdr_not_found: 0, + out_of_call: 0 + } + }); + + updateCallMetrics(aggregated); + + const output = await getMetrics(); + expect(output).toContain('cannot_send_message'); + expect(output).not.toContain('max_udp_retrans'); + }); + + test('handles empty Map', async () => { + updateCallMetrics(new Map()); + const output = await getMetrics(); + // Should not throw, metrics should be reset + expect(typeof output).toBe('string'); + }); + }); + + describe('updateStatsFileCount', () => { + test('sets the gauge to provided count', async () => { + updateStatsFileCount(42); + const output = await getMetrics(); + expect(output).toContain('sipp_stats_files_active'); + expect(output).toContain('42'); + }); + }); + + describe('getMetrics / getContentType', () => { + test('getMetrics returns a string', async () => { + const output = await getMetrics(); + expect(typeof output).toBe('string'); + }); + + test('getContentType returns a valid content type', () => { + const ct = getContentType(); + expect(typeof ct).toBe('string'); + expect(ct.length).toBeGreaterThan(0); + }); + }); + + describe('resetMetrics / getRegistry', () => { + test('resetMetrics clears all metric values', async () => { + updateStatsFileCount(99); + resetMetrics(); + // After reset, the gauge exists but values are cleared + const registry = getRegistry(); + expect(registry).toBeDefined(); + }); + + test('getRegistry returns the prometheus registry', () => { + const registry = getRegistry(); + expect(registry).toBeDefined(); + expect(typeof registry.metrics).toBe('function'); + }); + }); +}); diff --git a/test/lib/randomdata.test.js b/test/lib/randomdata.test.js new file mode 100644 index 0000000..5cd4a0d --- /dev/null +++ b/test/lib/randomdata.test.js @@ -0,0 +1,55 @@ +const { timeZones, queueNames, departmentNames, phoneModels } = require('../../lib/randomdata'); + +describe('randomdata', () => { + describe('timeZones', () => { + test('has 7 entries', () => { + expect(timeZones).toHaveLength(7); + }); + + test('all start with "US/"', () => { + timeZones.forEach(tz => { + expect(tz).toMatch(/^US\//); + }); + }); + + test('has no duplicates', () => { + expect(new Set(timeZones).size).toBe(timeZones.length); + }); + }); + + describe('queueNames', () => { + test('is a non-empty array of strings', () => { + expect(queueNames.length).toBeGreaterThan(0); + queueNames.forEach(name => { + expect(typeof name).toBe('string'); + expect(name.length).toBeGreaterThan(0); + }); + }); + }); + + describe('departmentNames', () => { + test('is a non-empty array of strings', () => { + expect(departmentNames.length).toBeGreaterThan(0); + departmentNames.forEach(name => { + expect(typeof name).toBe('string'); + }); + }); + + test('has no duplicates', () => { + expect(new Set(departmentNames).size).toBe(departmentNames.length); + }); + }); + + describe('phoneModels', () => { + test('is a non-empty array of strings', () => { + expect(phoneModels.length).toBeGreaterThan(0); + phoneModels.forEach(model => { + expect(typeof model).toBe('string'); + }); + }); + + test('has no duplicates', () => { + expect(new Set(phoneModels).size).toBe(phoneModels.length); + }); + }); +}); diff --git a/test/lib/sipp-parser-file.test.js b/test/lib/sipp-parser-file.test.js new file mode 100644 index 0000000..0bfb886 --- /dev/null +++ b/test/lib/sipp-parser-file.test.js @@ -0,0 +1,162 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { parseSippStatsFile, parseStatsDirectory, parseLatestStats } = require('../../lib/sipp-parser'); + +// Minimal SIPp CSV header with essential columns +const SIPP_HEADER = [ + 'StartTime', 'LastResetTime', 'CurrentTime', 'ElapsedTime', 'TargetRate', + 'CallRate', 'IncomingCall', 'OutgoingCall', 'TotalCallCreated', + 'CurrentCall', 'SuccessfulCall', 'FailedCall', + 'FailedCannotSendMessage', 'FailedMaxUDPRetrans', 'FailedUnexpectedMessage', + 'FailedCallRejected', 'FailedRegexpDoesntMatch', 'FailedRegexpHdrNotFound', + 'OutOfCallMsgs', + 'ResponseTimeRepartitionregister_<10', 'ResponseTimeRepartitionregister_<20', + 'ResponseTimeRepartitionregister_>=20' +].join(';'); + +const SIPP_DATA_LINE = [ + '1700000000', '1700000000', '1700000100', '100.0', '10', + '5.0', '0', '100', '100', + '10', '90', '5', + '1', '2', '0', + '1', '0', '0', + '1', + '50', '30', '5' +].join(';'); + +const SIPP_DATA_LINE_2 = [ + '1700000000', '1700000000', '1700000200', '200.0', '10', + '8.0', '0', '200', '200', + '15', '180', '10', + '2', '3', '1', + '2', '1', '0', + '1', + '80', '50', '10' +].join(';'); + +let tmpDir; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sipp-parser-test-')); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('parseSippStatsFile', () => { + test('returns null for non-existent file', () => { + expect(parseSippStatsFile('/nonexistent/file.csv')).toBeNull(); + }); + + test('returns null for empty file', () => { + const filePath = path.join(tmpDir, 'empty.csv'); + fs.writeFileSync(filePath, ''); + expect(parseSippStatsFile(filePath)).toBeNull(); + }); + + test('returns zeroed stats for header-only file', () => { + const filePath = path.join(tmpDir, 'header-only.csv'); + fs.writeFileSync(filePath, SIPP_HEADER + '\n'); + // SIPp parser reads header as last line too, producing zeroed stats + const result = parseSippStatsFile(filePath); + if (result !== null) { + expect(result.totalCalls).toBe(0); + expect(result.successfulCalls).toBe(0); + } + }); + + test('parses valid CSV file and returns expected structure', () => { + const filePath = path.join(tmpDir, 'valid.csv'); + fs.writeFileSync(filePath, SIPP_HEADER + '\n' + SIPP_DATA_LINE + '\n'); + + const result = parseSippStatsFile(filePath); + expect(result).not.toBeNull(); + expect(result.totalCalls).toBe(100); + expect(result.successfulCalls).toBe(90); + expect(result.failedCalls).toBe(5); + expect(result.currentCalls).toBe(10); + expect(result.callRate).toBe(5.0); + expect(result.responseTimesByOperation).toBeDefined(); + expect(result.responseTimesByOperation).toHaveProperty('register'); + expect(result.failureBreakdown).toBeDefined(); + expect(result.failureBreakdown.cannot_send_message).toBe(1); + expect(result.failureBreakdown.max_udp_retrans).toBe(2); + }); +}); + +describe('parseLatestStats', () => { + test('returns null stats and fileSize 0 for empty file', () => { + const filePath = path.join(tmpDir, 'empty-latest.csv'); + fs.writeFileSync(filePath, ''); + const result = parseLatestStats(filePath); + expect(result.stats).toBeNull(); + expect(result.fileSize).toBe(0); + }); + + test('returns correct fileSize and parsed stats', () => { + const content = SIPP_HEADER + '\n' + SIPP_DATA_LINE + '\n'; + const filePath = path.join(tmpDir, 'latest-valid.csv'); + fs.writeFileSync(filePath, content); + + const result = parseLatestStats(filePath); + expect(result.stats).not.toBeNull(); + expect(result.fileSize).toBe(Buffer.byteLength(content)); + expect(result.stats.totalCalls).toBe(100); + }); + + test('reads only the last data line from multi-line file', () => { + const content = SIPP_HEADER + '\n' + SIPP_DATA_LINE + '\n' + SIPP_DATA_LINE_2 + '\n'; + const filePath = path.join(tmpDir, 'multi-line.csv'); + fs.writeFileSync(filePath, content); + + const result = parseLatestStats(filePath); + expect(result.stats).not.toBeNull(); + // Should get values from SIPP_DATA_LINE_2 + expect(result.stats.totalCalls).toBe(200); + expect(result.stats.successfulCalls).toBe(180); + }); +}); + +describe('parseStatsDirectory', () => { + test('returns empty array for non-existent directory', () => { + expect(parseStatsDirectory('/nonexistent/dir')).toEqual([]); + }); + + test('returns empty array for directory with no CSV files', () => { + const emptyDir = path.join(tmpDir, 'empty-dir'); + fs.mkdirSync(emptyDir); + fs.writeFileSync(path.join(emptyDir, 'readme.txt'), 'not a csv'); + expect(parseStatsDirectory(emptyDir)).toEqual([]); + }); + + test('returns parsed stats with metadata for valid CSVs', () => { + const statsDir = path.join(tmpDir, 'stats-dir'); + fs.mkdirSync(statsDir); + + const content = SIPP_HEADER + '\n' + SIPP_DATA_LINE + '\n'; + fs.writeFileSync(path.join(statsDir, 'prod1_register_u1_test_com_12345.csv'), content); + + const results = parseStatsDirectory(statsDir); + expect(results).toHaveLength(1); + expect(results[0].serverId).toBe('prod1'); + expect(results[0].scenario).toBe('register'); + expect(results[0].transport).toBe('u1'); + expect(results[0].stats).toBeDefined(); + expect(results[0].stats.totalCalls).toBe(100); + }); + + test('ignores non-CSV files', () => { + const mixedDir = path.join(tmpDir, 'mixed-dir'); + fs.mkdirSync(mixedDir); + + const content = SIPP_HEADER + '\n' + SIPP_DATA_LINE + '\n'; + fs.writeFileSync(path.join(mixedDir, 'prod1_register_u1_test_99.csv'), content); + fs.writeFileSync(path.join(mixedDir, 'notes.txt'), 'not a csv'); + fs.writeFileSync(path.join(mixedDir, 'data.json'), '{}'); + + const results = parseStatsDirectory(mixedDir); + expect(results).toHaveLength(1); + }); +}); diff --git a/test/lib/sipp-parser.test.js b/test/lib/sipp-parser.test.js new file mode 100644 index 0000000..0e3b5de --- /dev/null +++ b/test/lib/sipp-parser.test.js @@ -0,0 +1,149 @@ +const { parseStatsFilename, extractResponseTimes } = require('../../lib/sipp-parser'); + +describe('sipp-parser', () => { + describe('parseStatsFilename', () => { + test('parses full format with server ID', () => { + const result = parseStatsFilename('prod1_register_t1_example_com_12345.csv'); + expect(result).toEqual({ + serverId: 'prod1', + scenario: 'register', + transport: 't1', + deviceFile: 'example_com', + pid: '12345' + }); + }); + + test('parses inbound scenario', () => { + const result = parseStatsFilename('prod1_inbound_u1_US_Eastern_12346.csv'); + expect(result).toEqual({ + serverId: 'prod1', + scenario: 'inbound', + transport: 'u1', + deviceFile: 'US_Eastern', + pid: '12346' + }); + }); + + test('parses format without server ID (defaults to "default")', () => { + const result = parseStatsFilename('register_l1_example_com_12347.csv'); + expect(result).toEqual({ + serverId: 'default', + scenario: 'register', + transport: 'l1', + deviceFile: 'example_com', + pid: '12347' + }); + }); + + test('handles all transport types', () => { + expect(parseStatsFilename('srv_register_u1_dom_1.csv').transport).toBe('u1'); + expect(parseStatsFilename('srv_register_t1_dom_1.csv').transport).toBe('t1'); + expect(parseStatsFilename('srv_register_l1_dom_1.csv').transport).toBe('l1'); + }); + + test('returns unknown for unrecognized format', () => { + const result = parseStatsFilename('random_file.csv'); + expect(result.serverId).toBe('unknown'); + expect(result.scenario).toBe('unknown'); + expect(result.transport).toBe('unknown'); + expect(result.pid).toBe('0'); + }); + + test('strips .csv extension', () => { + const result = parseStatsFilename('prod1_register_u1_test_99.csv'); + expect(result.pid).toBe('99'); + }); + + test('handles path with directory', () => { + const result = parseStatsFilename('/var/stats/prod1_register_u1_test_99.csv'); + expect(result.serverId).toBe('prod1'); + expect(result.scenario).toBe('register'); + }); + }); + + describe('extractResponseTimes', () => { + test('returns empty object for record with no ResponseTimeRepartition keys', () => { + const record = { CurrentTime: '12345', ElapsedTime: '100' }; + expect(extractResponseTimes(record)).toEqual({}); + }); + + test('extracts single operation response times', () => { + const record = { + 'ResponseTimeRepartitionregister_<10': '50', + 'ResponseTimeRepartitionregister_<20': '30', + 'ResponseTimeRepartitionregister_>=20': '5' + }; + const result = extractResponseTimes(record); + expect(result).toHaveProperty('register'); + expect(result.register.count).toBe(85); + expect(result.register.percentiles).toBeDefined(); + expect(result.register.percentiles.p50).toBeDefined(); + expect(result.register.percentiles.p95).toBeDefined(); + expect(result.register.percentiles.p99).toBeDefined(); + expect(result.register.average).toBeGreaterThan(0); + }); + + test('extracts multiple operations independently', () => { + const record = { + 'ResponseTimeRepartitionregister_<10': '100', + 'ResponseTimeRepartitionreregister_<20': '50', + 'ResponseTimeRepartitionreregister_>=20': '10' + }; + const result = extractResponseTimes(record); + expect(Object.keys(result)).toHaveLength(2); + expect(result).toHaveProperty('register'); + expect(result).toHaveProperty('reregister'); + }); + + test('normalizes numeric operation names', () => { + const record = { + 'ResponseTimeRepartition1_<100': '10', + 'ResponseTimeRepartition2_<100': '20', + 'ResponseTimeRepartition3_<100': '30' + }; + const result = extractResponseTimes(record); + expect(result).toHaveProperty('invite'); + expect(result).toHaveProperty('subscribe'); + expect(result).toHaveProperty('notify'); + }); + + test('skips operations with zero-count buckets only', () => { + const record = { + 'ResponseTimeRepartitionregister_<10': '0', + 'ResponseTimeRepartitionregister_<20': '0' + }; + const result = extractResponseTimes(record); + expect(result).toEqual({}); + }); + + test('calculates correct percentiles for known distribution', () => { + // 100 samples all in the <10 bucket -> p50/p95/p99 all = midpoint of [0,10) = 5ms -> 0.005s + const record = { + 'ResponseTimeRepartitionregister_<10': '100' + }; + const result = extractResponseTimes(record); + expect(result.register.percentiles.p50).toBeCloseTo(0.005, 3); + expect(result.register.percentiles.p95).toBeCloseTo(0.005, 3); + expect(result.register.percentiles.p99).toBeCloseTo(0.005, 3); + }); + + test('calculates average in seconds', () => { + // All 100 in <10ms bucket -> midpoint 5ms -> avg = 5ms = 0.005s + const record = { + 'ResponseTimeRepartitionregister_<10': '100' + }; + const result = extractResponseTimes(record); + expect(result.register.average).toBeCloseTo(0.005, 3); + }); + + test('handles >= bucket with estimated midpoint', () => { + const record = { + 'ResponseTimeRepartitionregister_>=100': '10' + }; + const result = extractResponseTimes(record); + expect(result.register.count).toBe(10); + // >= 100 bucket uses estimate of threshold * 1.5 = 150ms -> 0.15s + expect(result.register.average).toBeCloseTo(0.15, 2); + }); + }); +}); diff --git a/test/lib/stats-tracker.test.js b/test/lib/stats-tracker.test.js new file mode 100644 index 0000000..bcd8fc7 --- /dev/null +++ b/test/lib/stats-tracker.test.js @@ -0,0 +1,192 @@ +const statsTracker = require('../../lib/stats-tracker'); + +describe('stats-tracker', () => { + beforeEach(() => { + statsTracker.clearState(); + }); + + describe('updateFileState', () => { + const makeStats = (overrides = {}) => ({ + totalCalls: 100, + successfulCalls: 90, + failedCalls: 10, + currentCalls: 5, + ...overrides + }); + + test('returns null on first call (no previous state)', () => { + const result = statsTracker.updateFileState('/test/file.csv', makeStats(), 1000); + expect(result).toBeNull(); + }); + + test('returns deltas on second call with different fileSize', () => { + // Mock Date.now to control time delta + const realNow = Date.now; + let time = realNow(); + Date.now = () => time; + + statsTracker.updateFileState('/test/file.csv', makeStats(), 1000); + + time += 5000; // 5 seconds later + const result = statsTracker.updateFileState('/test/file.csv', makeStats({ + totalCalls: 150, + successfulCalls: 130, + failedCalls: 15 + }), 2000); + + Date.now = realNow; + + expect(result).not.toBeNull(); + expect(result.totalCallsDelta).toBe(50); + expect(result.successfulCallsDelta).toBe(40); + expect(result.failedCallsDelta).toBe(5); + expect(result.timeDeltaSec).toBe(5); + expect(result.instantCallRate).toBe(10); // 50 calls / 5 seconds + }); + + test('returns null when fileSize has not changed', () => { + statsTracker.updateFileState('/test/file.csv', makeStats(), 1000); + const result = statsTracker.updateFileState('/test/file.csv', makeStats({ + totalCalls: 200 + }), 1000); + expect(result).toBeNull(); + }); + + test('clamps negative deltas to zero', () => { + statsTracker.updateFileState('/test/file.csv', makeStats({ totalCalls: 200 }), 1000); + const result = statsTracker.updateFileState('/test/file.csv', makeStats({ totalCalls: 100 }), 2000); + + expect(result).not.toBeNull(); + expect(result.totalCallsDelta).toBe(0); + }); + }); + + describe('getFileState', () => { + test('returns null for unknown file', () => { + expect(statsTracker.getFileState('/nonexistent')).toBeNull(); + }); + + test('returns state after updateFileState', () => { + const stats = { totalCalls: 100, successfulCalls: 90, failedCalls: 10 }; + statsTracker.updateFileState('/test/file.csv', stats, 1000); + const state = statsTracker.getFileState('/test/file.csv'); + expect(state).not.toBeNull(); + expect(state.lastStats).toEqual(stats); + expect(state.lastFileSize).toBe(1000); + }); + }); + + describe('removeStaleFiles', () => { + test('does not remove fresh files', () => { + statsTracker.updateFileState('/test/fresh.csv', { totalCalls: 1 }, 100); + const removed = statsTracker.removeStaleFiles(); + expect(removed).toHaveLength(0); + expect(statsTracker.getFileState('/test/fresh.csv')).not.toBeNull(); + }); + + test('removes files older than STALE_TIMEOUT_MS', () => { + statsTracker.updateFileState('/test/old.csv', { totalCalls: 1 }, 100); + + // Mock Date.now to simulate time passage + const realNow = Date.now; + Date.now = () => realNow() + statsTracker.STALE_TIMEOUT_MS + 1000; + + const removed = statsTracker.removeStaleFiles(); + expect(removed).toContain('/test/old.csv'); + expect(statsTracker.getFileState('/test/old.csv')).toBeNull(); + + Date.now = realNow; + }); + }); + + describe('aggregateStats', () => { + test('returns empty map for empty input', () => { + const result = statsTracker.aggregateStats([]); + expect(result.size).toBe(0); + }); + + test('aggregates single file correctly', () => { + const result = statsTracker.aggregateStats([{ + metadata: { serverId: 'srv1', scenario: 'register', transport: 'u1' }, + stats: { + currentCalls: 5, + totalCalls: 100, + successfulCalls: 90, + failedCalls: 10, + failureBreakdown: { cannot_send_message: 1, max_udp_retrans: 2, unexpected_message: 0, call_rejected: 0, regexp_no_match: 0, regexp_hdr_not_found: 0, out_of_call: 0 } + }, + deltas: null + }]); + + expect(result.size).toBe(1); + const agg = result.get('srv1:register:u1'); + expect(agg.currentCalls).toBe(5); + expect(agg.totalCalls).toBe(100); + expect(agg.failureBreakdown.cannot_send_message).toBe(1); + }); + + test('groups and sums files with same server/scenario/transport', () => { + const result = statsTracker.aggregateStats([ + { + metadata: { serverId: 'srv1', scenario: 'register', transport: 'u1' }, + stats: { currentCalls: 5, totalCalls: 100, successfulCalls: 90, failedCalls: 10, failureBreakdown: { cannot_send_message: 0, max_udp_retrans: 0, unexpected_message: 0, call_rejected: 0, regexp_no_match: 0, regexp_hdr_not_found: 0, out_of_call: 0 } }, + deltas: { totalCallsDelta: 10, timeDeltaSec: 5 } + }, + { + metadata: { serverId: 'srv1', scenario: 'register', transport: 'u1' }, + stats: { currentCalls: 3, totalCalls: 80, successfulCalls: 70, failedCalls: 5, failureBreakdown: { cannot_send_message: 0, max_udp_retrans: 0, unexpected_message: 0, call_rejected: 0, regexp_no_match: 0, regexp_hdr_not_found: 0, out_of_call: 0 } }, + deltas: { totalCallsDelta: 8, timeDeltaSec: 5 } + } + ]); + + expect(result.size).toBe(1); + const agg = result.get('srv1:register:u1'); + expect(agg.currentCalls).toBe(8); + expect(agg.totalCalls).toBe(180); + expect(agg.successfulCalls).toBe(160); + expect(agg.failedCalls).toBe(15); + expect(agg.callRate).toBeCloseTo(1.8, 1); // 18 calls / 10 seconds + }); + + test('creates separate entries for different keys', () => { + const result = statsTracker.aggregateStats([ + { + metadata: { serverId: 'srv1', scenario: 'register', transport: 'u1' }, + stats: { currentCalls: 5, totalCalls: 100, successfulCalls: 90, failedCalls: 10, failureBreakdown: { cannot_send_message: 0, max_udp_retrans: 0, unexpected_message: 0, call_rejected: 0, regexp_no_match: 0, regexp_hdr_not_found: 0, out_of_call: 0 } }, + deltas: null + }, + { + metadata: { serverId: 'srv2', scenario: 'inbound', transport: 't1' }, + stats: { currentCalls: 3, totalCalls: 50, successfulCalls: 45, failedCalls: 5, failureBreakdown: { cannot_send_message: 0, max_udp_retrans: 0, unexpected_message: 0, call_rejected: 0, regexp_no_match: 0, regexp_hdr_not_found: 0, out_of_call: 0 } }, + deltas: null + } + ]); + + expect(result.size).toBe(2); + expect(result.has('srv1:register:u1')).toBe(true); + expect(result.has('srv2:inbound:t1')).toBe(true); + }); + }); + + describe('getCacheStats', () => { + test('returns zeros when empty', () => { + const stats = statsTracker.getCacheStats(); + expect(stats).toEqual({ totalFiles: 0, staleFiles: 0, activeFiles: 0 }); + }); + + test('returns correct counts after adding files', () => { + statsTracker.updateFileState('/test/a.csv', { totalCalls: 1 }, 100); + statsTracker.updateFileState('/test/b.csv', { totalCalls: 2 }, 200); + const stats = statsTracker.getCacheStats(); + expect(stats.totalFiles).toBe(2); + expect(stats.activeFiles).toBe(2); + expect(stats.staleFiles).toBe(0); + }); + }); + + describe('STALE_TIMEOUT_MS', () => { + test('is exported and equals 5 minutes', () => { + expect(statsTracker.STALE_TIMEOUT_MS).toBe(300000); + }); + }); +}); diff --git a/test/lib/utils-file.test.js b/test/lib/utils-file.test.js new file mode 100644 index 0000000..0fb8cd4 --- /dev/null +++ b/test/lib/utils-file.test.js @@ -0,0 +1,155 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +let tmpDir; +let originalCwd; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'utils-file-test-')); + originalCwd = process.cwd(); + process.chdir(tmpDir); +}); + +afterAll(() => { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// Require after chdir so file paths resolve correctly +const { addToCsv, addToCsvNumber } = require('../../lib/utils'); + +describe('addToCsv', () => { + test('creates directory and file with SEQUENTIAL header', () => { + const data = { + displayName: 'John Doe', + device: 'jdoe-dev1', + domain: 'testcorp.com', + 'device-sip-registration-password': 'secret123' + }; + + addToCsv(data); + + // Wait for async file operations + return new Promise((resolve) => { + setTimeout(() => { + const filePath = path.join(tmpDir, 'sipp/csv/devices/testcorp.com.csv'); + expect(fs.existsSync(filePath)).toBe(true); + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('SEQUENTIAL'); + expect(content).toContain('jdoe-dev1'); + expect(content).toContain('testcorp.com'); + expect(content).toContain('secret123'); + resolve(); + }, 200); + }); + }); + + test('does not duplicate entries', () => { + const data = { + displayName: 'Jane Smith', + device: 'jsmith-dev1', + domain: 'dedup.com', + 'device-sip-registration-password': 'pass456' + }; + + addToCsv(data); + + return new Promise((resolve) => { + setTimeout(() => { + // Add same device again after first write completes + addToCsv(data); + setTimeout(() => { + const filePath = path.join(tmpDir, 'sipp/csv/devices/dedup.com.csv'); + const content = fs.readFileSync(filePath, 'utf-8'); + // Check for the dedup marker: ;device;domain; + const matches = content.match(/;jsmith-dev1;dedup\.com;/g); + expect(matches).toHaveLength(1); + resolve(); + }, 500); + }, 500); + }); + }, 10000); + + test('uses server-specific path when serverId provided', () => { + const data = { + displayName: 'Test User', + device: 'tuser-dev1', + domain: 'servertest.com', + 'device-sip-registration-password': 'pass789' + }; + + addToCsv(data, 'srv1'); + + return new Promise((resolve) => { + setTimeout(() => { + const filePath = path.join(tmpDir, 'sipp/csv/servers/srv1/devices/servertest.com.csv'); + expect(fs.existsSync(filePath)).toBe(true); + resolve(); + }, 200); + }); + }); +}); + +describe('addToCsvNumber', () => { + test('creates directory and file with RANDOM header', () => { + const data = { + phonenumber: '5551234567', + domain: 'testcorp.com', + 'dial-rule-description': 'Inbound', + time_zone: 'US/Eastern' + }; + + addToCsvNumber(data); + + return new Promise((resolve) => { + setTimeout(() => { + const filePath = path.join(tmpDir, 'sipp/csv/phonenumbers/US_Eastern.csv'); + expect(fs.existsSync(filePath)).toBe(true); + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('RANDOM'); + expect(content).toContain('5551234567'); + expect(content).toContain('testcorp.com'); + resolve(); + }, 200); + }); + }); + + test('replaces US/ with US_ in timezone for filename', () => { + const data = { + phonenumber: '5559876543', + domain: 'tztest.com', + 'dial-rule-description': 'Inbound', + time_zone: 'US/Pacific' + }; + + addToCsvNumber(data); + + return new Promise((resolve) => { + setTimeout(() => { + const filePath = path.join(tmpDir, 'sipp/csv/phonenumbers/US_Pacific.csv'); + expect(fs.existsSync(filePath)).toBe(true); + resolve(); + }, 200); + }); + }); + + test('uses server-specific path when serverId provided', () => { + const data = { + phonenumber: '5551111111', + domain: 'srvnum.com', + 'dial-rule-description': 'Inbound', + time_zone: 'US/Central' + }; + + addToCsvNumber(data, 'srv2'); + + return new Promise((resolve) => { + setTimeout(() => { + const filePath = path.join(tmpDir, 'sipp/csv/servers/srv2/phonenumbers/US_Central.csv'); + expect(fs.existsSync(filePath)).toBe(true); + resolve(); + }, 200); + }); + }); +}); diff --git a/test/lib/utils.test.js b/test/lib/utils.test.js new file mode 100644 index 0000000..f2005ae --- /dev/null +++ b/test/lib/utils.test.js @@ -0,0 +1,93 @@ +const { randomIntFromInterval, toHex, getDomainSize } = require('../../lib/utils'); + +describe('utils', () => { + describe('randomIntFromInterval', () => { + test('returns value within range', () => { + for (let i = 0; i < 100; i++) { + const result = randomIntFromInterval(1, 10); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(10); + } + }); + + test('returns the value when min equals max', () => { + expect(randomIntFromInterval(5, 5)).toBe(5); + }); + + test('returns an integer', () => { + for (let i = 0; i < 50; i++) { + const result = randomIntFromInterval(1, 100); + expect(Number.isInteger(result)).toBe(true); + } + }); + }); + + describe('toHex', () => { + test('converts ASCII string to hex with non-digits stripped', () => { + // 'abc' -> hex '616263' -> all digits, so '616263' + expect(toHex('abc')).toBe('616263'); + }); + + test('strips hex letter characters from result', () => { + // 'z' -> charCode 122 -> hex '7a' -> strip non-digits -> '7' + expect(toHex('z')).toBe('7'); + }); + + test('returns empty string for empty input', () => { + expect(toHex('')).toBe(''); + }); + + test('handles numeric string input', () => { + // '1' -> charCode 49 -> hex '31' -> '31' + expect(toHex('1')).toBe('31'); + }); + }); + + describe('getDomainSize', () => { + test('returns 10000 for i=1000 regardless of domain', () => { + expect(getDomainSize('anything', 1000)).toBe(10000); + expect(getDomainSize('test.com', 1000)).toBe(10000); + }); + + test('is deterministic for the same inputs', () => { + const result1 = getDomainSize('example.com', 5); + const result2 = getDomainSize('example.com', 5); + expect(result1).toBe(result2); + }); + + test('returns different sizes for different domains', () => { + const result1 = getDomainSize('alpha.com', 1); + const result2 = getDomainSize('beta.com', 1); + // Not guaranteed but very likely different + // At minimum, both should be valid positive integers + expect(result1).toBeGreaterThan(0); + expect(result2).toBeGreaterThan(0); + }); + + test('returns value in normal range for typical domains', () => { + // Most domains should fall in the 5-50 range + const result = getDomainSize('normalcompany.com', 10); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(2500); + }); + + test('reduces size for i > 500 when domainSize > 10', () => { + const domain = 'test.com'; + const earlyResult = getDomainSize(domain, 100); + const lateResult = getDomainSize(domain, 600); + // Late entries (i>500) with size>10 get divided by 4 + // So late result should generally be smaller + if (earlyResult > 10) { + expect(lateResult).toBeLessThanOrEqual(earlyResult); + } + }); + + test('always returns a positive integer', () => { + for (let i = 0; i < 20; i++) { + const result = getDomainSize(`domain${i}.com`, i); + expect(Number.isInteger(result)).toBe(true); + expect(result).toBeGreaterThanOrEqual(1); + } + }); + }); +}); diff --git a/test/metrics-server.test.js b/test/metrics-server.test.js new file mode 100644 index 0000000..9dad40d --- /dev/null +++ b/test/metrics-server.test.js @@ -0,0 +1,37 @@ +const request = require('supertest'); +const { app } = require('../metrics-server'); + +describe('metrics-server HTTP endpoints', () => { + describe('GET /', () => { + test('returns 200 with HTML containing title', async () => { + const res = await request(app).get('/'); + expect(res.status).toBe(200); + expect(res.text).toContain('SIPp Prometheus Metrics Server'); + }); + }); + + describe('GET /health', () => { + test('returns 200 with JSON status ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body).toHaveProperty('uptime'); + expect(res.body).toHaveProperty('activeFiles'); + expect(res.body).toHaveProperty('settings'); + expect(res.body.settings).toHaveProperty('updateIntervalSec'); + }); + }); + + describe('GET /metrics', () => { + test('returns 200 with prometheus content type', async () => { + const res = await request(app).get('/metrics'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain|application\/openmetrics/); + }); + + test('response contains expected metric names', async () => { + const res = await request(app).get('/metrics'); + expect(res.text).toContain('sipp_stats_files_active'); + }); + }); +});