Files
adam-acs/tests/unit/services/exchange_metrics_service.spec.ts
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

270 lines
9.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ExchangeMetricsService } from '#services/exchange_metrics_service'
import type { MetricsSummary } from '#services/exchange_metrics_service'
// Mock the logger
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}))
describe('ExchangeMetricsService', () => {
beforeEach(() => {
ExchangeMetricsService.resetInstance()
})
afterEach(() => {
ExchangeMetricsService.resetInstance()
})
describe('singleton', () => {
it('should return the same instance on multiple calls', () => {
const instance1 = ExchangeMetricsService.getInstance()
const instance2 = ExchangeMetricsService.getInstance()
expect(instance1).toBe(instance2)
})
it('should create a fresh instance after resetInstance', () => {
const instance1 = ExchangeMetricsService.getInstance()
ExchangeMetricsService.resetInstance()
const instance2 = ExchangeMetricsService.getInstance()
expect(instance1).not.toBe(instance2)
})
})
describe('recordMongoWriteLatency', () => {
it('should record samples and compute correct percentiles', () => {
const metrics = ExchangeMetricsService.getInstance()
// Record 100 samples: 1, 2, 3, ..., 100
for (let i = 1; i <= 100; i++) {
metrics.recordMongoWriteLatency(i)
}
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.mongoWriteLatency).not.toBeNull()
const summary = aggregated.mongoWriteLatency!
expect(summary.count).toBe(100)
expect(summary.min).toBe(1)
expect(summary.max).toBe(100)
expect(summary.p50).toBe(50)
expect(summary.p95).toBe(95)
expect(summary.p99).toBe(99)
expect(summary.avg).toBe(50.5)
})
it('should return null when no samples exist', () => {
const metrics = ExchangeMetricsService.getInstance()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.mongoWriteLatency).toBeNull()
})
})
describe('recordSsePushLatency', () => {
it('should record and compute SSE push latency percentiles', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordSsePushLatency(0.5)
metrics.recordSsePushLatency(1.2)
metrics.recordSsePushLatency(2.8)
metrics.recordSsePushLatency(0.3)
metrics.recordSsePushLatency(15.0)
const aggregated = metrics.getAggregatedMetrics()
const summary = aggregated.ssePushLatency!
expect(summary.count).toBe(5)
expect(summary.min).toBe(0.3)
expect(summary.max).toBe(15.0)
})
})
describe('recordApiResponseTime', () => {
it('should track separate metrics per endpoint', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordApiResponseTime('GET /exchanges', 10)
metrics.recordApiResponseTime('GET /exchanges', 20)
metrics.recordApiResponseTime('DELETE /exchanges', 5)
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.apiResponseTimes['GET /exchanges']).toBeDefined()
expect(aggregated.apiResponseTimes['GET /exchanges'].count).toBe(2)
expect(aggregated.apiResponseTimes['GET /exchanges'].avg).toBe(15)
expect(aggregated.apiResponseTimes['DELETE /exchanges']).toBeDefined()
expect(aggregated.apiResponseTimes['DELETE /exchanges'].count).toBe(1)
expect(aggregated.apiResponseTimes['DELETE /exchanges'].avg).toBe(5)
})
it('should handle new endpoint names dynamically', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordApiResponseTime('GET /exchanges/stream', 42)
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.apiResponseTimes['GET /exchanges/stream']).toBeDefined()
expect(aggregated.apiResponseTimes['GET /exchanges/stream'].count).toBe(1)
})
})
describe('computeSummary', () => {
it('should return null for empty array', () => {
const metrics = ExchangeMetricsService.getInstance()
const result = metrics.computeSummary([])
expect(result).toBeNull()
})
it('should handle single sample', () => {
const metrics = ExchangeMetricsService.getInstance()
const result = metrics.computeSummary([42])!
expect(result.count).toBe(1)
expect(result.p50).toBe(42)
expect(result.p95).toBe(42)
expect(result.p99).toBe(42)
expect(result.min).toBe(42)
expect(result.max).toBe(42)
expect(result.avg).toBe(42)
})
it('should compute correct percentiles for known distribution', () => {
const metrics = ExchangeMetricsService.getInstance()
// 10 samples: 1..10
const samples = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const result = metrics.computeSummary(samples)!
expect(result.count).toBe(10)
expect(result.min).toBe(1)
expect(result.max).toBe(10)
expect(result.p50).toBe(5)
expect(result.p95).toBe(10) // ceil(0.95 * 10) - 1 = index 9 = 10
expect(result.p99).toBe(10) // ceil(0.99 * 10) - 1 = index 9 = 10
expect(result.avg).toBe(5.5)
})
it('should handle unordered input', () => {
const metrics = ExchangeMetricsService.getInstance()
const samples = [5, 1, 3, 2, 4]
const result = metrics.computeSummary(samples)!
expect(result.min).toBe(1)
expect(result.max).toBe(5)
expect(result.p50).toBe(3) // ceil(0.5*5)-1 = index 2 = 3 (sorted: 1,2,3,4,5)
})
})
describe('flush', () => {
it('should reset all samples after flush', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordMongoWriteLatency(10)
metrics.recordSsePushLatency(5)
metrics.recordApiResponseTime('GET /exchanges', 20)
metrics.flush()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.mongoWriteLatency).toBeNull()
expect(aggregated.ssePushLatency).toBeNull()
expect(Object.keys(aggregated.apiResponseTimes)).toHaveLength(0)
})
it('should return aggregated metrics at time of flush', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordMongoWriteLatency(10)
metrics.recordMongoWriteLatency(20)
const flushed = metrics.flush()
expect(flushed.mongoWriteLatency).not.toBeNull()
expect(flushed.mongoWriteLatency!.count).toBe(2)
})
it('should log metrics when there is data', async () => {
const loggerModule = await import('@adonisjs/core/services/logger')
const mockLogger = loggerModule.default
const metrics = ExchangeMetricsService.getInstance()
metrics.recordMongoWriteLatency(10)
metrics.flush()
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({ metrics: expect.any(Object) }),
'[ExchangeMetrics] Aggregated performance metrics'
)
})
it('should not log when there is no data', async () => {
const loggerModule = await import('@adonisjs/core/services/logger')
const mockLogger = loggerModule.default
vi.clearAllMocks()
const metrics = ExchangeMetricsService.getInstance()
metrics.flush()
expect(mockLogger.info).not.toHaveBeenCalled()
})
})
describe('getAggregatedMetrics', () => {
it('should include collectedAt ISO timestamp', () => {
const metrics = ExchangeMetricsService.getInstance()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.collectedAt).toBeDefined()
// Verify it's a valid ISO 8601 date
const date = new Date(aggregated.collectedAt)
expect(date.getTime()).not.toBeNaN()
})
it('should include windowMs representing the collection window', () => {
const metrics = ExchangeMetricsService.getInstance()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.windowMs).toBeGreaterThanOrEqual(0)
})
})
describe('max samples cap', () => {
it('should evict oldest samples when max is exceeded', () => {
// Use a small max for testing
ExchangeMetricsService.resetInstance()
const metrics = ExchangeMetricsService.getInstance(60_000, 5)
// Record 7 samples: only last 5 should be kept
for (let i = 1; i <= 7; i++) {
metrics.recordMongoWriteLatency(i)
}
const aggregated = metrics.getAggregatedMetrics()
const summary = aggregated.mongoWriteLatency!
expect(summary.count).toBe(5)
expect(summary.min).toBe(3) // oldest (1,2) evicted
expect(summary.max).toBe(7)
})
})
describe('start/stop', () => {
it('should start and stop without errors', () => {
const metrics = ExchangeMetricsService.getInstance()
expect(() => metrics.start()).not.toThrow()
expect(() => metrics.stop()).not.toThrow()
})
it('should be idempotent on multiple starts', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.start()
metrics.start() // second call should not create duplicate timers
metrics.stop()
})
})
})