Files
adam-acs/tests/integration/sse_reconnection.spec.ts
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

453 lines
14 KiB
TypeScript

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: <number>\ndata: <json>\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: `<soap-env:Envelope><cwmp:${sequenceNumber % 2 === 1 ? 'Inform' : 'InformResponse'}/></soap-env:Envelope>`,
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)
}
})
})