Files
adam-acs/tests/unit/controllers/health_controller.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

296 lines
9.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock dependencies before importing the controller
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: vi.fn(),
}))
vi.mock('#services/exchange_index_checker', () => ({
verifyCwmpExchangesIndexes: vi.fn(),
}))
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: vi.fn(),
},
}))
import { getMongoDb } from '#providers/mongodb_provider'
import { verifyCwmpExchangesIndexes } from '#services/exchange_index_checker'
import { ExchangeStoreService } from '#services/exchange_store_service'
import HealthController from '#controllers/health_controller'
describe('HealthController.checkTr069Exchanges', () => {
let controller: HealthController
let mockResponse: any
let mockContext: any
beforeEach(() => {
vi.clearAllMocks()
controller = new HealthController()
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
}
mockContext = {
response: mockResponse,
}
})
it('returns healthy status when MongoDB is connected with low latency and all indexes present', async () => {
const mockDb = {
command: vi.fn().mockResolvedValue({ ok: 1 }),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(500),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [
{ name: 'idx_deviceId_timestamp', description: 'Compound index', exists: true },
{ name: 'idx_deviceId_sequenceNumber', description: 'Unique index', exists: true },
{ name: 'idx_createdAt_ttl', description: 'TTL index', exists: true },
],
})
// Mock collStats command
mockDb.command.mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 1048576 }) // 1MB
return Promise.resolve({})
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 3,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'healthy',
mongodb: expect.objectContaining({
connected: true,
latency_ms: expect.any(Number),
collections: {
cwmp_exchanges: {
count: 500,
size_mb: expect.any(Number),
},
},
indexes: expect.objectContaining({
allPresent: true,
}),
}),
sse: {
active_connections: 3,
},
timestamp: expect.any(String),
})
)
})
it('returns unhealthy status when MongoDB is unreachable', async () => {
vi.mocked(getMongoDb).mockImplementation(() => {
throw new Error('MongoDB is not connected')
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(503)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'unhealthy',
mongodb: expect.objectContaining({
connected: false,
latency_ms: null,
collections: null,
indexes: null,
}),
})
)
})
it('returns degraded status when MongoDB latency is >= 100ms', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) {
// Simulate a delay >= 100ms by manipulating Date.now
return new Promise((resolve) => setTimeout(() => resolve({ ok: 1 }), 105))
}
if (cmd.collStats) return Promise.resolve({ size: 2097152 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(1000),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 5,
} as any)
await controller.checkTr069Exchanges(mockContext)
// The test may be flaky due to timing, so we check both possible outcomes
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.mongodb.connected).toBe(true)
// Latency >= 100ms would be degraded, otherwise healthy
if (jsonCall.mongodb.latency_ms >= 100) {
expect(jsonCall.status).toBe('degraded')
expect(mockResponse.status).toHaveBeenCalledWith(200)
} else {
expect(jsonCall.status).toBe('healthy')
}
})
it('returns degraded status when indexes are missing', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 512000 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(200),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: false,
indexes: [
{ name: 'idx_deviceId_timestamp', description: 'Compound index', exists: true },
{ name: 'idx_deviceId_sequenceNumber', description: 'Unique index', exists: false },
{ name: 'idx_createdAt_ttl', description: 'TTL index', exists: true },
],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 1,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'degraded',
mongodb: expect.objectContaining({
connected: true,
indexes: expect.objectContaining({
allPresent: false,
}),
}),
})
)
})
it('reports SSE active connection count from ExchangeStoreService', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 0 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 42,
} as any)
await controller.checkTr069Exchanges(mockContext)
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.sse.active_connections).toBe(42)
})
it('handles collStats failure gracefully (size defaults to 0)', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.reject(new Error('ns not found'))
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.mongodb.collections.cwmp_exchanges.size_mb).toBe(0)
})
it('includes a valid ISO 8601 timestamp in the response', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 0 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
const jsonCall = mockResponse.json.mock.calls[0][0]
const date = new Date(jsonCall.timestamp)
expect(date.getTime()).not.toBeNaN()
})
})