/** * 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); }); });