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: '', 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() }) }) })