test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core infrastructure, API routes, and error handling. Final coverage: 86.73% statements / 80.57% branches / 85.57% functions / 87.42% lines, all above the 80% threshold enforced by jest.config.js. Highlights: - Unit tests for crypto-utils, credential-manager, auth-manager, csrf, input-validator, state-manager, health-checker, backup-manager, update-manager, resource-monitor, app-templates, platform-paths, port-lock-manager, errors, error-handler, pagination, url-resolver - Route tests for health, services, and containers (supertest + mocked deps) - Shared test-utils helper for mock factories and Express app builder - npm scripts for CI: test:ci, test:unit, test:routes, test:security, test:changed, test:debug - jest.config.js: expand coverage targets, add 80% threshold gate - routes/services.js: import ValidationError and NotFoundError from errors - .gitignore: exclude coverage/, *.bak, *.log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
472
dashcaddy-api/__tests__/resource-monitor.test.js
Normal file
472
dashcaddy-api/__tests__/resource-monitor.test.js
Normal file
@@ -0,0 +1,472 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user