Files
adam-acs/tests/unit/services/exchange_store_service.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

266 lines
8.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockInsertOne = vi.fn().mockResolvedValue({ insertedId: 'mock-id' })
const mockCollection = vi.fn().mockReturnValue({ insertOne: mockInsertOne })
const mockFindOneAndUpdate = vi.fn().mockResolvedValue({ seq: 1 })
const mockCountersCollection = { findOneAndUpdate: mockFindOneAndUpdate }
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: () => ({
collection: (name: string) => {
if (name === 'cwmp_exchange_counters') return mockCountersCollection
return { insertOne: mockInsertOne }
},
}),
}))
vi.mock('#models/exchange', () => ({
default: {
collectionName: 'cwmp_exchanges',
getCollection: () => ({ insertOne: mockInsertOne }),
countByDevice: vi.fn().mockResolvedValue(0),
deleteOldestBeyondCap: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
retry: {
maxAttempts: 3,
baseDelayMs: 1, // Use 1ms for fast tests
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
import { ExchangeStoreService } from '../../../app/services/exchange_store_service.js'
describe('ExchangeStoreService', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFindOneAndUpdate.mockResolvedValue({ seq: 1 })
;(ExchangeStoreService as any).instance = undefined
})
describe('getInstance', () => {
it('returns the same instance on multiple calls', () => {
const a = ExchangeStoreService.getInstance()
const b = ExchangeStoreService.getInstance()
expect(a).toBe(b)
})
})
describe('extractCwmpMethod', () => {
it('returns "Empty" for empty string', () => {
const service = ExchangeStoreService.getInstance()
expect(service.extractCwmpMethod('')).toBe('Empty')
})
it('returns "Empty" for whitespace-only string', () => {
const service = ExchangeStoreService.getInstance()
expect(service.extractCwmpMethod(' \n\t ')).toBe('Empty')
})
it('extracts Inform from a valid SOAP body', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body>
<cwmp:Inform>
<DeviceId>test</DeviceId>
</cwmp:Inform>
</soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('Inform')
})
it('extracts GetParameterValuesResponse', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body>
<cwmp:GetParameterValuesResponse>
<ParameterList/>
</cwmp:GetParameterValuesResponse>
</soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('GetParameterValuesResponse')
})
it('returns "Unknown" when no cwmp: namespace element is found', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<soap-env:Envelope>
<soap-env:Body>
<someOtherElement>data</someOtherElement>
</soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('Unknown')
})
it('returns "Unknown" for malformed XML without cwmp namespace', () => {
const service = ExchangeStoreService.getInstance()
expect(service.extractCwmpMethod('<broken><xml')).toBe('Unknown')
})
it('extracts method from self-closing cwmp element', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body><cwmp:TransferComplete/></soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('TransferComplete')
})
})
describe('persistExchange', () => {
it('persists a valid exchange and returns the document', async () => {
const service = ExchangeStoreService.getInstance()
const result = await service.persistExchange({
deviceId: 'device-001',
direction: 'CPE→ACS',
sessionId: '10.0.0.1:1234->192.168.1.1:8080',
rawXml: '<cwmp:Inform><DeviceId/></cwmp:Inform>',
})
expect(result).not.toBeNull()
expect(result!.deviceId).toBe('device-001')
expect(result!.direction).toBe('CPE→ACS')
expect(result!.cwmpMethod).toBe('Inform')
expect(result!.sequenceNumber).toBe(1)
expect(result!.rawXml).toBe('<cwmp:Inform><DeviceId/></cwmp:Inform>')
expect(mockInsertOne).toHaveBeenCalledTimes(1)
})
it('emits an event after persisting', async () => {
const service = ExchangeStoreService.getInstance()
const listener = vi.fn()
service.subscribe('device-002', listener)
await service.persistExchange({
deviceId: 'device-002',
direction: 'ACS→CPE',
sessionId: 'session-1',
rawXml: '<cwmp:GetParameterValues/>',
})
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: 'device-002',
cwmpMethod: 'GetParameterValues',
})
)
service.unsubscribe('device-002', listener)
})
it('returns null when persistence is disabled', async () => {
// Re-mock config with persistence disabled
const configModule = await import('#config/exchange_store')
const originalPersistence = configModule.default.featureFlags.persistence
configModule.default.featureFlags.persistence = false
const service = ExchangeStoreService.getInstance()
const result = await service.persistExchange({
deviceId: 'device-003',
direction: 'CPE→ACS',
sessionId: 'session-2',
rawXml: '<cwmp:Inform/>',
})
expect(result).toBeNull()
expect(mockInsertOne).not.toHaveBeenCalled()
// Restore
configModule.default.featureFlags.persistence = originalPersistence
})
it('returns exchange even when MongoDB write fails (fire-and-forget)', async () => {
mockInsertOne.mockRejectedValue(new Error('MongoDB write failed'))
const service = ExchangeStoreService.getInstance()
const result = await service.persistExchange({
deviceId: 'device-004',
direction: 'CPE→ACS',
sessionId: 'session-3',
rawXml: '<cwmp:Inform/>',
})
// persistExchange never throws and returns the exchange object
// because persistWithRetry absorbs all errors (fire-and-forget design)
expect(result).not.toBeNull()
expect(result!.deviceId).toBe('device-004')
expect(result!.cwmpMethod).toBe('Inform')
mockInsertOne.mockResolvedValue({ insertedId: 'mock-id' })
})
})
describe('getNextSequenceNumber', () => {
it('returns the incremented sequence number', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce({ seq: 42 })
const service = ExchangeStoreService.getInstance()
const seq = await service.getNextSequenceNumber('device-100')
expect(seq).toBe(42)
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'device-100' },
{ $inc: { seq: 1 } },
{ upsert: true, returnDocument: 'after' }
)
})
})
describe('subscribe / unsubscribe', () => {
it('subscriber receives events for the correct device', async () => {
const service = ExchangeStoreService.getInstance()
const listener1 = vi.fn()
const listener2 = vi.fn()
service.subscribe('dev-A', listener1)
service.subscribe('dev-B', listener2)
await service.persistExchange({
deviceId: 'dev-A',
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).not.toHaveBeenCalled()
service.unsubscribe('dev-A', listener1)
service.unsubscribe('dev-B', listener2)
})
it('unsubscribed listener no longer receives events', async () => {
const service = ExchangeStoreService.getInstance()
const listener = vi.fn()
service.subscribe('dev-C', listener)
service.unsubscribe('dev-C', listener)
await service.persistExchange({
deviceId: 'dev-C',
direction: 'CPE→ACS',
sessionId: 's2',
rawXml: '<cwmp:Inform/>',
})
expect(listener).not.toHaveBeenCalled()
})
})
})