Two silent-death defects in one class: 1. Seven emitters absent from DEFAULT events, so the send() gate (config.events[canonical] !== true) dropped them on every install: ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-complete/-failed, recipeRemoved, workflow. All now default ON; dependency-restart spellings fold onto one canonical toggle; recipeRemoved aliases to recipe-removed in both the manager and route alias maps. 2. Nine call sites used a legacy 4-arg send(event, title, message, type) against the 3-arg signature: the message string landed in the type slot (Discord embed color fell back to info-blue, history.type wrong) and providers received the TITLE as the body - deploy-failure notifications carried no error text at all. Fixed at source (9 sites) plus a type-guarded shim in send() for external legacy callers. Also: explicit data.title now flows to ntfy Title header, email subject, Discord embed title, and history; settings UI gains 9 event toggles (separate Backup Complete/Failed) with defaults-on semantics. Tests: +24 (new DC-094 suite: defaults, gate pass-through, alias folding send-time and load-time, stored-config inheritance, shim body/title/ color/subject/history, type-guard, 3-arg no-regression); 4 assertions in bundled-workflows-health-check updated from the old 4-arg mock contract to the canonical shape (same behavior asserted). Full suite 116 suites / 2706 tests green. Judge: GLM-5.3 cold read via delegate_task (deleg_8a7cedd0), grade A, zero blockers; 2 polish items (Backups toggle conflation, shim type-guard) folded into this commit. Verdict URN: urn:ump:uyipjjwdjqjy3alvceqxvlucrymd7hnsben5udpp2bqycoh3l2la
234 lines
9.9 KiB
JavaScript
234 lines
9.9 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|