/** * DC-094: remaining gate-miss notification emitters + legacy 4-arg send shape. * * Part 1 — seven emitters were absent from DEFAULT events, so the send() * gate (config.events[canonical] !== true) silently dropped them all: * ssl-cert-expiry (ssl-monitor), dns-propagation (dns-propagation), * drift-detected (config-drift-detector), dependency-restart-complete/-failed * (dependency-manager), recipe-removed (recipes/manage), workflow * (bundled-workflows). Stored configs must inherit the new defaults via the * _mergeConfig shallow per-key merge. * * Part 2 — nine in-repo call sites used a legacy 4-arg shape * send(event, title, message, type) against the 3-arg signature: the message * string landed in the `type` slot (embed color fell back) and providers got * the TITLE as the body. send() now shims that shape, and the explicit title * flows to ntfy/email subjects and the Discord embed title. * * Part 3 — route EVENT_KEY_ALIASES and manager EVENT_ALIASES stay in sync: * recipeRemoved and the dependency-restart spellings fold in both places. */ 'use strict'; const fs = require('fs'); const nodemailer = require('nodemailer'); 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 NotificationManager = require('../src/managers/notification-manager'); describe('DC-094 NotificationManager', () => { let nm; beforeEach(() => { jest.clearAllMocks(); fs.existsSync.mockReturnValue(false); fs.readFileSync.mockReturnValue('{}'); nm = new NotificationManager({ NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json', log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, fetchT: jest.fn(), docker: null, }); // jest.config restoreMocks strips the factory nodemailer implementation // before every test; re-establish it and capture the sendMail mock so // email assertions don't depend on module state. nodemailer.createTransport.mockImplementation(() => { mailMock = jest.fn().mockResolvedValue({ messageId: 'mock' }); return { sendMail: mailMock }; }); // Same aliasing hazard as providers: without a config file the // constructor's spread aliases module-level DEFAULT_CONFIG.events, so // gate-mutation tests would poison every later instance. nm.config.events = { ...nm.config.events }; }); let mailMock; afterEach(() => { nm.stopHealthDaemon(); }); describe('new events present in DEFAULT events (gate-miss fix)', () => { const newlyGated = [ 'ssl-cert-expiry', 'dns-propagation', 'drift-detected', 'dependency-restart', 'recipe-removed', 'workflow', ]; test.each(newlyGated)('%s defaults to enabled', (event) => { expect(nm.config.events[event]).toBe(true); }); test.each(newlyGated)('%s passes the send() gate by default', async (event) => { nm.config.providers.discord = { enabled: false }; // no providers -> send short-circuits after the gate const result = await nm.send(event, { text: 'x' }); expect(result.error).not.toBe(`Event ${event} not enabled`); }); test('dependency-restart spellings alias onto the single canonical toggle', async () => { nm.config.events['dependency-restart'] = false; const complete = await nm.send('dependency-restart-complete', { text: 'x' }); const failed = await nm.send('dependency-restart-failed', { text: 'x' }); expect(complete.error).toBe('Event dependency-restart not enabled'); expect(failed.error).toBe('Event dependency-restart not enabled'); }); test('recipeRemoved camelCase alias folds onto recipe-removed', async () => { nm.config.events['recipe-removed'] = false; const result = await nm.send('recipeRemoved', { text: 'x' }); expect(result.error).toBe('Event recipe-removed not enabled'); }); test('stored pre-DC-094 configs inherit the new event defaults via merge', () => { // A config saved before this fix has none of the new keys. After load, // the defaults merge must supply them as enabled. const legacyFile = JSON.stringify({ enabled: true, providers: { discord: { enabled: false, webhookUrl: '' } }, events: { 'container-down': true, alert: true }, }); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(legacyFile); const loaded = new NotificationManager({ NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json', log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, fetchT: jest.fn(), docker: null, }); for (const event of newlyGated) { expect(loaded.config.events[event]).toBe(true); } // operator choice preserved, not clobbered by defaults expect(loaded.config.events['container-down']).toBe(true); }); test('stored legacy dependency-restart spellings fold at load', () => { const legacyFile = JSON.stringify({ enabled: true, events: { 'dependency-restart-complete': false }, }); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(legacyFile); const loaded = new NotificationManager({ NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json', log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, fetchT: jest.fn(), docker: null, }); expect(loaded.config.events['dependency-restart']).toBe(false); expect(loaded.config.events['dependency-restart-complete']).toBeUndefined(); }); }); describe('legacy 4-arg send shape shim', () => { beforeEach(() => { // Fresh providers object per test: on the no-config-file constructor // path this.config.providers aliases module-level DEFAULT_CONFIG.providers, // so per-provider mutation in one test otherwise leaks into the next. nm.config.providers = { discord: { enabled: false, webhookUrl: '' }, telegram: { enabled: false, botToken: '', chatId: '' }, ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' }, email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' }, }; nm.config.providers.ntfy = { enabled: true, topic: 'dc094', serverUrl: 'https://ntfy.sh' }; nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true }); }); const ntfyCall = (n) => n.ctx.fetchT.mock.calls.find(c => String(c[0]).includes('ntfy.sh')); const discordCall = (n) => n.ctx.fetchT.mock.calls.find(c => String(c[0]).includes('hook.test')); test('send(event, title, message, type) delivers the message as body', async () => { const result = await nm.send('deploymentFailed', 'Recipe Failed', 'Failed to deploy **plex**: boom', 'error'); expect(result.success).toBe(true); const body = ntfyCall(nm)[1].body; expect(body).toBe('Failed to deploy **plex**: boom'); }); test('the explicit legacy title reaches the ntfy Title header', async () => { await nm.send('deploymentFailed', 'Recipe Failed', 'boom', 'error'); const headers = ntfyCall(nm)[1].headers; expect(headers.Title).toBe('Recipe Failed'); }); test('canonical-title events without data.title still get the mapped title', async () => { await nm.send('ssl-cert-expiry', { text: 'expiring' }, 'warning'); const headers = ntfyCall(nm)[1].headers; expect(headers.Title).toBe('SSL Certificate Expiry'); }); test('Discord embed carries the explicit title and the right severity color', async () => { nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' }; nm.config.providers.ntfy = { enabled: false }; await nm.send('deploymentFailed', 'Recipe Failed', 'boom', 'error'); const payload = JSON.parse(discordCall(nm)[1].body); expect(payload.embeds[0].title).toBe('Recipe Failed'); expect(payload.embeds[0].description).toBe('boom'); expect(payload.embeds[0].color).toBe(15158332); // error/red, not the info-blue fallback }); test('email subject uses the explicit title', async () => { nm.config.providers.email = { enabled: true, host: 'smtp.test', port: 587, to: 'a@b.c', from: 'd@e.f', username: '', password: '' }; nm.config.providers.ntfy = { enabled: false }; await nm.send('deploymentSuccess', 'Recipe Deployed', 'plex deployed', 'success'); expect(mailMock.mock.calls.length).toBeGreaterThan(0); const last = mailMock.mock.calls[mailMock.mock.calls.length - 1]; expect(last[0].subject).toBe('Recipe Deployed'); expect(last[0].text).toBe('plex deployed'); }); test('history records the canonical event and the explicit title', async () => { await nm.send('recipeRemoved', 'Recipe Removed', 'Removed **plex** recipe (3 containers).', 'info'); const entry = nm.getHistory()[0]; expect(entry.event).toBe('recipe-removed'); expect(entry.title).toBe('Recipe Removed'); }); test('3-arg object calls are unchanged (no regression)', async () => { await nm.send('alert', { text: 'resource spike' }, 'warning'); const body = ntfyCall(nm)[1].body; expect(body).toBe('resource spike'); const headers = ntfyCall(nm)[1].headers; expect(headers.Title).toBe('Resource Alert'); }); test('shim is type-guarded: a 4th arg with object data is not rewritten', async () => { const data = { text: 'kept' }; await nm.send('alert', data, 'warning', 'stray-extra'); // Object data passes through untouched (stray 4th arg ignored, not // treated as a legacy type) — the shim only fires for legacy // string-title calls. const body = ntfyCall(nm)[1].body; expect(body).toBe('kept'); const entry = nm.getHistory()[0]; expect(entry.type).toBe('warning'); }); }); });