- 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
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
/**
|
|
* Integration test: Message cap enforcement
|
|
* Validates: Requirements 1.4
|
|
*
|
|
* Verifies that when a device accumulates more than 5000 messages,
|
|
* the oldest messages are deleted and only the 5000 most recent remain.
|
|
*
|
|
* This is a unit-level integration test using mocks (not a real MongoDB).
|
|
* It mocks MongoDB operations but verifies the correct sequence of calls.
|
|
*/
|
|
|
|
// ─── Hoisted Mocks ────────────────────────────────────────────────────────────
|
|
|
|
const loggerMock = vi.hoisted(() => ({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
}))
|
|
|
|
const insertOneMock = vi.hoisted(() => vi.fn().mockResolvedValue({ insertedId: 'mock-id' }))
|
|
const countDocumentsMock = vi.hoisted(() => vi.fn())
|
|
const findMock = vi.hoisted(() => vi.fn())
|
|
const deleteManyMock = vi.hoisted(() => vi.fn().mockResolvedValue({ deletedCount: 1 }))
|
|
const findOneAndUpdateMock = vi.hoisted(() => vi.fn())
|
|
|
|
// Track sequence numbers per device
|
|
let sequenceCounter: number
|
|
|
|
vi.mock('@adonisjs/core/services/logger', () => ({
|
|
default: loggerMock,
|
|
}))
|
|
|
|
vi.mock('#start/env', () => ({
|
|
default: {
|
|
get: (key: string, defaultValue?: any) => defaultValue,
|
|
},
|
|
}))
|
|
|
|
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',
|
|
},
|
|
},
|
|
}))
|
|
|
|
vi.mock('#models/exchange', () => ({
|
|
default: {
|
|
getCollection: () => ({
|
|
insertOne: insertOneMock,
|
|
countDocuments: countDocumentsMock,
|
|
find: findMock,
|
|
deleteMany: deleteManyMock,
|
|
}),
|
|
countByDevice: async (deviceId: string) => {
|
|
return countDocumentsMock(deviceId)
|
|
},
|
|
deleteOldestBeyondCap: async (deviceId: string, maxCount: number) => {
|
|
const count = await countDocumentsMock(deviceId)
|
|
if (count <= maxCount) return
|
|
|
|
const toDelete = count - maxCount
|
|
// Simulate finding oldest documents
|
|
const oldest = Array.from({ length: toDelete }, (_, i) => ({
|
|
sequenceNumber: i + 1,
|
|
}))
|
|
|
|
if (oldest.length === 0) return
|
|
|
|
const maxSeqToDelete = oldest[oldest.length - 1].sequenceNumber
|
|
await deleteManyMock({
|
|
deviceId,
|
|
sequenceNumber: { $lte: maxSeqToDelete },
|
|
})
|
|
},
|
|
},
|
|
}))
|
|
|
|
import { ExchangeStoreService } from '../../app/services/exchange_store_service.js'
|
|
|
|
// ─── Test Suite ───────────────────────────────────────────────────────────────
|
|
|
|
describe('Integration: Message Cap Enforcement', () => {
|
|
let service: ExchangeStoreService
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
;(ExchangeStoreService as any).instance = undefined
|
|
service = ExchangeStoreService.getInstance()
|
|
sequenceCounter = 0
|
|
|
|
// Each call to findOneAndUpdate increments and returns the next sequence number
|
|
findOneAndUpdateMock.mockImplementation(() => {
|
|
sequenceCounter++
|
|
return Promise.resolve({ seq: sequenceCounter })
|
|
})
|
|
})
|
|
|
|
it('enforces cap: inserting 5001 messages results in oldest being deleted and count = 5000', async () => {
|
|
const deviceId = 'device-cap-test'
|
|
const totalMessages = 5001
|
|
const maxCap = 5000
|
|
|
|
// Track the state: as messages are inserted, count grows
|
|
// After each insert, enforceMessageCap is called
|
|
// We simulate: for the first 5000 inserts, count <= 5000 so no delete
|
|
// On the 5001st insert, count is 5001 so deleteOldestBeyondCap is called
|
|
|
|
let currentCount = 0
|
|
|
|
countDocumentsMock.mockImplementation((_filter?: any) => {
|
|
// Return current count (updated after each insert)
|
|
return Promise.resolve(currentCount)
|
|
})
|
|
|
|
// Track all deleteMany calls to verify which messages were deleted
|
|
const deleteManyCalls: any[] = []
|
|
deleteManyMock.mockImplementation((filter: any) => {
|
|
deleteManyCalls.push(filter)
|
|
// After delete, current count should decrease
|
|
currentCount = maxCap
|
|
return Promise.resolve({ deletedCount: 1 })
|
|
})
|
|
|
|
// Mock find for deleteOldestBeyondCap: returns the oldest documents to delete
|
|
findMock.mockImplementation(() => ({
|
|
sort: () => ({
|
|
limit: (n: number) => ({
|
|
project: () => ({
|
|
toArray: () =>
|
|
Promise.resolve(
|
|
Array.from({ length: n }, (_, i) => ({
|
|
sequenceNumber: i + 1,
|
|
}))
|
|
),
|
|
}),
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
// Simulate inserting 5001 messages
|
|
for (let i = 0; i < totalMessages; i++) {
|
|
currentCount = i + 1 // After this insert, count is i+1
|
|
await service.persistExchange({
|
|
deviceId,
|
|
direction: 'CPE→ACS',
|
|
sessionId: `session-${i}`,
|
|
rawXml: `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform><DeviceId>${i}</DeviceId></cwmp:Inform></soap-env:Body></soap-env:Envelope>`,
|
|
})
|
|
}
|
|
|
|
// Verify: insertOne was called 5001 times (all messages persisted first)
|
|
expect(insertOneMock).toHaveBeenCalledTimes(totalMessages)
|
|
|
|
// Verify: countByDevice (via countDocuments in the mock) was called
|
|
// Each insert triggers enforceMessageCap which calls countByDevice
|
|
expect(countDocumentsMock).toHaveBeenCalled()
|
|
|
|
// Verify: deleteMany was called when count exceeded 5000
|
|
// The 5001st message triggers a delete
|
|
expect(deleteManyMock).toHaveBeenCalled()
|
|
|
|
// Verify: after enforcement, the effective count is 5000 (capped)
|
|
// The last countDocumentsMock returned 5001, which triggered the delete
|
|
// After delete, count became 5000
|
|
expect(currentCount).toBe(maxCap)
|
|
|
|
// Verify: the deletion targeted the oldest message (sequenceNumber <= 1)
|
|
const lastDeleteCall = deleteManyCalls[deleteManyCalls.length - 1]
|
|
expect(lastDeleteCall).toHaveProperty('deviceId', deviceId)
|
|
expect(lastDeleteCall).toHaveProperty('sequenceNumber')
|
|
expect(lastDeleteCall.sequenceNumber.$lte).toBe(1)
|
|
})
|
|
|
|
it('verifies correct call sequence: countByDevice is called before deleteOldestBeyondCap', async () => {
|
|
const deviceId = 'device-sequence-test'
|
|
const callOrder: string[] = []
|
|
|
|
// Track call order
|
|
countDocumentsMock.mockImplementation((_filter?: any) => {
|
|
callOrder.push('countByDevice')
|
|
return Promise.resolve(5001) // Always over cap for this test
|
|
})
|
|
|
|
deleteManyMock.mockImplementation((filter: any) => {
|
|
callOrder.push('deleteOldestBeyondCap')
|
|
return Promise.resolve({ deletedCount: 1 })
|
|
})
|
|
|
|
findMock.mockImplementation(() => ({
|
|
sort: () => ({
|
|
limit: (n: number) => ({
|
|
project: () => ({
|
|
toArray: () =>
|
|
Promise.resolve([{ sequenceNumber: 1 }]),
|
|
}),
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
await service.persistExchange({
|
|
deviceId,
|
|
direction: 'CPE→ACS',
|
|
sessionId: 'session-test',
|
|
rawXml: '<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform/></soap-env:Body></soap-env:Envelope>',
|
|
})
|
|
|
|
// Verify call sequence: count is checked BEFORE delete
|
|
const countIndex = callOrder.indexOf('countByDevice')
|
|
const deleteIndex = callOrder.indexOf('deleteOldestBeyondCap')
|
|
expect(countIndex).toBeGreaterThanOrEqual(0)
|
|
expect(deleteIndex).toBeGreaterThan(countIndex)
|
|
})
|
|
|
|
it('retains the most recent messages by timestamp after cap enforcement', async () => {
|
|
const deviceId = 'device-recent-test'
|
|
const timestamps: string[] = []
|
|
|
|
// Generate 5001 timestamps in ascending order
|
|
const baseTime = new Date('2025-01-15T10:00:00.000Z')
|
|
for (let i = 0; i < 5001; i++) {
|
|
timestamps.push(new Date(baseTime.getTime() + i * 1000).toISOString())
|
|
}
|
|
|
|
let insertedDocs: any[] = []
|
|
insertOneMock.mockImplementation((doc: any) => {
|
|
insertedDocs.push({ ...doc })
|
|
return Promise.resolve({ insertedId: `id-${doc.sequenceNumber}` })
|
|
})
|
|
|
|
// After 5001 inserts, count exceeds cap
|
|
countDocumentsMock.mockImplementation((_filter?: any) => {
|
|
return Promise.resolve(insertedDocs.length)
|
|
})
|
|
|
|
// Track what gets deleted
|
|
let deletedMaxSeq = 0
|
|
deleteManyMock.mockImplementation((filter: any) => {
|
|
deletedMaxSeq = filter.sequenceNumber?.$lte || 0
|
|
return Promise.resolve({ deletedCount: 1 })
|
|
})
|
|
|
|
findMock.mockImplementation(() => ({
|
|
sort: () => ({
|
|
limit: (n: number) => ({
|
|
project: () => ({
|
|
toArray: () => {
|
|
// Return the oldest n documents (lowest sequence numbers)
|
|
const oldest = insertedDocs
|
|
.slice(0, n)
|
|
.map((d) => ({ sequenceNumber: d.sequenceNumber }))
|
|
return Promise.resolve(oldest)
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
// Override Date to control timestamps
|
|
let dateIndex = 0
|
|
const originalDateNow = Date.prototype.toISOString
|
|
vi.spyOn(Date.prototype, 'toISOString').mockImplementation(function () {
|
|
if (dateIndex < timestamps.length) {
|
|
return timestamps[dateIndex++]
|
|
}
|
|
return originalDateNow.call(this)
|
|
})
|
|
|
|
// Insert 5001 messages
|
|
for (let i = 0; i < 5001; i++) {
|
|
await service.persistExchange({
|
|
deviceId,
|
|
direction: i % 2 === 0 ? 'CPE→ACS' : 'ACS→CPE',
|
|
sessionId: `session-${i}`,
|
|
rawXml: `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform><Seq>${i}</Seq></cwmp:Inform></soap-env:Body></soap-env:Envelope>`,
|
|
})
|
|
}
|
|
|
|
// Verify: 5001 documents were inserted
|
|
expect(insertedDocs).toHaveLength(5001)
|
|
|
|
// Verify: the deletion targeted the oldest message (sequenceNumber = 1)
|
|
// This means messages with seq 2..5001 remain (the 5000 most recent)
|
|
expect(deletedMaxSeq).toBe(1)
|
|
|
|
// Verify: retained messages are the most recent by checking the last inserted
|
|
// has the highest sequence number (5001) and most recent timestamp
|
|
const lastInserted = insertedDocs[insertedDocs.length - 1]
|
|
expect(lastInserted.sequenceNumber).toBe(5001)
|
|
|
|
// The retained set would be seq 2..5001 (most recent 5000)
|
|
// The deleted set would be seq 1 (oldest)
|
|
// Verify the oldest deleted doc has the earliest timestamp
|
|
const oldestDoc = insertedDocs[0]
|
|
const newestRetainedDoc = insertedDocs[1] // seq 2 is now the oldest retained
|
|
expect(new Date(oldestDoc.timestamp).getTime()).toBeLessThan(
|
|
new Date(newestRetainedDoc.timestamp).getTime()
|
|
)
|
|
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('does not delete when count is exactly at cap (5000 messages)', async () => {
|
|
const deviceId = 'device-exact-cap'
|
|
|
|
countDocumentsMock.mockImplementation((_filter?: any) => {
|
|
return Promise.resolve(5000) // Exactly at cap, no deletion needed
|
|
})
|
|
|
|
await service.persistExchange({
|
|
deviceId,
|
|
direction: 'CPE→ACS',
|
|
sessionId: 'session-exact',
|
|
rawXml: '<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform/></soap-env:Body></soap-env:Envelope>',
|
|
})
|
|
|
|
// Verify: deleteMany was NOT called (count <= maxCap)
|
|
expect(deleteManyMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|