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