- 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
453 lines
14 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|