- 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
301 lines
9.0 KiB
TypeScript
301 lines
9.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// Hoist mocks before any module resolution
|
|
const loggerMock = vi.hoisted(() => ({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
}))
|
|
|
|
const insertOneMock = vi.hoisted(() => vi.fn())
|
|
const deleteOldestBeyondCapMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
|
const findOneAndUpdateMock = vi.hoisted(() => vi.fn().mockResolvedValue({ seq: 1 }))
|
|
|
|
vi.mock('@adonisjs/core/services/logger', () => ({
|
|
default: loggerMock,
|
|
}))
|
|
|
|
vi.mock('#start/env', () => ({
|
|
default: {
|
|
get: (key: string, defaultValue?: any) => defaultValue,
|
|
},
|
|
}))
|
|
|
|
vi.mock('#models/exchange', () => ({
|
|
default: {
|
|
getCollection: () => ({
|
|
insertOne: insertOneMock,
|
|
}),
|
|
deleteOldestBeyondCap: deleteOldestBeyondCapMock,
|
|
},
|
|
}))
|
|
|
|
vi.mock('#providers/mongodb_provider', () => ({
|
|
getMongoDb: () => ({
|
|
collection: () => ({
|
|
findOneAndUpdate: findOneAndUpdateMock,
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('#config/exchange_store', () => ({
|
|
default: {
|
|
maxMessagesPerDevice: 5000,
|
|
retry: {
|
|
maxAttempts: 3,
|
|
baseDelayMs: 1000,
|
|
},
|
|
featureFlags: {
|
|
persistence: true,
|
|
sseEnabled: true,
|
|
apiSource: 'mongodb',
|
|
},
|
|
},
|
|
}))
|
|
|
|
import { ExchangeStoreService } from '../../../app/services/exchange_store_service.js'
|
|
import type { CwmpExchange } from '../../../app/models/exchange.js'
|
|
|
|
describe('ExchangeStoreService — persistWithRetry', () => {
|
|
let service: ExchangeStoreService
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
// Reset singleton for testing
|
|
;(ExchangeStoreService as any).instance = undefined
|
|
service = ExchangeStoreService.getInstance()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
const makeCriticalDoc = (method: string = 'Inform'): CwmpExchange => ({
|
|
deviceId: 'device-001',
|
|
direction: 'CPE→ACS',
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
|
|
cwmpMethod: method,
|
|
rawXml: '<soap:Envelope><cwmp:Inform/></soap:Envelope>',
|
|
sequenceNumber: 1,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
const makeNonCriticalDoc = (method: string = 'Empty'): CwmpExchange => ({
|
|
deviceId: 'device-001',
|
|
direction: 'CPE→ACS',
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
|
|
cwmpMethod: method,
|
|
rawXml: '',
|
|
sequenceNumber: 1,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
describe('isCriticalMethod', () => {
|
|
it('classifies Inform as critical', () => {
|
|
expect(service.isCriticalMethod('Inform')).toBe(true)
|
|
})
|
|
|
|
it('classifies GetParameterValues as critical', () => {
|
|
expect(service.isCriticalMethod('GetParameterValues')).toBe(true)
|
|
})
|
|
|
|
it('classifies GetParameterValuesResponse as critical', () => {
|
|
expect(service.isCriticalMethod('GetParameterValuesResponse')).toBe(true)
|
|
})
|
|
|
|
it('classifies SetParameterValues as critical', () => {
|
|
expect(service.isCriticalMethod('SetParameterValues')).toBe(true)
|
|
})
|
|
|
|
it('classifies SetParameterValuesResponse as critical', () => {
|
|
expect(service.isCriticalMethod('SetParameterValuesResponse')).toBe(true)
|
|
})
|
|
|
|
it('classifies AddObject as critical', () => {
|
|
expect(service.isCriticalMethod('AddObject')).toBe(true)
|
|
})
|
|
|
|
it('classifies DeleteObject as critical', () => {
|
|
expect(service.isCriticalMethod('DeleteObject')).toBe(true)
|
|
})
|
|
|
|
it('classifies Download as critical', () => {
|
|
expect(service.isCriticalMethod('Download')).toBe(true)
|
|
})
|
|
|
|
it('classifies Upload as critical', () => {
|
|
expect(service.isCriticalMethod('Upload')).toBe(true)
|
|
})
|
|
|
|
it('classifies Reboot as critical', () => {
|
|
expect(service.isCriticalMethod('Reboot')).toBe(true)
|
|
})
|
|
|
|
it('classifies FactoryReset as critical', () => {
|
|
expect(service.isCriticalMethod('FactoryReset')).toBe(true)
|
|
})
|
|
|
|
it('classifies Fault as critical', () => {
|
|
expect(service.isCriticalMethod('Fault')).toBe(true)
|
|
})
|
|
|
|
it('classifies Empty as non-critical', () => {
|
|
expect(service.isCriticalMethod('Empty')).toBe(false)
|
|
})
|
|
|
|
it('classifies Unknown as non-critical', () => {
|
|
expect(service.isCriticalMethod('Unknown')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('critical messages — retry behavior', () => {
|
|
it('succeeds on first attempt without retries', async () => {
|
|
insertOneMock.mockResolvedValueOnce({ insertedId: 'abc' })
|
|
|
|
await service.persistWithRetry(makeCriticalDoc(), true)
|
|
|
|
expect(insertOneMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('retries up to 3 times with exponential backoff on failure', async () => {
|
|
const error = new Error('MongoDB write failed')
|
|
insertOneMock.mockRejectedValue(error)
|
|
|
|
const promise = service.persistWithRetry(makeCriticalDoc(), true)
|
|
|
|
// Initial attempt fails immediately
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
// Retry 1: after 1s delay (baseDelayMs * 2^0 = 1000ms)
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
// Retry 2: after 2s delay (baseDelayMs * 2^1 = 2000ms)
|
|
await vi.advanceTimersByTimeAsync(2000)
|
|
|
|
// Retry 3: after 4s delay (baseDelayMs * 2^2 = 4000ms)
|
|
await vi.advanceTimersByTimeAsync(4000)
|
|
|
|
await promise
|
|
|
|
// 1 initial attempt + 3 retries = 4 total
|
|
expect(insertOneMock).toHaveBeenCalledTimes(4)
|
|
})
|
|
|
|
it('succeeds on second retry attempt', async () => {
|
|
const error = new Error('MongoDB write failed')
|
|
// Initial attempt fails
|
|
insertOneMock.mockRejectedValueOnce(error)
|
|
// Retry 1 fails
|
|
insertOneMock.mockRejectedValueOnce(error)
|
|
// Retry 2 succeeds
|
|
insertOneMock.mockResolvedValueOnce({ insertedId: 'abc' })
|
|
|
|
const promise = service.persistWithRetry(makeCriticalDoc(), true)
|
|
|
|
// Initial attempt fails
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
// Retry 1 delay: 1000ms
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
// Retry 2 delay: 2000ms
|
|
await vi.advanceTimersByTimeAsync(2000)
|
|
|
|
await promise
|
|
|
|
// 1 initial + 2 retries = 3 calls
|
|
expect(insertOneMock).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('never throws even when all retries are exhausted', async () => {
|
|
insertOneMock.mockRejectedValue(new Error('persistent failure'))
|
|
|
|
const promise = service.persistWithRetry(makeCriticalDoc(), true)
|
|
|
|
await vi.advanceTimersByTimeAsync(10000)
|
|
|
|
// Should not throw
|
|
await expect(promise).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('logs error when all retries exhausted', async () => {
|
|
insertOneMock.mockRejectedValue(new Error('persistent failure'))
|
|
|
|
const promise = service.persistWithRetry(makeCriticalDoc('Inform'), true)
|
|
await vi.advanceTimersByTimeAsync(10000)
|
|
await promise
|
|
|
|
expect(loggerMock.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
deviceId: 'device-001',
|
|
cwmpMethod: 'Inform',
|
|
maxAttempts: 3,
|
|
}),
|
|
'[ExchangeStore] All retries exhausted for critical message — discarding'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('non-critical messages — discard immediately', () => {
|
|
it('discards immediately on first failure (zero retries)', async () => {
|
|
insertOneMock.mockRejectedValueOnce(new Error('write error'))
|
|
|
|
await service.persistWithRetry(makeNonCriticalDoc('Empty'), false)
|
|
|
|
// Only 1 attempt, no retries
|
|
expect(insertOneMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('never throws for non-critical messages', async () => {
|
|
insertOneMock.mockRejectedValueOnce(new Error('write error'))
|
|
|
|
await expect(
|
|
service.persistWithRetry(makeNonCriticalDoc('Unknown'), false)
|
|
).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('persists successfully on first attempt when no error', async () => {
|
|
insertOneMock.mockResolvedValueOnce({ insertedId: 'abc' })
|
|
|
|
await service.persistWithRetry(makeNonCriticalDoc(), false)
|
|
|
|
expect(insertOneMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('logs warning on failure for non-critical messages', async () => {
|
|
insertOneMock.mockRejectedValueOnce(new Error('write error'))
|
|
|
|
await service.persistWithRetry(makeNonCriticalDoc('Empty'), false)
|
|
|
|
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
deviceId: 'device-001',
|
|
cwmpMethod: 'Empty',
|
|
}),
|
|
'[ExchangeStore] Non-critical message persistence failed — discarding'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('CWMP session never interrupted', () => {
|
|
it('critical message failure does not throw', async () => {
|
|
insertOneMock.mockRejectedValue(new Error('catastrophic failure'))
|
|
|
|
const promise = service.persistWithRetry(makeCriticalDoc('Inform'), true)
|
|
await vi.advanceTimersByTimeAsync(10000)
|
|
|
|
await expect(promise).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('non-critical message failure does not throw', async () => {
|
|
insertOneMock.mockRejectedValue(new Error('catastrophic failure'))
|
|
|
|
await expect(
|
|
service.persistWithRetry(makeNonCriticalDoc('Empty'), false)
|
|
).resolves.toBeUndefined()
|
|
})
|
|
})
|
|
})
|