Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:
1. SMTP auth never applied: the UI sent email.user/email.pass while the
manager read username/password. Route now normalizes aliases; legacy
config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
folds every known spelling (manager gate, route store, legacy files);
UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
deploy-failed/auto-restart were missing from DEFAULT events, and
send('test') was itself gated -> the Test button was a no-op. Defaults
added; 'test' bypasses the gate.
Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.
Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).
Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
/**
|
|
* Smoke tests for notification-manager.js
|
|
* Verifies the NotificationManager loads, exposes the expected interface,
|
|
* handles config loading/saving, sends notifications via providers, and
|
|
* correctly tracks history.
|
|
*/
|
|
|
|
jest.mock('fs', () => ({
|
|
existsSync: jest.fn().mockReturnValue(false),
|
|
readFileSync: jest.fn().mockReturnValue('{}'),
|
|
writeFileSync: jest.fn(),
|
|
mkdirSync: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('nodemailer', () => ({
|
|
createTransport: jest.fn(() => ({
|
|
sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }),
|
|
})),
|
|
}));
|
|
|
|
const fs = require('fs');
|
|
const nodemailer = require('nodemailer');
|
|
const NotificationManager = require('../src/managers/notification-manager');
|
|
|
|
describe('NotificationManager', () => {
|
|
let nm;
|
|
const NOTIF_FILE = '/tmp/dc-notif-test.json';
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
fs.existsSync.mockReturnValue(false);
|
|
fs.readFileSync.mockReturnValue('{}');
|
|
fs.writeFileSync.mockReturnValue(undefined);
|
|
fs.mkdirSync.mockReturnValue(undefined);
|
|
|
|
nm = new NotificationManager({
|
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
fetchT: jest.fn(),
|
|
docker: null,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
nm.stopHealthDaemon();
|
|
});
|
|
|
|
test('initializes with default config', () => {
|
|
const cfg = nm.getConfig();
|
|
expect(cfg.enabled).toBe(true);
|
|
expect(cfg.providers).toHaveProperty('discord');
|
|
expect(cfg.providers).toHaveProperty('telegram');
|
|
expect(cfg.providers).toHaveProperty('ntfy');
|
|
expect(cfg.providers).toHaveProperty('email');
|
|
});
|
|
|
|
test('starts with empty history and null lastSent', () => {
|
|
expect(nm.getHistory()).toEqual([]);
|
|
expect(nm.lastSent).toBeNull();
|
|
});
|
|
|
|
test('saveConfig writes the config to disk and creates parent dir', async () => {
|
|
fs.existsSync.mockReturnValue(false);
|
|
await nm.saveConfig();
|
|
expect(fs.mkdirSync).toHaveBeenCalled();
|
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
const callArgs = fs.writeFileSync.mock.calls[0];
|
|
expect(callArgs[0]).toBe(NOTIF_FILE);
|
|
expect(callArgs[1]).toContain('enabled');
|
|
});
|
|
|
|
test('loadConfig merges file content with defaults', () => {
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.readFileSync.mockReturnValue(JSON.stringify({ enabled: false }));
|
|
const loaded = new NotificationManager({
|
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
});
|
|
expect(loaded.getConfig().enabled).toBe(false);
|
|
});
|
|
|
|
test('clearHistory empties the history array', () => {
|
|
nm.history.push({ event: 'test', timestamp: new Date().toISOString() });
|
|
expect(nm.getHistory().length).toBe(1);
|
|
nm.clearHistory();
|
|
expect(nm.getHistory().length).toBe(0);
|
|
});
|
|
|
|
test('send returns disabled when notifications are off', async () => {
|
|
nm.config.enabled = false;
|
|
const result = await nm.send('alert', { text: 'hi' });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/disabled/i);
|
|
});
|
|
|
|
test('send returns event-not-enabled for unknown events', async () => {
|
|
nm.config.events['some-disabled-event'] = false;
|
|
const result = await nm.send('some-disabled-event', { text: 'hi' });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/not enabled/i);
|
|
});
|
|
|
|
test('send with no providers enabled records history and returns success:false', async () => {
|
|
const result = await nm.send('alert', { text: 'hello' });
|
|
expect(result).toHaveProperty('results');
|
|
expect(Array.isArray(result.results)).toBe(true);
|
|
expect(nm.getHistory().length).toBe(1);
|
|
expect(nm.getHistory()[0].event).toBe('alert');
|
|
});
|
|
|
|
test('sendDiscord calls ctx.fetchT and returns success on 2xx', async () => {
|
|
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
|
const result = await nm.sendDiscord('msg', { title: 'T' });
|
|
expect(result.success).toBe(true);
|
|
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
|
'https://hook.test/x',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
test('sendDiscord throws on non-2xx response', async () => {
|
|
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: false, status: 500 });
|
|
await expect(nm.sendDiscord('msg', null)).rejects.toThrow(/Discord/);
|
|
});
|
|
|
|
test('sendTelegram calls Telegram API', async () => {
|
|
nm.config.providers.telegram = { enabled: true, botToken: 'TOK', chatId: '123' };
|
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }) });
|
|
const result = await nm.sendTelegram('hello');
|
|
expect(result.success).toBe(true);
|
|
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
|
expect.stringContaining('api.telegram.org'),
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
test('sendNtfy posts to the configured serverUrl + topic', async () => {
|
|
nm.config.providers.ntfy = { enabled: true, topic: 'dashcaddy', serverUrl: 'https://ntfy.sh' };
|
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
|
const result = await nm.sendNtfy('body', 'title');
|
|
expect(result.success).toBe(true);
|
|
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
|
'https://ntfy.sh/dashcaddy',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
test('sendEmail uses nodemailer transporter', async () => {
|
|
nm.config.providers.email = {
|
|
enabled: true,
|
|
host: 'smtp.test',
|
|
port: 587,
|
|
to: 'me@test',
|
|
from: 'from@test',
|
|
username: 'u',
|
|
password: 'p',
|
|
};
|
|
const result = await nm.sendEmail('subject', 'body');
|
|
expect(result.success).toBe(true);
|
|
expect(nodemailer.createTransport).toHaveBeenCalled();
|
|
});
|
|
|
|
test('sendAlert, sendBackupComplete, sendServiceEvent do not throw', async () => {
|
|
const alertResult = await nm.sendAlert({
|
|
containerName: 'web',
|
|
alerts: [{ type: 'cpu', severity: 'warning', message: 'high' }],
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
expect(alertResult).toBeDefined();
|
|
|
|
const backupResult = await nm.sendBackupComplete({
|
|
name: 'daily',
|
|
status: 'success',
|
|
});
|
|
expect(backupResult).toBeDefined();
|
|
|
|
const serviceResult = await nm.sendServiceEvent('container-down', {
|
|
name: 'web',
|
|
containerName: 'sami-web',
|
|
});
|
|
expect(serviceResult).toBeDefined();
|
|
});
|
|
|
|
test('checkHealth returns checked:false when no docker client', async () => {
|
|
nm.ctx.docker = null;
|
|
const r = await nm.checkHealth();
|
|
expect(r.checked).toBe(false);
|
|
});
|
|
|
|
test('checkHealth with mocked docker returns checked:true', async () => {
|
|
nm.ctx.docker = {
|
|
listContainers: jest.fn().mockResolvedValue([
|
|
{ Id: 'aaaabbbbcccc', Names: ['/web'], State: 'running', Status: 'Up' },
|
|
{ Id: 'ddddeeeeffff', Names: ['/api'], State: 'exited', Status: 'Exited' },
|
|
]),
|
|
};
|
|
nm.config.healthCheck = { enabled: true, intervalMinutes: 5 };
|
|
const r = await nm.checkHealth();
|
|
expect(r.checked).toBe(true);
|
|
expect(r.containersMonitored).toBe(2);
|
|
});
|
|
|
|
test('formatTitle returns a string for known events', () => {
|
|
expect(typeof nm._formatTitle('alert')).toBe('string');
|
|
expect(typeof nm._formatTitle('unknown')).toBe('string');
|
|
});
|
|
|
|
test('startHealthDaemon and stopHealthDaemon are idempotent', () => {
|
|
nm.startHealthDaemon();
|
|
nm.startHealthDaemon(); // should not double-schedule
|
|
nm.stopHealthDaemon();
|
|
nm.stopHealthDaemon();
|
|
expect(nm.healthDaemonInterval).toBeNull();
|
|
});
|
|
|
|
// ── DC-092: event alias folding + legacy config canonicalization ──────────
|
|
|
|
test('DC-092: send() folds camelCase aliases onto canonical kebab keys', async () => {
|
|
// deploymentSuccess (emitted by routes/apps/deploy.js) previously hit a
|
|
// gate miss (no such key in events) and the notification was dropped.
|
|
const result = await nm.send('deploymentSuccess', { text: 'deployed' });
|
|
expect(result.error).toBeUndefined();
|
|
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
|
});
|
|
|
|
test('DC-092: send() accepts the canonical kebab spelling too', async () => {
|
|
const result = await nm.send('deploy-success', { text: 'deployed' });
|
|
expect(result.error).toBeUndefined();
|
|
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
|
});
|
|
|
|
test("DC-092: send('test') bypasses the events gate (Test button works)", async () => {
|
|
const result = await nm.send('test', { text: 'Test Notification' });
|
|
// No providers are enabled in the default config, so results is empty —
|
|
// but the gate must NOT return 'Event test not enabled' like it used to.
|
|
expect(result.error).toBeUndefined();
|
|
expect(nm.getHistory()[0].event).toBe('test');
|
|
});
|
|
|
|
test('DC-092: send() still gates unknown and disabled events', async () => {
|
|
const unknown = await nm.send('some-unknown-event', { text: 'x' });
|
|
expect(unknown.success).toBe(false);
|
|
expect(unknown.error).toMatch(/not enabled/i);
|
|
|
|
nm.config.events['container-down'] = false;
|
|
const disabled = await nm.send('container-down', { text: 'x' });
|
|
expect(disabled.success).toBe(false);
|
|
expect(disabled.error).toMatch(/not enabled/i);
|
|
});
|
|
|
|
test('DC-092: DEFAULT_CONFIG includes deploy/auto-restart events', () => {
|
|
// Regression pin: these were absent entirely, so deploy notifications
|
|
// were dropped for every install regardless of UI toggles.
|
|
expect(nm.config.events['deploy-success']).toBe(true);
|
|
expect(nm.config.events['deploy-failed']).toBe(true);
|
|
expect(nm.config.events['auto-restart']).toBe(true);
|
|
});
|
|
|
|
test('DC-092: legacy config with user/pass and camelCase events canonicalizes on load', () => {
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.readFileSync.mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
providers: {
|
|
email: {
|
|
enabled: true,
|
|
host: 'smtp.test',
|
|
port: 465,
|
|
secure: 'false', // legacy string — must normalize to boolean false
|
|
to: 'me@test',
|
|
from: 'from@test',
|
|
user: 'legacy-user',
|
|
pass: 'legacy-pass',
|
|
}
|
|
},
|
|
events: {
|
|
containerDown: false,
|
|
deploymentSuccess: false,
|
|
}
|
|
}));
|
|
const loaded = new NotificationManager({
|
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
});
|
|
const email = loaded.getConfig().providers.email;
|
|
expect(email.username).toBe('legacy-user');
|
|
expect(email.password).toBe('legacy-pass');
|
|
expect(email.user).toBeUndefined();
|
|
expect(email.pass).toBeUndefined();
|
|
expect(email.secure).toBe(false);
|
|
const events = loaded.getConfig().events;
|
|
expect(events['container-down']).toBe(false);
|
|
expect(events['deploy-success']).toBe(false);
|
|
expect(events.containerDown).toBeUndefined();
|
|
expect(events.deploymentSuccess).toBeUndefined();
|
|
});
|
|
|
|
test('DC-092: canonical keys win when both spellings exist in a legacy file', () => {
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.readFileSync.mockReturnValue(JSON.stringify({
|
|
providers: { email: { user: 'legacy', username: 'canonical' } },
|
|
events: { containerDown: false, 'container-down': true },
|
|
}));
|
|
const loaded = new NotificationManager({
|
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
});
|
|
expect(loaded.getConfig().providers.email.username).toBe('canonical');
|
|
expect(loaded.getConfig().events['container-down']).toBe(true);
|
|
});
|
|
});
|