- 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
266 lines
8.5 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|