- 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
270 lines
9.0 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|