Files
adam-acs/tests/integration/message_cap.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

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()
})
})