- 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
296 lines
9.2 KiB
TypeScript
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()
|
|
})
|
|
})
|