import { beforeEach, describe, expect, it, vi } from 'vitest' // --- Mocks --- 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), }, })) const mockIsTraced = vi.fn() const mockList = vi.fn() vi.mock('#services/tr069_trace_service', () => ({ Tr069TraceService: { getInstance: () => ({ isTraced: mockIsTraced, list: mockList, }), }, })) vi.mock('#services/exchange_store_service', () => ({ ExchangeStoreService: { getInstance: () => ({ subscribe: vi.fn(), unsubscribe: vi.fn(), }), }, })) vi.mock('#config/exchange_store', () => ({ default: { maxMessagesPerDevice: 5000, readTimeoutMs: 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 --- function createHttpContext(overrides: { params?: Record qs?: Record } = {}) { const responseData: { status?: number; body?: any } = {} const response = { status(code: number) { responseData.status = code return this }, json(data: any) { responseData.body = data return { status: responseData.status, body: data } }, send(data: any) { responseData.body = data return { status: responseData.status, body: data } }, } const request = { qs: () => overrides.qs || {}, id: () => 'test-correlation-id', } return { params: overrides.params || {}, request, response, responseData, } as any } describe('ExchangeController', () => { let controller: ExchangeController beforeEach(() => { vi.clearAllMocks() controller = new ExchangeController() mockList.mockReturnValue([]) }) describe('index()', () => { it('returns 404 when device has never been traced and has no exchanges', async () => { mockIsTraced.mockReturnValue(false) mockHasExchanges.mockResolvedValue(false) const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const result = await controller.index(ctx) expect(result.status).toBe(404) expect(result.body.error).toContain('No trace exists') }) it('returns 200 with empty array when device is traced but has no messages', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(false) mockFindByDevice.mockResolvedValue([]) mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 }) const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const result = await controller.index(ctx) expect(result.status).toBe(200) expect(result.body.exchanges).toEqual([]) expect(result.body.metadata.traceStatus).toBe('ACTIVE') expect(result.body.metadata.total).toBe(0) }) it('returns 200 with exchanges for actively traced device', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) const exchanges = [ { deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' }, { deviceId: 'device-123', direction: 'ACS→CPE', timestamp: '2025-01-15T10:30:01.000Z', cwmpMethod: 'InformResponse' }, ] mockFindByDevice.mockResolvedValue(exchanges) mockGetMetadata.mockResolvedValue({ recordedFrom: '2025-01-15T10:30:00.000Z', recordedUntil: '2025-01-15T10:30:01.000Z', count: 2, }) const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const result = await controller.index(ctx) expect(result.status).toBe(200) expect(result.body.exchanges).toHaveLength(2) expect(result.body.metadata.traceStatus).toBe('ACTIVE') expect(result.body.metadata.traceStoppedAt).toBeNull() expect(result.body.metadata.recordedFrom).toBe('2025-01-15T10:30:00.000Z') expect(result.body.metadata.recordedUntil).toBe('2025-01-15T10:30:01.000Z') expect(result.body.metadata.total).toBe(2) }) it('returns 200 with STOPPED status for previously traced device', async () => { mockIsTraced.mockReturnValue(false) mockHasExchanges.mockResolvedValue(true) const exchanges = [ { deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' }, ] mockFindByDevice.mockResolvedValue(exchanges) mockGetMetadata.mockResolvedValue({ recordedFrom: '2025-01-15T10:30:00.000Z', recordedUntil: '2025-01-15T10:35:00.000Z', count: 1, }) const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const result = await controller.index(ctx) expect(result.status).toBe(200) expect(result.body.metadata.traceStatus).toBe('STOPPED') expect(result.body.metadata.traceStoppedAt).toBe('2025-01-15T10:35:00.000Z') }) it('uses default limit of 200 when not specified', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) mockFindByDevice.mockResolvedValue([]) mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 }) const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) await controller.index(ctx) expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 200, after: undefined, before: undefined, latest: false }) }) it('accepts valid limit parameter', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) mockFindByDevice.mockResolvedValue([]) mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 }) const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '50' } }) await controller.index(ctx) expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 50, after: undefined, before: undefined, latest: false }) }) it('clamps limit to 1000', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) mockFindByDevice.mockResolvedValue([]) mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 }) const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '5000' } }) await controller.index(ctx) expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 1000, after: undefined, before: undefined, latest: false }) }) it('returns 400 for non-numeric limit', async () => { const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: 'abc' } }) const result = await controller.index(ctx) expect(result.status).toBe(400) expect(result.body.error).toContain('Invalid limit') }) it('returns 400 for limit less than 1', async () => { const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '0' } }) const result = await controller.index(ctx) expect(result.status).toBe(400) expect(result.body.error).toContain('Invalid limit') }) it('returns 400 for negative limit', async () => { const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '-5' } }) const result = await controller.index(ctx) expect(result.status).toBe(400) expect(result.body.error).toContain('Invalid limit') }) it('returns 400 for float limit', async () => { const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '2.5' } }) const result = await controller.index(ctx) expect(result.status).toBe(400) expect(result.body.error).toContain('Invalid limit') }) it('accepts valid after parameter', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) mockFindByDevice.mockResolvedValue([]) mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 }) const after = '2025-01-15T10:30:00.000Z' const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after } }) await controller.index(ctx) expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 200, after, before: undefined, latest: false }) }) it('returns 400 for invalid after parameter', async () => { const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after: 'not-a-date' } }) const result = await controller.index(ctx) expect(result.status).toBe(400) expect(result.body.error).toContain('Invalid after') }) it('returns 400 for partial ISO 8601 timestamp in after', async () => { const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after: '2025-13-45' } }) const result = await controller.index(ctx) expect(result.status).toBe(400) expect(result.body.error).toContain('Invalid after') }) it('returns partial results with isTruncated: true when query exceeds 5s timeout', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) // Simulate a slow query that never resolves within the timeout mockFindByDevice.mockImplementation(() => new Promise((resolve) => { setTimeout(() => resolve([{ deviceId: 'device-123', cwmpMethod: 'Inform' }]), 10_000) })) mockGetMetadata.mockResolvedValue({ recordedFrom: '2025-01-15T10:30:00.000Z', recordedUntil: '2025-01-15T10:35:00.000Z', count: 42, }) vi.useFakeTimers() const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const resultPromise = controller.index(ctx) // Advance timers past the 5s timeout await vi.advanceTimersByTimeAsync(5001) const result = await resultPromise expect(result.status).toBe(200) expect(result.body.exchanges).toEqual([]) expect(result.body.metadata.isTruncated).toBe(true) expect(result.body.metadata.total).toBe(42) vi.useRealTimers() }) it('does not include isTruncated flag when query completes within timeout', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) const exchanges = [ { deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' }, ] mockFindByDevice.mockResolvedValue(exchanges) mockGetMetadata.mockResolvedValue({ recordedFrom: '2025-01-15T10:30:00.000Z', recordedUntil: '2025-01-15T10:30:00.000Z', count: 1, }) const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const result = await controller.index(ctx) expect(result.status).toBe(200) expect(result.body.exchanges).toHaveLength(1) expect(result.body.metadata.isTruncated).toBeUndefined() expect(result.body.metadata.total).toBe(1) }) it('includes total count in metadata even when truncated', async () => { mockIsTraced.mockReturnValue(true) mockHasExchanges.mockResolvedValue(true) mockFindByDevice.mockImplementation(() => new Promise((resolve) => { setTimeout(() => resolve([]), 10_000) })) mockGetMetadata.mockResolvedValue({ recordedFrom: '2025-01-15T10:00:00.000Z', recordedUntil: '2025-01-15T12:00:00.000Z', count: 3500, }) vi.useFakeTimers() const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const resultPromise = controller.index(ctx) await vi.advanceTimersByTimeAsync(5001) const result = await resultPromise expect(result.status).toBe(200) expect(result.body.metadata.isTruncated).toBe(true) expect(result.body.metadata.total).toBe(3500) vi.useRealTimers() }) }) describe('destroy()', () => { it('returns 204 with no body', async () => { mockDeleteByDevice.mockResolvedValue(5) const ctx = createHttpContext({ params: { deviceId: 'device-123' } }) const result = await controller.destroy(ctx) expect(result.status).toBe(204) expect(mockDeleteByDevice).toHaveBeenCalledWith('device-123') }) it('returns 204 even when no exchanges existed', async () => { mockDeleteByDevice.mockResolvedValue(0) const ctx = createHttpContext({ params: { deviceId: 'device-456' } }) const result = await controller.destroy(ctx) expect(result.status).toBe(204) expect(mockDeleteByDevice).toHaveBeenCalledWith('device-456') }) }) })