Files
adam-acs/tests/unit/controllers/exchange_controller.spec.ts
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

389 lines
13 KiB
TypeScript

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')
})
})
})