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
This commit is contained in:
Kevin L'Hereec committed 2026-06-03 15:52:44 +02:00
1 parent 3fefbf6c40
commit 0c83a4ccb2
50 files changed
+15731 -43

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,388 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// --- Mocks ---
const mockFindByDevice = vi.fn()
const mockHasExchanges = vi.fn()
const mockGetMetadata = vi.fn()
const mockDeleteByDevice = vi.fn()
vi.mock('#models/exchange', () => ({
default: {
findByDevice: (...args: any[]) => mockFindByDevice(...args),
hasExchanges: (...args: any[]) => mockHasExchanges(...args),
getMetadata: (...args: any[]) => mockGetMetadata(...args),
deleteByDevice: (...args: any[]) => mockDeleteByDevice(...args),
},
}))
const mockIsTraced = vi.fn()
const mockList = vi.fn()
vi.mock('#services/tr069_trace_service', () => ({
Tr069TraceService: {
getInstance: () => ({
isTraced: mockIsTraced,
list: mockList,
}),
},
}))
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: () => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
readTimeoutMs: 5000,
sse: {
inactivityTimeoutMs: 300_000,
absoluteTimeoutMs: 7_200_000,
maxConnectionsPerDevice: 5,
retryIntervalMs: 3000,
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}))
import ExchangeController from '../../../app/controllers/exchange_controller.js'
// --- Helpers ---
function createHttpContext(overrides: {
params?: Record<string, string>
qs?: Record<string, any>
} = {}) {
const responseData: { status?: number; body?: any } = {}
const response = {
status(code: number) {
responseData.status = code
return this
},
json(data: any) {
responseData.body = data
return { status: responseData.status, body: data }
},
send(data: any) {
responseData.body = data
return { status: responseData.status, body: data }
},
}
const request = {
qs: () => overrides.qs || {},
id: () => 'test-correlation-id',
}
return {
params: overrides.params || {},
request,
response,
responseData,
} as any
}
describe('ExchangeController', () => {
let controller: ExchangeController
beforeEach(() => {
vi.clearAllMocks()
controller = new ExchangeController()
mockList.mockReturnValue([])
})
describe('index()', () => {
it('returns 404 when device has never been traced and has no exchanges', async () => {
mockIsTraced.mockReturnValue(false)
mockHasExchanges.mockResolvedValue(false)
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(404)
expect(result.body.error).toContain('No trace exists')
})
it('returns 200 with empty array when device is traced but has no messages', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(false)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.exchanges).toEqual([])
expect(result.body.metadata.traceStatus).toBe('ACTIVE')
expect(result.body.metadata.total).toBe(0)
})
it('returns 200 with exchanges for actively traced device', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
const exchanges = [
{ deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' },
{ deviceId: 'device-123', direction: 'ACS→CPE', timestamp: '2025-01-15T10:30:01.000Z', cwmpMethod: 'InformResponse' },
]
mockFindByDevice.mockResolvedValue(exchanges)
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:30:01.000Z',
count: 2,
})
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.exchanges).toHaveLength(2)
expect(result.body.metadata.traceStatus).toBe('ACTIVE')
expect(result.body.metadata.traceStoppedAt).toBeNull()
expect(result.body.metadata.recordedFrom).toBe('2025-01-15T10:30:00.000Z')
expect(result.body.metadata.recordedUntil).toBe('2025-01-15T10:30:01.000Z')
expect(result.body.metadata.total).toBe(2)
})
it('returns 200 with STOPPED status for previously traced device', async () => {
mockIsTraced.mockReturnValue(false)
mockHasExchanges.mockResolvedValue(true)
const exchanges = [
{ deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' },
]
mockFindByDevice.mockResolvedValue(exchanges)
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:35:00.000Z',
count: 1,
})
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.metadata.traceStatus).toBe('STOPPED')
expect(result.body.metadata.traceStoppedAt).toBe('2025-01-15T10:35:00.000Z')
})
it('uses default limit of 200 when not specified', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 200, after: undefined, before: undefined, latest: false })
})
it('accepts valid limit parameter', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '50' } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 50, after: undefined, before: undefined, latest: false })
})
it('clamps limit to 1000', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '5000' } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 1000, after: undefined, before: undefined, latest: false })
})
it('returns 400 for non-numeric limit', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: 'abc' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('returns 400 for limit less than 1', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '0' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('returns 400 for negative limit', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '-5' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('returns 400 for float limit', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '2.5' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('accepts valid after parameter', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const after = '2025-01-15T10:30:00.000Z'
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 200, after, before: undefined, latest: false })
})
it('returns 400 for invalid after parameter', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after: 'not-a-date' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid after')
})
it('returns 400 for partial ISO 8601 timestamp in after', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after: '2025-13-45' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid after')
})
it('returns partial results with isTruncated: true when query exceeds 5s timeout', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
// Simulate a slow query that never resolves within the timeout
mockFindByDevice.mockImplementation(() => new Promise((resolve) => {
setTimeout(() => resolve([{ deviceId: 'device-123', cwmpMethod: 'Inform' }]), 10_000)
}))
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:35:00.000Z',
count: 42,
})
vi.useFakeTimers()
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const resultPromise = controller.index(ctx)
// Advance timers past the 5s timeout
await vi.advanceTimersByTimeAsync(5001)
const result = await resultPromise
expect(result.status).toBe(200)
expect(result.body.exchanges).toEqual([])
expect(result.body.metadata.isTruncated).toBe(true)
expect(result.body.metadata.total).toBe(42)
vi.useRealTimers()
})
it('does not include isTruncated flag when query completes within timeout', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
const exchanges = [
{ deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' },
]
mockFindByDevice.mockResolvedValue(exchanges)
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:30:00.000Z',
count: 1,
})
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.exchanges).toHaveLength(1)
expect(result.body.metadata.isTruncated).toBeUndefined()
expect(result.body.metadata.total).toBe(1)
})
it('includes total count in metadata even when truncated', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockImplementation(() => new Promise((resolve) => {
setTimeout(() => resolve([]), 10_000)
}))
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:00:00.000Z',
recordedUntil: '2025-01-15T12:00:00.000Z',
count: 3500,
})
vi.useFakeTimers()
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const resultPromise = controller.index(ctx)
await vi.advanceTimersByTimeAsync(5001)
const result = await resultPromise
expect(result.status).toBe(200)
expect(result.body.metadata.isTruncated).toBe(true)
expect(result.body.metadata.total).toBe(3500)
vi.useRealTimers()
})
})
describe('destroy()', () => {
it('returns 204 with no body', async () => {
mockDeleteByDevice.mockResolvedValue(5)
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.destroy(ctx)
expect(result.status).toBe(204)
expect(mockDeleteByDevice).toHaveBeenCalledWith('device-123')
})
it('returns 204 even when no exchanges existed', async () => {
mockDeleteByDevice.mockResolvedValue(0)
const ctx = createHttpContext({ params: { deviceId: 'device-456' } })
const result = await controller.destroy(ctx)
expect(result.status).toBe(204)
expect(mockDeleteByDevice).toHaveBeenCalledWith('device-456')
})
})
})
@@ -0,0 +1,369 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
// --- Mocks ---
const mockFindByDevice = vi.fn()
const mockHasExchanges = vi.fn()
const mockGetMetadata = vi.fn()
const mockDeleteByDevice = vi.fn()
const mockFindAfterSequence = vi.fn()
vi.mock('#models/exchange', () => ({
default: {
findByDevice: (...args: any[]) => mockFindByDevice(...args),
hasExchanges: (...args: any[]) => mockHasExchanges(...args),
getMetadata: (...args: any[]) => mockGetMetadata(...args),
deleteByDevice: (...args: any[]) => mockDeleteByDevice(...args),
findAfterSequence: (...args: any[]) => mockFindAfterSequence(...args),
},
}))
const mockIsTraced = vi.fn()
vi.mock('#services/tr069_trace_service', () => ({
Tr069TraceService: {
getInstance: () => ({
isTraced: mockIsTraced,
}),
},
}))
const mockEmitter = new EventEmitter()
const mockSubscribe = vi.fn((deviceId: string, listener: Function) => {
mockEmitter.on(`exchange:${deviceId}`, listener as any)
})
const mockUnsubscribe = vi.fn((deviceId: string, listener: Function) => {
mockEmitter.off(`exchange:${deviceId}`, listener as any)
})
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: () => ({
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
}),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
sse: {
inactivityTimeoutMs: 300_000,
absoluteTimeoutMs: 7_200_000,
maxConnectionsPerDevice: 5,
retryIntervalMs: 3000,
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}))
import ExchangeController from '../../../app/controllers/exchange_controller.js'
// --- Helpers ---
function createStreamContext(options: {
deviceId: string
lastEventId?: string
}) {
const written: string[] = []
const headers: Record<string, string> = {}
let headWritten = false
let statusCode = 0
let ended = false
const reqEmitter = new EventEmitter()
const nodeRes = {
writeHead: vi.fn((code: number, hdrs: Record<string, string>) => {
statusCode = code
Object.assign(headers, hdrs)
headWritten = true
}),
write: vi.fn((chunk: string) => {
written.push(chunk)
}),
end: vi.fn(() => {
ended = true
}),
}
const responseObj = {
response: nodeRes,
status: vi.fn(function (this: any, code: number) {
statusCode = code
return this
}),
json: vi.fn((data: any) => data),
}
const ctx = {
params: { deviceId: options.deviceId },
request: {
header: vi.fn((name: string) => {
if (name === 'Last-Event-ID') return options.lastEventId
return undefined
}),
id: vi.fn(() => 'test-request-id'),
request: reqEmitter,
},
response: responseObj,
}
return {
ctx: ctx as any,
nodeRes,
reqEmitter,
getWritten: () => written,
getHeaders: () => headers,
isHeadWritten: () => headWritten,
isEnded: () => ended,
getStatusCode: () => statusCode,
}
}
describe('ExchangeController.stream()', () => {
let controller: ExchangeController
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
controller = new ExchangeController()
mockFindAfterSequence.mockResolvedValue([])
})
afterEach(() => {
vi.useRealTimers()
mockEmitter.removeAllListeners()
})
it('returns 404 if device is not traced', async () => {
mockIsTraced.mockReturnValue(false)
const { ctx, getStatusCode } = createStreamContext({ deviceId: 'device-unknown' })
await controller.stream(ctx)
expect(ctx.response.status).toHaveBeenCalledWith(404)
expect(ctx.response.json).toHaveBeenCalledWith({ error: 'Device is not being traced' })
})
it('sets correct SSE headers on traced device', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, getHeaders, getStatusCode } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(getStatusCode()).toBe(200)
expect(getHeaders()).toEqual({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
})
})
it('sends retry interval as first SSE message', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, getWritten } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(getWritten()[0]).toBe('retry: 3000\n\n')
})
it('subscribes to ExchangeStoreService events for the device', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(mockSubscribe).toHaveBeenCalledWith('device-1', expect.any(Function))
})
it('pushes new exchanges as SSE events with id: sequenceNumber', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, getWritten } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
const exchange = {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: '2025-01-15T10:30:00.123Z',
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: 'Inform',
rawXml: '<soap-env:Envelope />',
sequenceNumber: 42,
}
// Emit an exchange event
mockEmitter.emit('exchange:device-1', exchange)
const written = getWritten()
const sseEvent = written[written.length - 1]
expect(sseEvent).toBe(`id: 42\ndata: ${JSON.stringify(exchange)}\n\n`)
})
it('handles Last-Event-ID for reconnection — sends missed exchanges', async () => {
mockIsTraced.mockReturnValue(true)
const missedExchanges = [
{
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: '2025-01-15T10:30:01.000Z',
sessionId: 'sess-1',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 6,
},
{
deviceId: 'device-1',
direction: 'ACS→CPE',
timestamp: '2025-01-15T10:30:02.000Z',
sessionId: 'sess-1',
cwmpMethod: 'InformResponse',
rawXml: '<xml/>',
sequenceNumber: 7,
},
]
mockFindAfterSequence.mockResolvedValue(missedExchanges)
const { ctx, getWritten } = createStreamContext({
deviceId: 'device-1',
lastEventId: '5',
})
await controller.stream(ctx)
expect(mockFindAfterSequence).toHaveBeenCalledWith('device-1', 5)
const written = getWritten()
// After retry message, should have 2 missed exchanges
expect(written[1]).toBe(`id: 6\ndata: ${JSON.stringify(missedExchanges[0])}\n\n`)
expect(written[2]).toBe(`id: 7\ndata: ${JSON.stringify(missedExchanges[1])}\n\n`)
})
it('closes connection on client disconnect', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, reqEmitter, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Simulate client disconnect
reqEmitter.emit('close')
expect(isEnded()).toBe(true)
expect(mockUnsubscribe).toHaveBeenCalledWith('device-1', expect.any(Function))
})
it('closes connection after inactivity timeout (5min)', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(isEnded()).toBe(false)
// Advance time by 5 minutes (inactivity timeout)
vi.advanceTimersByTime(300_000)
expect(isEnded()).toBe(true)
expect(mockUnsubscribe).toHaveBeenCalledWith('device-1', expect.any(Function))
})
it('closes connection after absolute timeout (2h)', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Send an exchange to reset inactivity timer at 4min
vi.advanceTimersByTime(240_000)
mockEmitter.emit('exchange:device-1', {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: 'sess',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 1,
})
expect(isEnded()).toBe(false)
// Advance to absolute timeout (2h from start)
vi.advanceTimersByTime(7_200_000 - 240_000)
expect(isEnded()).toBe(true)
})
it('resets inactivity timer when a new exchange is pushed', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Advance 4 minutes (just under the 5min inactivity timeout)
vi.advanceTimersByTime(240_000)
expect(isEnded()).toBe(false)
// Emit an exchange event → resets inactivity timer
mockEmitter.emit('exchange:device-1', {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: 'sess',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 1,
})
// Advance another 4 minutes (still within reset inactivity window)
vi.advanceTimersByTime(240_000)
expect(isEnded()).toBe(false)
// Advance 1 more minute to exceed the 5min inactivity window
vi.advanceTimersByTime(60_001)
expect(isEnded()).toBe(true)
})
it('does not push events after connection is closed', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, reqEmitter, nodeRes } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Close connection
reqEmitter.emit('close')
// Clear the write mock to check no further writes
nodeRes.write.mockClear()
// Emit exchange after close
mockEmitter.emit('exchange:device-1', {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: 'sess',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 99,
})
expect(nodeRes.write).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock dependencies before importing the controller
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: vi.fn(),
}))
vi.mock('#services/exchange_index_checker', () => ({
verifyCwmpExchangesIndexes: vi.fn(),
}))
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: vi.fn(),
},
}))
import { getMongoDb } from '#providers/mongodb_provider'
import { verifyCwmpExchangesIndexes } from '#services/exchange_index_checker'
import { ExchangeStoreService } from '#services/exchange_store_service'
import HealthController from '#controllers/health_controller'
describe('HealthController.checkTr069Exchanges', () => {
let controller: HealthController
let mockResponse: any
let mockContext: any
beforeEach(() => {
vi.clearAllMocks()
controller = new HealthController()
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
}
mockContext = {
response: mockResponse,
}
})
it('returns healthy status when MongoDB is connected with low latency and all indexes present', async () => {
const mockDb = {
command: vi.fn().mockResolvedValue({ ok: 1 }),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(500),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [
{ name: 'idx_deviceId_timestamp', description: 'Compound index', exists: true },
{ name: 'idx_deviceId_sequenceNumber', description: 'Unique index', exists: true },
{ name: 'idx_createdAt_ttl', description: 'TTL index', exists: true },
],
})
// Mock collStats command
mockDb.command.mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 1048576 }) // 1MB
return Promise.resolve({})
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 3,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'healthy',
mongodb: expect.objectContaining({
connected: true,
latency_ms: expect.any(Number),
collections: {
cwmp_exchanges: {
count: 500,
size_mb: expect.any(Number),
},
},
indexes: expect.objectContaining({
allPresent: true,
}),
}),
sse: {
active_connections: 3,
},
timestamp: expect.any(String),
})
)
})
it('returns unhealthy status when MongoDB is unreachable', async () => {
vi.mocked(getMongoDb).mockImplementation(() => {
throw new Error('MongoDB is not connected')
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(503)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'unhealthy',
mongodb: expect.objectContaining({
connected: false,
latency_ms: null,
collections: null,
indexes: null,
}),
})
)
})
it('returns degraded status when MongoDB latency is >= 100ms', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) {
// Simulate a delay >= 100ms by manipulating Date.now
return new Promise((resolve) => setTimeout(() => resolve({ ok: 1 }), 105))
}
if (cmd.collStats) return Promise.resolve({ size: 2097152 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(1000),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 5,
} as any)
await controller.checkTr069Exchanges(mockContext)
// The test may be flaky due to timing, so we check both possible outcomes
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.mongodb.connected).toBe(true)
// Latency >= 100ms would be degraded, otherwise healthy
if (jsonCall.mongodb.latency_ms >= 100) {
expect(jsonCall.status).toBe('degraded')
expect(mockResponse.status).toHaveBeenCalledWith(200)
} else {
expect(jsonCall.status).toBe('healthy')
}
})
it('returns degraded status when indexes are missing', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 512000 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(200),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: false,
indexes: [
{ name: 'idx_deviceId_timestamp', description: 'Compound index', exists: true },
{ name: 'idx_deviceId_sequenceNumber', description: 'Unique index', exists: false },
{ name: 'idx_createdAt_ttl', description: 'TTL index', exists: true },
],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 1,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'degraded',
mongodb: expect.objectContaining({
connected: true,
indexes: expect.objectContaining({
allPresent: false,
}),
}),
})
)
})
it('reports SSE active connection count from ExchangeStoreService', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 0 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 42,
} as any)
await controller.checkTr069Exchanges(mockContext)
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.sse.active_connections).toBe(42)
})
it('handles collStats failure gracefully (size defaults to 0)', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.reject(new Error('ns not found'))
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.mongodb.collections.cwmp_exchanges.size_mb).toBe(0)
})
it('includes a valid ISO 8601 timestamp in the response', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 0 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
const jsonCall = mockResponse.json.mock.calls[0][0]
const date = new Date(jsonCall.timestamp)
expect(date.getTime()).not.toBeNaN()
})
})