- 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
389 lines
13 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|