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 = `
test
`
expect(service.extractCwmpMethod(xml)).toBe('Inform')
})
it('extracts GetParameterValuesResponse', () => {
const service = ExchangeStoreService.getInstance()
const xml = `
`
expect(service.extractCwmpMethod(xml)).toBe('GetParameterValuesResponse')
})
it('returns "Unknown" when no cwmp: namespace element is found', () => {
const service = ExchangeStoreService.getInstance()
const xml = `
data
`
expect(service.extractCwmpMethod(xml)).toBe('Unknown')
})
it('returns "Unknown" for malformed XML without cwmp namespace', () => {
const service = ExchangeStoreService.getInstance()
expect(service.extractCwmpMethod(' {
const service = ExchangeStoreService.getInstance()
const xml = `
`
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: '',
})
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('')
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: '',
})
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: '',
})
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: '',
})
// 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: '',
})
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: '',
})
expect(listener).not.toHaveBeenCalled()
})
})
})