// Resource Monitor Tests // Validates container CPU/memory/disk/network tracking, alerts, and persistence jest.mock('dockerode'); jest.mock('fs'); const fs = require('fs'); const EventEmitter = require('events'); // Setup defaults BEFORE requiring singleton fs.existsSync.mockReturnValue(false); fs.readFileSync.mockReturnValue('{}'); fs.writeFileSync.mockReturnValue(undefined); const resourceMonitor = require('../resource-monitor'); function makeStat(overrides = {}) { return { timestamp: new Date().toISOString(), cpu: { percent: 15.5, usage: 500000 }, memory: { usage: 536870912, limit: 2147483648, percent: 25.0, usageMB: 512, limitMB: 2048 }, network: { rxBytes: 1048576, txBytes: 524288, rxMB: 1, txMB: 0.5 }, disk: { readBytes: 0, writeBytes: 0, readMB: 0, writeMB: 0 }, pids: 42, ...overrides }; } beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); fs.existsSync.mockReturnValue(false); fs.readFileSync.mockReturnValue('{}'); fs.writeFileSync.mockReturnValue(undefined); // Reset internal state resourceMonitor.stats.clear(); resourceMonitor.alerts.clear(); resourceMonitor.lastAlerts.clear(); resourceMonitor.monitoring = false; if (resourceMonitor.monitoringInterval) { clearInterval(resourceMonitor.monitoringInterval); resourceMonitor.monitoringInterval = null; } }); afterEach(() => { resourceMonitor.stop(); jest.useRealTimers(); }); describe('ResourceMonitor — container resource tracking', () => { describe('start/stop lifecycle', () => { it('starts monitoring', () => { resourceMonitor.start(); expect(resourceMonitor.monitoring).toBe(true); }); it('ignores double start', () => { resourceMonitor.start(); resourceMonitor.start(); expect(resourceMonitor.monitoring).toBe(true); }); it('stops monitoring and saves stats', () => { resourceMonitor.start(); resourceMonitor.stop(); expect(resourceMonitor.monitoring).toBe(false); expect(resourceMonitor.monitoringInterval).toBeNull(); expect(fs.writeFileSync).toHaveBeenCalled(); }); it('ignores stop when not monitoring', () => { resourceMonitor.stop(); expect(resourceMonitor.monitoring).toBe(false); }); }); describe('recordStats', () => { it('creates new entry for unknown container', () => { const stat = makeStat(); resourceMonitor.recordStats('abc123', '/plex', stat); expect(resourceMonitor.stats.has('abc123')).toBe(true); expect(resourceMonitor.stats.get('abc123').history).toHaveLength(1); }); it('appends to existing container history', () => { resourceMonitor.recordStats('abc123', '/plex', makeStat()); resourceMonitor.recordStats('abc123', '/plex', makeStat()); expect(resourceMonitor.stats.get('abc123').history).toHaveLength(2); }); it('updates container name if changed', () => { resourceMonitor.recordStats('abc123', '/plex-old', makeStat()); resourceMonitor.recordStats('abc123', '/plex-new', makeStat()); expect(resourceMonitor.stats.get('abc123').name).toBe('/plex-new'); }); it('trims stats older than retention period', () => { const oldStat = makeStat({ timestamp: new Date(Date.now() - 999 * 60 * 60 * 1000).toISOString() }); const newStat = makeStat(); resourceMonitor.recordStats('abc123', '/plex', oldStat); resourceMonitor.recordStats('abc123', '/plex', newStat); // Old stat exceeds 168h (7 day) retention expect(resourceMonitor.stats.get('abc123').history).toHaveLength(1); }); }); describe('getCurrentStats', () => { it('returns null for unknown container', () => { expect(resourceMonitor.getCurrentStats('unknown')).toBeNull(); }); it('returns latest stat entry', () => { const stat1 = makeStat({ cpu: { percent: 10, usage: 100 } }); const stat2 = makeStat({ cpu: { percent: 50, usage: 500 } }); resourceMonitor.recordStats('abc123', '/plex', stat1); resourceMonitor.recordStats('abc123', '/plex', stat2); expect(resourceMonitor.getCurrentStats('abc123').cpu.percent).toBe(50); }); }); describe('getHistoricalStats', () => { it('returns empty array for unknown container', () => { expect(resourceMonitor.getHistoricalStats('unknown')).toEqual([]); }); it('filters by time window', () => { const recentStat = makeStat(); const oldStat = makeStat({ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString() }); resourceMonitor.stats.set('abc123', { name: '/plex', history: [oldStat, recentStat] }); // Only last 24 hours const result = resourceMonitor.getHistoricalStats('abc123', 24); expect(result).toHaveLength(1); }); }); describe('getAggregatedStats', () => { it('returns null for unknown container', () => { expect(resourceMonitor.getAggregatedStats('unknown')).toBeNull(); }); it('calculates min/max/avg for CPU and memory', () => { const stats = [ makeStat({ cpu: { percent: 10, usage: 100 }, memory: { percent: 20, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }), makeStat({ cpu: { percent: 30, usage: 300 }, memory: { percent: 40, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }), makeStat({ cpu: { percent: 50, usage: 500 }, memory: { percent: 60, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }), ]; resourceMonitor.stats.set('abc123', { name: '/plex', history: stats }); const agg = resourceMonitor.getAggregatedStats('abc123', 24); expect(agg.cpu.min).toBe(10); expect(agg.cpu.max).toBe(50); expect(agg.cpu.avg).toBe(30); expect(agg.cpu.current).toBe(50); expect(agg.memory.min).toBe(20); expect(agg.memory.max).toBe(60); expect(agg.dataPoints).toBe(3); }); }); describe('getAllStats', () => { it('returns all containers with current and aggregated data', () => { resourceMonitor.recordStats('abc123', '/plex', makeStat()); resourceMonitor.recordStats('def456', '/radarr', makeStat()); const all = resourceMonitor.getAllStats(); expect(Object.keys(all)).toHaveLength(2); expect(all['abc123'].name).toBe('/plex'); expect(all['abc123'].current).toBeDefined(); expect(all['abc123'].aggregated).toBeDefined(); }); }); describe('alert configuration', () => { it('setAlertConfig stores config and persists', () => { resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80, memoryThreshold: 90, cooldownMinutes: 30 }); const config = resourceMonitor.getAlertConfig('abc123'); expect(config.enabled).toBe(true); expect(config.cpuThreshold).toBe(80); expect(config.memoryThreshold).toBe(90); expect(config.cooldownMinutes).toBe(30); expect(fs.writeFileSync).toHaveBeenCalled(); }); it('returns null for unconfigured container', () => { expect(resourceMonitor.getAlertConfig('unknown')).toBeNull(); }); it('removeAlertConfig clears config and cooldown', () => { resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 }); resourceMonitor.lastAlerts.set('abc123', Date.now()); resourceMonitor.removeAlertConfig('abc123'); expect(resourceMonitor.getAlertConfig('abc123')).toBeNull(); expect(resourceMonitor.lastAlerts.has('abc123')).toBe(false); }); }); describe('checkAlerts', () => { it('emits alert when CPU exceeds threshold', () => { const handler = jest.fn(); resourceMonitor.on('alert', handler); resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 50, cooldownMinutes: 0 }); const stat = makeStat({ cpu: { percent: 75, usage: 750 } }); resourceMonitor.checkAlerts('abc123', '/plex', stat); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ containerId: 'abc123', alerts: expect.arrayContaining([ expect.objectContaining({ type: 'cpu', value: 75 }) ]) }) ); resourceMonitor.off('alert', handler); }); it('emits alert when memory exceeds threshold', () => { const handler = jest.fn(); resourceMonitor.on('alert', handler); resourceMonitor.setAlertConfig('abc123', { memoryThreshold: 20, cooldownMinutes: 0 }); const stat = makeStat({ memory: { percent: 80, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }); resourceMonitor.checkAlerts('abc123', '/plex', stat); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ alerts: expect.arrayContaining([ expect.objectContaining({ type: 'memory' }) ]) }) ); resourceMonitor.off('alert', handler); }); it('emits alert when disk I/O exceeds threshold', () => { const handler = jest.fn(); resourceMonitor.on('alert', handler); resourceMonitor.setAlertConfig('abc123', { diskIOThreshold: 10, cooldownMinutes: 0 }); const stat = makeStat({ disk: { readMB: 15, writeMB: 10, readBytes: 0, writeBytes: 0 } }); resourceMonitor.checkAlerts('abc123', '/plex', stat); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ alerts: expect.arrayContaining([ expect.objectContaining({ type: 'disk' }) ]) }) ); resourceMonitor.off('alert', handler); }); it('respects cooldown period', () => { const handler = jest.fn(); resourceMonitor.on('alert', handler); resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 50, cooldownMinutes: 15 }); resourceMonitor.lastAlerts.set('abc123', Date.now()); // Just alerted const stat = makeStat({ cpu: { percent: 99, usage: 990 } }); resourceMonitor.checkAlerts('abc123', '/plex', stat); expect(handler).not.toHaveBeenCalled(); resourceMonitor.off('alert', handler); }); it('skips when alerts not configured or disabled', () => { const handler = jest.fn(); resourceMonitor.on('alert', handler); // No config resourceMonitor.checkAlerts('abc123', '/plex', makeStat()); expect(handler).not.toHaveBeenCalled(); // Disabled config resourceMonitor.alerts.set('abc123', { enabled: false, cpuThreshold: 1 }); resourceMonitor.checkAlerts('abc123', '/plex', makeStat()); expect(handler).not.toHaveBeenCalled(); resourceMonitor.off('alert', handler); }); it('does not alert when below thresholds', () => { const handler = jest.fn(); resourceMonitor.on('alert', handler); resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 90, memoryThreshold: 90, cooldownMinutes: 0 }); const stat = makeStat({ cpu: { percent: 5, usage: 50 }, memory: { percent: 10, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }); resourceMonitor.checkAlerts('abc123', '/plex', stat); expect(handler).not.toHaveBeenCalled(); resourceMonitor.off('alert', handler); }); }); describe('cleanupOldStats', () => { it('removes containers with no recent data', () => { const oldStat = makeStat({ timestamp: new Date(Date.now() - 999 * 60 * 60 * 1000).toISOString() }); resourceMonitor.stats.set('old-container', { name: '/old', history: [oldStat] }); resourceMonitor.cleanupOldStats(); expect(resourceMonitor.stats.has('old-container')).toBe(false); }); it('keeps containers with recent data', () => { resourceMonitor.recordStats('abc123', '/plex', makeStat()); resourceMonitor.cleanupOldStats(); expect(resourceMonitor.stats.has('abc123')).toBe(true); }); }); describe('persistence (loadStats/saveStats)', () => { it('loadStats populates from file', () => { const savedData = { 'abc123': { name: '/plex', history: [makeStat()] } }; fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(JSON.stringify(savedData)); resourceMonitor.loadStats(); expect(resourceMonitor.stats.size).toBe(1); }); it('loadStats handles missing file', () => { fs.existsSync.mockReturnValue(false); resourceMonitor.loadStats(); expect(resourceMonitor.stats.size).toBe(0); }); it('loadStats handles corrupt file', () => { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockImplementation(() => { throw new Error('corrupt'); }); resourceMonitor.loadStats(); // should not throw }); it('saveStats writes Map as JSON object', () => { resourceMonitor.recordStats('abc123', '/plex', makeStat()); resourceMonitor.saveStats(); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.any(String), expect.stringContaining('abc123') ); }); it('saveStats handles write error', () => { fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); resourceMonitor.recordStats('abc123', '/plex', makeStat()); resourceMonitor.saveStats(); // should not throw }); }); describe('alert config persistence', () => { it('loadAlertConfig populates from file', () => { const config = { 'abc123': { enabled: true, cpuThreshold: 80 } }; fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(JSON.stringify(config)); resourceMonitor.loadAlertConfig(); expect(resourceMonitor.alerts.size).toBe(1); }); it('saveAlertConfig writes alerts as JSON', () => { resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 }); expect(fs.writeFileSync).toHaveBeenCalled(); }); }); describe('exportStats / importStats', () => { it('exports stats and alerts', () => { resourceMonitor.recordStats('abc123', '/plex', makeStat()); resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 }); const exported = resourceMonitor.exportStats(); expect(exported.stats['abc123']).toBeDefined(); expect(exported.alerts['abc123']).toBeDefined(); expect(exported.exportedAt).toBeDefined(); }); it('imports stats and alerts', () => { const data = { stats: { 'abc123': { name: '/plex', history: [makeStat()] } }, alerts: { 'abc123': { enabled: true, cpuThreshold: 90 } } }; resourceMonitor.importStats(data); expect(resourceMonitor.stats.size).toBe(1); expect(resourceMonitor.alerts.size).toBe(1); // Should persist after import expect(fs.writeFileSync).toHaveBeenCalled(); }); }); describe('getContainerStats (Docker stats parsing)', () => { it('parses Docker stats into structured format', async () => { const Docker = require('dockerode'); const mockContainer = { stats: jest.fn((opts, cb) => cb(null, { cpu_stats: { cpu_usage: { total_usage: 200000 }, system_cpu_usage: 1000000 }, precpu_stats: { cpu_usage: { total_usage: 100000 }, system_cpu_usage: 500000 }, memory_stats: { usage: 536870912, // 512MB limit: 2147483648 // 2GB }, networks: { eth0: { rx_bytes: 1048576, tx_bytes: 524288 } }, blkio_stats: { io_service_bytes_recursive: [ { op: 'Read', value: 1048576 }, { op: 'Write', value: 2097152 } ] }, pids_stats: { current: 42 } })) }; const result = await resourceMonitor.getContainerStats(mockContainer); expect(result.cpu.percent).toBe(20); // (100000/500000) * 100 expect(result.memory.usageMB).toBe(512); expect(result.memory.limitMB).toBe(2048); expect(result.memory.percent).toBe(25); expect(result.network.rxMB).toBe(1); expect(result.disk.readMB).toBe(1); expect(result.disk.writeMB).toBe(2); expect(result.pids).toBe(42); }); it('handles missing network stats', async () => { const mockContainer = { stats: jest.fn((opts, cb) => cb(null, { cpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 }, precpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 }, memory_stats: { usage: 0, limit: 0 }, blkio_stats: {}, pids_stats: {} })) }; const result = await resourceMonitor.getContainerStats(mockContainer); expect(result.network.rxBytes).toBe(0); expect(result.network.txBytes).toBe(0); expect(result.pids).toBe(0); }); it('rejects on Docker error', async () => { const mockContainer = { stats: jest.fn((opts, cb) => cb(new Error('container gone'))) }; await expect(resourceMonitor.getContainerStats(mockContainer)).rejects.toThrow('container gone'); }); }); });