Files
adam-acs/tests/unit/services/exchange_store_retry.spec.ts
T
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

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