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:
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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user