/** * Integration test: Full Exchange Lifecycle * * Tests the complete flow: * SOAP capture → persist to MongoDB → SSE event emitted → data integrity verified * * This is a unit-level integration test using mocks (not a real MongoDB connection). * We mock the MongoDB layer but verify the data flows through: * ExchangeStoreService → ExchangeModel → EventEmitter correctly. * * Validates: Requirements 1.1, 1.7, 6.1, 6.2 */ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' // ─── MongoDB Mocks ────────────────────────────────────────────────────────────── let sequenceCounter = 0 const mockInsertOne = vi.fn().mockImplementation(async () => ({ insertedId: 'mock-id' })) const mockCountDocuments = vi.fn().mockResolvedValue(0) const mockDeleteMany = vi.fn().mockResolvedValue({ deletedCount: 0 }) const mockFind = vi.fn().mockReturnValue({ sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), project: vi.fn().mockReturnThis(), toArray: vi.fn().mockResolvedValue([]), }) const mockFindOneAndUpdate = vi.fn().mockImplementation(async () => { sequenceCounter++ return { seq: sequenceCounter } }) vi.mock('#providers/mongodb_provider', () => ({ getMongoDb: () => ({ collection: (name: string) => { if (name === 'cwmp_exchange_counters') { return { findOneAndUpdate: mockFindOneAndUpdate } } return { insertOne: mockInsertOne, countDocuments: mockCountDocuments, deleteMany: mockDeleteMany, find: mockFind, findOne: vi.fn().mockResolvedValue(null), } }, }), })) vi.mock('#models/exchange', () => ({ default: { collectionName: 'cwmp_exchanges', getCollection: () => ({ insertOne: mockInsertOne, countDocuments: mockCountDocuments, deleteMany: mockDeleteMany, find: mockFind, findOne: vi.fn().mockResolvedValue(null), }), countByDevice: vi.fn().mockResolvedValue(0), deleteOldestBeyondCap: vi.fn().mockResolvedValue(undefined), }, })) vi.mock('#config/exchange_store', () => ({ default: { maxMessagesPerDevice: 5000, ttlSeconds: 86400, sse: { inactivityTimeoutMs: 300_000, absoluteTimeoutMs: 7_200_000, maxConnectionsPerDevice: 5, retryIntervalMs: 3000, }, retry: { maxAttempts: 3, baseDelayMs: 1, // 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(), debug: vi.fn(), }, })) import { ExchangeStoreService } from '../../app/services/exchange_store_service.js' import type { CwmpExchange } from '../../app/models/exchange.js' // ─── Test Suite ───────────────────────────────────────────────────────────────── describe('Exchange Lifecycle Integration', () => { let service: ExchangeStoreService beforeEach(() => { vi.clearAllMocks() sequenceCounter = 0 // Reset singleton for clean state ;(ExchangeStoreService as any).instance = undefined service = ExchangeStoreService.getInstance() }) afterEach(() => { ;(ExchangeStoreService as any).instance = undefined }) describe('Full lifecycle: SOAP capture → persist → SSE event', () => { it('persists a CPE→ACS Inform and emits SSE event with correct data', async () => { const soapXml = ` Sagemcom F8B7FF Livebox 4 LB4-TEST-001 ` const deviceId = 'test-device-001' const receivedEvents: CwmpExchange[] = [] // Subscribe to SSE events before persisting const listener = (exchange: CwmpExchange) => { receivedEvents.push(exchange) } service.subscribe(deviceId, listener) // Simulate SOAP capture → persist (what CwmpLoggerMiddleware does) const result = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: '10.0.0.1:54321->192.168.1.1:8080', rawXml: soapXml, }) // Verify persistence result expect(result).not.toBeNull() expect(result!.deviceId).toBe(deviceId) expect(result!.direction).toBe('CPE→ACS') expect(result!.cwmpMethod).toBe('Inform') expect(result!.rawXml).toBe(soapXml) expect(result!.sequenceNumber).toBe(1) expect(result!.sessionId).toBe('10.0.0.1:54321->192.168.1.1:8080') expect(result!.timestamp).toBeDefined() // Verify MongoDB insertOne was called with correct document expect(mockInsertOne).toHaveBeenCalledTimes(1) const insertedDoc = mockInsertOne.mock.calls[0][0] expect(insertedDoc.deviceId).toBe(deviceId) expect(insertedDoc.direction).toBe('CPE→ACS') expect(insertedDoc.cwmpMethod).toBe('Inform') expect(insertedDoc.rawXml).toBe(soapXml) expect(insertedDoc.sequenceNumber).toBe(1) // Verify SSE event was emitted with the same data expect(receivedEvents).toHaveLength(1) expect(receivedEvents[0].deviceId).toBe(deviceId) expect(receivedEvents[0].direction).toBe('CPE→ACS') expect(receivedEvents[0].cwmpMethod).toBe('Inform') expect(receivedEvents[0].rawXml).toBe(soapXml) expect(receivedEvents[0].sequenceNumber).toBe(1) service.unsubscribe(deviceId, listener) }) it('persists an ACS→CPE GetParameterValuesResponse and emits SSE event', async () => { const responseXml = ` Device.DeviceInfo.SoftwareVersion SR40_sip-fr-4.62.0 ` const deviceId = 'test-device-002' const receivedEvents: CwmpExchange[] = [] const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange) service.subscribe(deviceId, listener) const result = await service.persistExchange({ deviceId, direction: 'ACS→CPE', sessionId: '192.168.1.1:8080->10.0.0.1:54321', rawXml: responseXml, }) // Verify data integrity across all steps expect(result).not.toBeNull() expect(result!.deviceId).toBe(deviceId) expect(result!.direction).toBe('ACS→CPE') expect(result!.cwmpMethod).toBe('GetParameterValuesResponse') expect(result!.rawXml).toBe(responseXml) // Verify SSE event matches persisted document expect(receivedEvents).toHaveLength(1) expect(receivedEvents[0]).toEqual(result) service.unsubscribe(deviceId, listener) }) it('handles a complete session: CPE→ACS Inform + ACS→CPE InformResponse', async () => { const deviceId = 'test-device-session' const receivedEvents: CwmpExchange[] = [] const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange) service.subscribe(deviceId, listener) // Step 1: CPE sends Inform const informXml = ` ` const inform = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: '10.0.0.1:5000->192.168.1.1:8080', rawXml: informXml, }) // Step 2: ACS responds with InformResponse const informResponseXml = ` 1 ` const informResponse = await service.persistExchange({ deviceId, direction: 'ACS→CPE', sessionId: '10.0.0.1:5000->192.168.1.1:8080', rawXml: informResponseXml, }) // Verify sequence numbers are monotonically increasing expect(inform!.sequenceNumber).toBe(1) expect(informResponse!.sequenceNumber).toBe(2) // Verify all events were emitted in order expect(receivedEvents).toHaveLength(2) expect(receivedEvents[0].cwmpMethod).toBe('Inform') expect(receivedEvents[0].direction).toBe('CPE→ACS') expect(receivedEvents[0].sequenceNumber).toBe(1) expect(receivedEvents[1].cwmpMethod).toBe('InformResponse') expect(receivedEvents[1].direction).toBe('ACS→CPE') expect(receivedEvents[1].sequenceNumber).toBe(2) // Verify data integrity: rawXml preserved at each step expect(receivedEvents[0].rawXml).toBe(informXml) expect(receivedEvents[1].rawXml).toBe(informResponseXml) service.unsubscribe(deviceId, listener) }) }) describe('Data integrity verification', () => { it('preserves deviceId through the entire lifecycle', async () => { const deviceId = 'unique-device-id-αβγ' const receivedEvents: CwmpExchange[] = [] const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange) service.subscribe(deviceId, listener) const result = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 'sess-1', rawXml: '', }) // Verify deviceId preserved: service return expect(result!.deviceId).toBe(deviceId) // Verify deviceId preserved: MongoDB insert expect(mockInsertOne.mock.calls[0][0].deviceId).toBe(deviceId) // Verify deviceId preserved: SSE event expect(receivedEvents[0].deviceId).toBe(deviceId) service.unsubscribe(deviceId, listener) }) it('preserves direction through the entire lifecycle', async () => { const deviceId = 'direction-test' const receivedEvents: CwmpExchange[] = [] const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange) service.subscribe(deviceId, listener) // Test CPE→ACS direction await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) // Test ACS→CPE direction await service.persistExchange({ deviceId, direction: 'ACS→CPE', sessionId: 's1', rawXml: '', }) expect(receivedEvents[0].direction).toBe('CPE→ACS') expect(receivedEvents[1].direction).toBe('ACS→CPE') // Verify in MongoDB writes expect(mockInsertOne.mock.calls[0][0].direction).toBe('CPE→ACS') expect(mockInsertOne.mock.calls[1][0].direction).toBe('ACS→CPE') service.unsubscribe(deviceId, listener) }) it('preserves cwmpMethod extraction through the entire lifecycle', async () => { const deviceId = 'method-test' const receivedEvents: CwmpExchange[] = [] const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange) service.subscribe(deviceId, listener) const methods = [ { xml: '', expected: 'Inform' }, { xml: '', expected: 'GetParameterValues' }, { xml: '', expected: 'SetParameterValuesResponse' }, { xml: '', expected: 'Empty' }, { xml: '', expected: 'Unknown' }, ] for (const { xml } of methods) { await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 'sess', rawXml: xml, }) } // Verify each extracted method in the SSE events expect(receivedEvents[0].cwmpMethod).toBe('Inform') expect(receivedEvents[1].cwmpMethod).toBe('GetParameterValues') expect(receivedEvents[2].cwmpMethod).toBe('SetParameterValuesResponse') expect(receivedEvents[3].cwmpMethod).toBe('Empty') expect(receivedEvents[4].cwmpMethod).toBe('Unknown') // Verify same values in MongoDB inserts for (let i = 0; i < methods.length; i++) { expect(mockInsertOne.mock.calls[i][0].cwmpMethod).toBe(methods[i].expected) } service.unsubscribe(deviceId, listener) }) it('preserves rawXml without modification through the entire lifecycle', async () => { const deviceId = 'rawxml-test' const receivedEvents: CwmpExchange[] = [] const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange) service.subscribe(deviceId, listener) const complexXml = ` 123456 Sagemcom F8B7FF Livebox 4 LB4-TEST-001 0 BOOTSTRAP Device.DeviceInfo.SoftwareVersion 4.62.0 ` const result = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: '10.0.0.1:1234->192.168.1.1:8080', rawXml: complexXml, }) // Verify rawXml preserved exactly in service result expect(result!.rawXml).toBe(complexXml) // Verify rawXml preserved exactly in MongoDB insert expect(mockInsertOne.mock.calls[0][0].rawXml).toBe(complexXml) // Verify rawXml preserved exactly in SSE event expect(receivedEvents[0].rawXml).toBe(complexXml) service.unsubscribe(deviceId, listener) }) }) describe('SSE event emission and subscription', () => { it('emits events only to subscribers of the correct device', async () => { const eventsA: CwmpExchange[] = [] const eventsB: CwmpExchange[] = [] const listenerA = (e: CwmpExchange) => eventsA.push(e) const listenerB = (e: CwmpExchange) => eventsB.push(e) service.subscribe('device-A', listenerA) service.subscribe('device-B', listenerB) await service.persistExchange({ deviceId: 'device-A', direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) expect(eventsA).toHaveLength(1) expect(eventsB).toHaveLength(0) await service.persistExchange({ deviceId: 'device-B', direction: 'ACS→CPE', sessionId: 's2', rawXml: '', }) expect(eventsA).toHaveLength(1) expect(eventsB).toHaveLength(1) service.unsubscribe('device-A', listenerA) service.unsubscribe('device-B', listenerB) }) it('stops emitting after unsubscribe', async () => { const deviceId = 'unsub-test' const events: CwmpExchange[] = [] const listener = (e: CwmpExchange) => events.push(e) service.subscribe(deviceId, listener) await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) expect(events).toHaveLength(1) service.unsubscribe(deviceId, listener) await service.persistExchange({ deviceId, direction: 'ACS→CPE', sessionId: 's1', rawXml: '', }) expect(events).toHaveLength(1) // No new event after unsubscribe }) it('supports multiple subscribers for the same device', async () => { const deviceId = 'multi-sub' const events1: CwmpExchange[] = [] const events2: CwmpExchange[] = [] const listener1 = (e: CwmpExchange) => events1.push(e) const listener2 = (e: CwmpExchange) => events2.push(e) service.subscribe(deviceId, listener1) service.subscribe(deviceId, listener2) await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) expect(events1).toHaveLength(1) expect(events2).toHaveLength(1) expect(events1[0]).toEqual(events2[0]) service.unsubscribe(deviceId, listener1) service.unsubscribe(deviceId, listener2) }) }) describe('Retry behavior for critical vs non-critical messages (Req 1.7)', () => { it('retries for critical Inform message on MongoDB failure — never interrupts CWMP', async () => { // Reset mock entirely and make all inserts fail mockInsertOne.mockReset() mockInsertOne.mockRejectedValue(new Error('MongoDB write failed')) const deviceId = 'retry-critical-test' const events: CwmpExchange[] = [] const listener = (e: CwmpExchange) => events.push(e) service.subscribe(deviceId, listener) const result = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) // Service never throws and still returns the exchange (fire-and-forget persistence) // The CWMP session must NOT be interrupted regardless of persistence outcome expect(result).not.toBeNull() expect(result!.cwmpMethod).toBe('Inform') expect(result!.deviceId).toBe(deviceId) // Verify retries happened: 1 initial attempt + 3 retries = 4 total calls expect(mockInsertOne).toHaveBeenCalledTimes(4) // SSE event is still emitted (subscribers get notified even on write failure) expect(events).toHaveLength(1) expect(events[0].cwmpMethod).toBe('Inform') service.unsubscribe(deviceId, listener) }) it('does NOT retry for non-critical Empty message on MongoDB failure', async () => { // Reset mock entirely and make insert fail mockInsertOne.mockReset() mockInsertOne.mockRejectedValue(new Error('MongoDB write failed')) const deviceId = 'retry-noncritical-test' const events: CwmpExchange[] = [] const listener = (e: CwmpExchange) => events.push(e) service.subscribe(deviceId, listener) const result = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 's1', rawXml: '', // Empty → non-critical }) // Service still returns exchange (fire-and-forget), CWMP never interrupted expect(result).not.toBeNull() expect(result!.cwmpMethod).toBe('Empty') // Only 1 attempt — non-critical messages are discarded immediately on failure expect(mockInsertOne).toHaveBeenCalledTimes(1) // SSE event still emitted expect(events).toHaveLength(1) service.unsubscribe(deviceId, listener) }) it('critical messages get 3 retries while non-critical get 0 retries', async () => { // This verifies the retry classification difference: // Critical: 1 initial + 3 retries = 4 attempts // Non-critical: 1 initial + 0 retries = 1 attempt mockInsertOne.mockReset() mockInsertOne.mockRejectedValue(new Error('MongoDB down')) // Test critical method (Inform) await service.persistExchange({ deviceId: 'retry-class-test', direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) const criticalAttempts = mockInsertOne.mock.calls.length expect(criticalAttempts).toBe(4) // 1 initial + 3 retries // Reset and test non-critical method (Unknown) mockInsertOne.mockClear() await service.persistExchange({ deviceId: 'retry-class-test', direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) const nonCriticalAttempts = mockInsertOne.mock.calls.length expect(nonCriticalAttempts).toBe(1) // Only 1 attempt, no retries }) }) describe('Sequence number assignment (Req 1.1)', () => { it('assigns monotonically increasing sequence numbers across multiple messages', async () => { const deviceId = 'seq-test' const events: CwmpExchange[] = [] const listener = (e: CwmpExchange) => events.push(e) service.subscribe(deviceId, listener) for (let i = 0; i < 5; i++) { await service.persistExchange({ deviceId, direction: i % 2 === 0 ? 'CPE→ACS' : 'ACS→CPE', sessionId: 'sess', rawXml: ``, }) } expect(events).toHaveLength(5) for (let i = 0; i < 5; i++) { expect(events[i].sequenceNumber).toBe(i + 1) } // Verify atomic increment was called for each message expect(mockFindOneAndUpdate).toHaveBeenCalledTimes(5) service.unsubscribe(deviceId, listener) }) it('uses device-scoped sequence numbers (separate counters per device)', async () => { const eventsA: CwmpExchange[] = [] const eventsB: CwmpExchange[] = [] const listenerA = (e: CwmpExchange) => eventsA.push(e) const listenerB = (e: CwmpExchange) => eventsB.push(e) service.subscribe('dev-X', listenerA) service.subscribe('dev-Y', listenerB) await service.persistExchange({ deviceId: 'dev-X', direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) await service.persistExchange({ deviceId: 'dev-Y', direction: 'CPE→ACS', sessionId: 's2', rawXml: '', }) // Verify the counter collection is called with device-scoped _id expect(mockFindOneAndUpdate).toHaveBeenCalledWith( { _id: 'dev-X' }, { $inc: { seq: 1 } }, { upsert: true, returnDocument: 'after' } ) expect(mockFindOneAndUpdate).toHaveBeenCalledWith( { _id: 'dev-Y' }, { $inc: { seq: 1 } }, { upsert: true, returnDocument: 'after' } ) service.unsubscribe('dev-X', listenerA) service.unsubscribe('dev-Y', listenerB) }) }) describe('CWMP session non-blocking guarantee', () => { it('never throws even if MongoDB sequence counter is unavailable', async () => { mockFindOneAndUpdate.mockRejectedValue(new Error('MongoDB unavailable')) const deviceId = 'error-test' // Should not throw — CWMP session must never be interrupted const result = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 's1', rawXml: '', }) expect(result).toBeNull() }) it('never throws even if MongoDB insertOne is unavailable', async () => { mockInsertOne.mockRejectedValue(new Error('MongoDB insertOne failed')) const deviceId = 'insert-error-test' // Should not throw — returns null const result = await service.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId: 's1', rawXml: '', // Unknown → non-critical, no retries }) expect(result).toBeNull() }) }) })