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: `${i}`, }) } // 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: '', }) // 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: `${i}`, }) } // 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: '', }) // Verify: deleteMany was NOT called (count <= maxCap) expect(deleteManyMock).not.toHaveBeenCalled() }) })