Add Jest unit test infrastructure and initial test suite
Sets up Jest + ts-jest for the backend and adds 47 tests across 5 suites covering crypto utilities, OUI vendor lookup, auth middleware, AlertService dispatch/cooldown logic, and DeviceCollector uptime parsing.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
jest.mock('../../config/database');
|
||||
jest.mock('nodemailer');
|
||||
|
||||
import { AlertService } from '../AlertService';
|
||||
import { query } from '../../config/database';
|
||||
|
||||
const mockedQuery = jest.mocked(query);
|
||||
|
||||
// Minimal rule and channel fixtures
|
||||
const enabledRule = { event_type: 'device_offline', enabled: true, threshold: null, cooldown_min: 15 };
|
||||
const disabledRule = { event_type: 'device_online', enabled: false, threshold: null, cooldown_min: 15 };
|
||||
const slackChannel = { id: 1, name: 'slack-test', type: 'slack' as const, enabled: true, config: { webhook_url: 'https://hooks.example.com/test' } };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('dispatch', () => {
|
||||
it('does nothing when the rule is disabled', async () => {
|
||||
mockedQuery.mockResolvedValueOnce([disabledRule]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
await service.dispatch('device_online', 'came back', { cooldownKey: 'test-disabled' });
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls sendToChannel when the rule is enabled', async () => {
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([enabledRule])
|
||||
.mockResolvedValueOnce([slackChannel])
|
||||
.mockResolvedValueOnce([]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
await service.dispatch('device_offline', 'went down', { cooldownKey: 'test-enabled' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendSpy).toHaveBeenCalledWith(slackChannel, 'device_offline', 'went down', expect.any(Object));
|
||||
});
|
||||
|
||||
it('does nothing when no channels are configured', async () => {
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([enabledRule])
|
||||
.mockResolvedValueOnce([]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
await service.dispatch('device_offline', 'msg', { cooldownKey: 'test-no-channels' });
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks a second dispatch during the cooldown window', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([enabledRule]) // rule for first dispatch
|
||||
.mockResolvedValueOnce([slackChannel]) // channels for first dispatch
|
||||
.mockResolvedValueOnce([]); // history insert
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
|
||||
// First dispatch sets the cooldown
|
||||
await service.dispatch('high_cpu', 'cpu spike', { cooldownKey: 'cooldown-test-key' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
sendSpy.mockClear();
|
||||
// Second dispatch immediately — cooldown not yet expired
|
||||
mockedQuery.mockResolvedValueOnce([enabledRule]);
|
||||
await service.dispatch('high_cpu', 'cpu spike again', { cooldownKey: 'cooldown-test-key' });
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('allows dispatch again after the cooldown expires', async () => {
|
||||
jest.useFakeTimers();
|
||||
const shortCooldownRule = { ...enabledRule, cooldown_min: 1 };
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([shortCooldownRule])
|
||||
.mockResolvedValueOnce([slackChannel])
|
||||
.mockResolvedValueOnce([]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
|
||||
await service.dispatch('log_error', 'first', { cooldownKey: 'cooldown-expire-test' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past 1-minute cooldown
|
||||
jest.advanceTimersByTime(61 * 1000);
|
||||
sendSpy.mockClear();
|
||||
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([shortCooldownRule])
|
||||
.mockResolvedValueOnce([slackChannel])
|
||||
.mockResolvedValueOnce([]);
|
||||
await service.dispatch('log_error', 'second', { cooldownKey: 'cooldown-expire-test' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── slackColor ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('slackColor', () => {
|
||||
const service = new AlertService();
|
||||
const slackColor = (et: string) => (service as never as Record<string, (s: string) => string>).slackColor(et);
|
||||
|
||||
it('returns "good" for device_online', () => {
|
||||
expect(slackColor('device_online')).toBe('good');
|
||||
});
|
||||
|
||||
it('returns "danger" for device_offline', () => {
|
||||
expect(slackColor('device_offline')).toBe('danger');
|
||||
});
|
||||
|
||||
it('returns "danger" for high_cpu', () => {
|
||||
expect(slackColor('high_cpu')).toBe('danger');
|
||||
});
|
||||
|
||||
it('returns "warning" for cert_expiry', () => {
|
||||
expect(slackColor('cert_expiry')).toBe('warning');
|
||||
});
|
||||
});
|
||||
|
||||
// ── discordColor ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discordColor', () => {
|
||||
const service = new AlertService();
|
||||
const discordColor = (et: string) => (service as never as Record<string, (s: string) => number>).discordColor(et);
|
||||
|
||||
it('returns green (0x22c55e) for device_online', () => {
|
||||
expect(discordColor('device_online')).toBe(0x22c55e);
|
||||
});
|
||||
|
||||
it('returns amber (0xf59e0b) for log_warning', () => {
|
||||
expect(discordColor('log_warning')).toBe(0xf59e0b);
|
||||
});
|
||||
|
||||
it('returns amber (0xf59e0b) for cert_expiry', () => {
|
||||
expect(discordColor('cert_expiry')).toBe(0xf59e0b);
|
||||
});
|
||||
|
||||
it('returns red (0xef4444) for device_offline', () => {
|
||||
expect(discordColor('device_offline')).toBe(0xef4444);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Mock external deps before importing DeviceCollector
|
||||
jest.mock('../../../utils/crypto', () => ({ decrypt: (s: string) => s }));
|
||||
jest.mock('../RouterOSClient');
|
||||
jest.mock('../../../config/database');
|
||||
jest.mock('../../../config/influxdb');
|
||||
jest.mock('../../../utils/oui');
|
||||
jest.mock('../../../utils/serverArp');
|
||||
|
||||
import { DeviceCollector, DeviceRow } from '../DeviceCollector';
|
||||
|
||||
const testDevice: DeviceRow = {
|
||||
id: 1,
|
||||
name: 'test-router',
|
||||
ip_address: '192.168.1.1',
|
||||
api_port: 8728,
|
||||
api_username: 'admin',
|
||||
api_password_encrypted: 'plaintext-password',
|
||||
device_type: 'router',
|
||||
status: 'online',
|
||||
};
|
||||
|
||||
// ── parseUptime ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseUptime', () => {
|
||||
let collector: DeviceCollector;
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new DeviceCollector(testDevice);
|
||||
});
|
||||
|
||||
function parse(uptime: string): number {
|
||||
// Access private method for unit testing
|
||||
return (collector as unknown as Record<string, (s: string) => number>).parseUptime(uptime);
|
||||
}
|
||||
|
||||
it('parses a full uptime string (1w2d3h4m5s)', () => {
|
||||
// 1w=604800, 2d=172800, 3h=10800, 4m=240, 5s=5 → 788645
|
||||
expect(parse('1w2d3h4m5s')).toBe(788645);
|
||||
});
|
||||
|
||||
it('parses weeks only', () => {
|
||||
expect(parse('2w')).toBe(2 * 604800);
|
||||
});
|
||||
|
||||
it('parses days only', () => {
|
||||
expect(parse('3d')).toBe(3 * 86400);
|
||||
});
|
||||
|
||||
it('parses hours and minutes', () => {
|
||||
expect(parse('5h30m')).toBe(5 * 3600 + 30 * 60);
|
||||
});
|
||||
|
||||
it('parses seconds only', () => {
|
||||
expect(parse('45s')).toBe(45);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty string', () => {
|
||||
expect(parse('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for zero-second uptime (0s)', () => {
|
||||
expect(parse('0s')).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user