import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' /** * SSE Reconnection with Last-Event-ID Integration Test * * Validates: Requirements 6.8, 6.9 * * This is a unit-level integration test using mocks (not a real server). * It simulates the full reconnection lifecycle: * 1. Connect to SSE stream, receive 5 messages * 2. Simulate disconnect * 3. Reconnect with Last-Event-ID set to sequence number of last received message * 4. Verify: no duplicates received, messages resume from correct point */ // --- Mocks --- const mockFindAfterSequence = vi.fn() const mockFindByDevice = vi.fn() const mockHasExchanges = vi.fn() const mockGetMetadata = vi.fn() const mockDeleteByDevice = vi.fn() vi.mock('#models/exchange', () => ({ default: { findByDevice: (...args: any[]) => mockFindByDevice(...args), hasExchanges: (...args: any[]) => mockHasExchanges(...args), getMetadata: (...args: any[]) => mockGetMetadata(...args), deleteByDevice: (...args: any[]) => mockDeleteByDevice(...args), findAfterSequence: (...args: any[]) => mockFindAfterSequence(...args), }, })) const mockIsTraced = vi.fn() vi.mock('#services/tr069_trace_service', () => ({ Tr069TraceService: { getInstance: () => ({ isTraced: mockIsTraced, }), }, })) const mockEmitter = new EventEmitter() const mockSubscribe = vi.fn((deviceId: string, listener: Function) => { mockEmitter.on(`exchange:${deviceId}`, listener as any) }) const mockUnsubscribe = vi.fn((deviceId: string, listener: Function) => { mockEmitter.off(`exchange:${deviceId}`, listener as any) }) vi.mock('#services/exchange_store_service', () => ({ ExchangeStoreService: { getInstance: () => ({ subscribe: mockSubscribe, unsubscribe: mockUnsubscribe, }), }, })) vi.mock('#config/exchange_store', () => ({ default: { maxMessagesPerDevice: 5000, sse: { inactivityTimeoutMs: 300_000, absoluteTimeoutMs: 7_200_000, maxConnectionsPerDevice: 5, retryIntervalMs: 3000, }, featureFlags: { persistence: true, sseEnabled: true, apiSource: 'mongodb', }, }, })) vi.mock('@adonisjs/core/services/logger', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), }, })) import ExchangeController from '../../app/controllers/exchange_controller.js' // --- Helpers --- interface SSEEvent { id: number data: any } function createStreamContext(options: { deviceId: string; lastEventId?: string }) { const written: string[] = [] let ended = false const reqEmitter = new EventEmitter() const nodeRes = { writeHead: vi.fn(), write: vi.fn((chunk: string) => { written.push(chunk) }), end: vi.fn(() => { ended = true }), } const responseObj = { response: nodeRes, status: vi.fn(function (this: any, _code: number) { return this }), json: vi.fn((data: any) => data), } const ctx = { params: { deviceId: options.deviceId }, request: { header: vi.fn((name: string) => { if (name === 'Last-Event-ID') return options.lastEventId return undefined }), request: reqEmitter, id: () => 'test-correlation-id', }, response: responseObj, } return { ctx: ctx as any, nodeRes, reqEmitter, getWritten: () => written, isEnded: () => ended, disconnect: () => reqEmitter.emit('close'), } } /** * Parse SSE events from written chunks. * Each SSE event has format: `id: \ndata: \n\n` * Ignores retry messages and empty chunks. */ function parseSSEEvents(written: string[]): SSEEvent[] { const events: SSEEvent[] = [] for (const chunk of written) { // Skip retry messages if (chunk.startsWith('retry:')) continue const idMatch = chunk.match(/^id: (\d+)\n/) const dataMatch = chunk.match(/\ndata: (.+)\n\n$/) if (idMatch && dataMatch) { events.push({ id: Number(idMatch[1]), data: JSON.parse(dataMatch[1]), }) } } return events } function createExchange(deviceId: string, sequenceNumber: number) { return { deviceId, direction: sequenceNumber % 2 === 1 ? 'CPE→ACS' : 'ACS→CPE', timestamp: new Date(Date.now() + sequenceNumber * 1000).toISOString(), sessionId: '10.0.0.1:54321->192.168.1.1:8080', cwmpMethod: sequenceNumber % 2 === 1 ? 'Inform' : 'InformResponse', rawXml: ``, sequenceNumber, } } // --- Tests --- describe('SSE Reconnection with Last-Event-ID', () => { let controller: ExchangeController const DEVICE_ID = 'device-reconnect-test' beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() controller = new ExchangeController() mockIsTraced.mockReturnValue(true) mockFindAfterSequence.mockResolvedValue([]) }) afterEach(() => { vi.useRealTimers() mockEmitter.removeAllListeners() }) it('full reconnection lifecycle: receive 5 messages, disconnect, reconnect with Last-Event-ID, no duplicates', async () => { // --- Phase 1: Initial connection, receive 5 messages --- const { ctx: ctx1, getWritten: getWritten1, disconnect: disconnect1 } = createStreamContext({ deviceId: DEVICE_ID, }) await controller.stream(ctx1) // Emit 5 exchanges through the EventEmitter (simulating real-time messages) const allExchanges = Array.from({ length: 10 }, (_, i) => createExchange(DEVICE_ID, i + 1)) for (let i = 0; i < 5; i++) { mockEmitter.emit(`exchange:${DEVICE_ID}`, allExchanges[i]) } // Verify 5 messages were received const eventsPhase1 = parseSSEEvents(getWritten1()) expect(eventsPhase1).toHaveLength(5) expect(eventsPhase1.map((e) => e.id)).toEqual([1, 2, 3, 4, 5]) // Get the Last-Event-ID (sequence number of last received message) const lastEventId = eventsPhase1[eventsPhase1.length - 1].id expect(lastEventId).toBe(5) // --- Phase 2: Simulate disconnect --- disconnect1() // Verify unsubscribe was called (cleanup) expect(mockUnsubscribe).toHaveBeenCalledWith(DEVICE_ID, expect.any(Function)) // --- Phase 3: While disconnected, 5 more messages arrive (seq 6-10) --- // These would have been persisted to MongoDB by ExchangeStoreService const missedExchanges = allExchanges.slice(5) // seq 6-10 mockFindAfterSequence.mockResolvedValue(missedExchanges) // --- Phase 4: Reconnect with Last-Event-ID --- const { ctx: ctx2, getWritten: getWritten2 } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: String(lastEventId), // "5" }) await controller.stream(ctx2) // Verify findAfterSequence was called with the correct sequence number expect(mockFindAfterSequence).toHaveBeenCalledWith(DEVICE_ID, 5) // --- Phase 5: Verify no duplicates, messages resume from correct point --- const eventsPhase2 = parseSSEEvents(getWritten2()) expect(eventsPhase2).toHaveLength(5) expect(eventsPhase2.map((e) => e.id)).toEqual([6, 7, 8, 9, 10]) // Verify no sequence numbers from phase 1 appear in phase 2 const phase1Ids = new Set(eventsPhase1.map((e) => e.id)) const phase2Ids = eventsPhase2.map((e) => e.id) for (const id of phase2Ids) { expect(phase1Ids.has(id)).toBe(false) } // Verify all messages are accounted for (no gaps) const allReceivedIds = [...eventsPhase1.map((e) => e.id), ...phase2Ids] expect(allReceivedIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) }) it('reconnection with Last-Event-ID=0 resumes all available messages', async () => { const exchanges = Array.from({ length: 3 }, (_, i) => createExchange(DEVICE_ID, i + 1)) mockFindAfterSequence.mockResolvedValue(exchanges) const { ctx, getWritten } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: '0', }) await controller.stream(ctx) expect(mockFindAfterSequence).toHaveBeenCalledWith(DEVICE_ID, 0) const events = parseSSEEvents(getWritten()) expect(events).toHaveLength(3) expect(events.map((e) => e.id)).toEqual([1, 2, 3]) }) it('reconnection with Last-Event-ID pointing to latest message yields no missed messages', async () => { mockFindAfterSequence.mockResolvedValue([]) const { ctx, getWritten } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: '100', }) await controller.stream(ctx) expect(mockFindAfterSequence).toHaveBeenCalledWith(DEVICE_ID, 100) const events = parseSSEEvents(getWritten()) expect(events).toHaveLength(0) }) it('reconnection sends missed messages then continues receiving live events', async () => { // Missed messages during disconnect (seq 6-8) const missedExchanges = [ createExchange(DEVICE_ID, 6), createExchange(DEVICE_ID, 7), createExchange(DEVICE_ID, 8), ] mockFindAfterSequence.mockResolvedValue(missedExchanges) const { ctx, getWritten } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: '5', }) await controller.stream(ctx) // New live exchange arrives after reconnection (seq 9) const liveExchange = createExchange(DEVICE_ID, 9) mockEmitter.emit(`exchange:${DEVICE_ID}`, liveExchange) const events = parseSSEEvents(getWritten()) expect(events).toHaveLength(4) // 3 missed + 1 live expect(events.map((e) => e.id)).toEqual([6, 7, 8, 9]) // Verify ordering: missed messages come before live ones for (let i = 1; i < events.length; i++) { expect(events[i].id).toBeGreaterThan(events[i - 1].id) } }) it('SSE event id field always equals sequenceNumber (Property 13 validation)', async () => { const { ctx, getWritten } = createStreamContext({ deviceId: DEVICE_ID, }) await controller.stream(ctx) // Emit exchanges with various sequence numbers const sequenceNumbers = [1, 5, 42, 100, 9999] for (const seq of sequenceNumbers) { const exchange = createExchange(DEVICE_ID, seq) mockEmitter.emit(`exchange:${DEVICE_ID}`, exchange) } const events = parseSSEEvents(getWritten()) expect(events).toHaveLength(5) // Each event id must match the exchange's sequenceNumber for (const event of events) { expect(event.id).toBe(event.data.sequenceNumber) } }) it('multiple rapid reconnections do not produce duplicates', async () => { const allReceived: number[] = [] // First connection: receive messages 1-3 const { ctx: ctx1, getWritten: getWritten1, disconnect: disconnect1 } = createStreamContext({ deviceId: DEVICE_ID, }) await controller.stream(ctx1) for (let i = 1; i <= 3; i++) { mockEmitter.emit(`exchange:${DEVICE_ID}`, createExchange(DEVICE_ID, i)) } const events1 = parseSSEEvents(getWritten1()) allReceived.push(...events1.map((e) => e.id)) disconnect1() // Second connection: reconnect from seq 3, receive missed 4-5 mockFindAfterSequence.mockResolvedValue([ createExchange(DEVICE_ID, 4), createExchange(DEVICE_ID, 5), ]) const { ctx: ctx2, getWritten: getWritten2, disconnect: disconnect2 } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: '3', }) await controller.stream(ctx2) const events2 = parseSSEEvents(getWritten2()) allReceived.push(...events2.map((e) => e.id)) disconnect2() // Third connection: reconnect from seq 5, receive missed 6-7 mockFindAfterSequence.mockResolvedValue([ createExchange(DEVICE_ID, 6), createExchange(DEVICE_ID, 7), ]) const { ctx: ctx3, getWritten: getWritten3 } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: '5', }) await controller.stream(ctx3) const events3 = parseSSEEvents(getWritten3()) allReceived.push(...events3.map((e) => e.id)) // Verify no duplicates across all connections const uniqueIds = new Set(allReceived) expect(uniqueIds.size).toBe(allReceived.length) // Verify continuous sequence expect(allReceived).toEqual([1, 2, 3, 4, 5, 6, 7]) }) it('reconnection handles non-numeric Last-Event-ID gracefully (no crash)', async () => { const { ctx, getWritten } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: 'invalid-not-a-number', }) // Should not throw await controller.stream(ctx) // findAfterSequence should NOT be called with NaN expect(mockFindAfterSequence).not.toHaveBeenCalled() // Connection should still be established (live events still work) mockEmitter.emit(`exchange:${DEVICE_ID}`, createExchange(DEVICE_ID, 1)) const events = parseSSEEvents(getWritten()) expect(events).toHaveLength(1) expect(events[0].id).toBe(1) }) it('data integrity: reconnected messages have the same content as originally persisted', async () => { const originalExchanges = Array.from({ length: 5 }, (_, i) => createExchange(DEVICE_ID, i + 1)) mockFindAfterSequence.mockResolvedValue(originalExchanges) const { ctx, getWritten } = createStreamContext({ deviceId: DEVICE_ID, lastEventId: '0', }) await controller.stream(ctx) const events = parseSSEEvents(getWritten()) // Verify data integrity for each reconnected message for (let i = 0; i < events.length; i++) { const event = events[i] const original = originalExchanges[i] expect(event.data.deviceId).toBe(original.deviceId) expect(event.data.direction).toBe(original.direction) expect(event.data.timestamp).toBe(original.timestamp) expect(event.data.sessionId).toBe(original.sessionId) expect(event.data.cwmpMethod).toBe(original.cwmpMethod) expect(event.data.rawXml).toBe(original.rawXml) expect(event.data.sequenceNumber).toBe(original.sequenceNumber) } }) })