/** * DC-097: notification-manager `_loadConfig` write-back. * _canonicalizeLegacyKeys (DC-092) fixed legacy spellings in memory only; * the on-disk notifications.json kept `email.user`/`email.pass`, camelCase * event keys, and string `secure` until the next explicit UI save. These * tests pin the new behavior: the canonical form is persisted right after * load, the write is idempotent, and a failed write never blocks startup. */ jest.mock('fs', () => ({ existsSync: jest.fn().mockReturnValue(false), readFileSync: jest.fn().mockReturnValue('{}'), writeFileSync: jest.fn(), mkdirSync: jest.fn(), // DC-099 atomic write path (open tmp → write → fsync → close → rename). openSync: jest.fn().mockReturnValue(3), writeSync: jest.fn(), fsyncSync: jest.fn(), closeSync: jest.fn(), renameSync: jest.fn(), unlinkSync: jest.fn(), })); jest.mock('nodemailer', () => ({ createTransport: jest.fn(() => ({ sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }), })), })); const fs = require('fs'); const NotificationManager = require('../src/managers/notification-manager'); const NOTIF_FILE = '/tmp/dc097-notif-test.json'; function makeCtx(log) { return { NOTIFICATIONS_FILE: NOTIF_FILE, log, }; } // Serializes exactly like the manager does (2-space indent). const ser = (obj) => JSON.stringify(obj, null, 2); function loadWithFile(contents, log) { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(contents); return new NotificationManager(makeCtx(log)); } describe('DC-097 notification config canonicalization write-back', () => { let log; beforeEach(() => { jest.clearAllMocks(); fs.existsSync.mockReturnValue(false); fs.readFileSync.mockReturnValue('{}'); fs.writeFileSync.mockClear(); log = { error: jest.fn(), info: jest.fn(), warn: jest.fn() }; }); afterEach(() => { try { NotificationManager.prototype.stopHealthDaemon && undefined; } catch (_) {} jest.clearAllMocks(); }); test('legacy file (user/pass, camelCase events, string secure) is rewritten on disk in canonical form', () => { const legacy = { enabled: true, providers: { email: { enabled: true, host: 'smtp.test', port: 465, secure: 'false', to: 'me@test', from: 'from@test', user: 'legacy-user', pass: 'legacy-pass', }, }, events: { containerDown: false, deploymentSuccess: false, }, }; const nm = loadWithFile(ser(legacy), log); // In-memory: canonical (pinned by DC-092 tests, re-pinned here). expect(nm.config.providers.email.username).toBe('legacy-user'); expect(nm.config.providers.email.password).toBe('legacy-pass'); expect(nm.config.providers.email.secure).toBe(false); expect(nm.config.events['container-down']).toBe(false); expect(nm.config.events['deploy-success']).toBe(false); // On-disk write-back: exactly one atomic write (DC-099: write tmp → fsync → rename). expect(fs.renameSync).toHaveBeenCalledTimes(1); expect(fs.renameSync.mock.calls[0][1]).toBe(NOTIF_FILE); const contentsArg = fs.writeSync.mock.calls[0][1]; const written = JSON.parse(contentsArg); expect(written.providers.email.username).toBe('legacy-user'); expect(written.providers.email.password).toBe('legacy-pass'); expect(written.providers.email.user).toBeUndefined(); expect(written.providers.email.pass).toBeUndefined(); expect(written.providers.email.secure).toBe(false); expect(written.events['container-down']).toBe(false); written.events && expect(Object.keys(written.events)).not.toContain('containerDown'); nm.stopHealthDaemon && nm.stopHealthDaemon(); }); test('write-back is idempotent: an already-canonical file is not rewritten', () => { // First load performs the write-back; capture what it wrote. const legacy = ser({ providers: { email: { user: 'u', pass: 'p', secure: 'false' } }, events: { containerDown: true }, }); const first = loadWithFile(legacy, log); expect(fs.renameSync).toHaveBeenCalledTimes(1); const canonicalContents = fs.writeSync.mock.calls[0][1]; first.stopHealthDaemon && first.stopHealthDaemon(); fs.renameSync.mockClear(); fs.writeSync.mockClear(); // Second load against the canonical bytes: no write. const second = loadWithFile(canonicalContents, log); expect(fs.renameSync).not.toHaveBeenCalled(); expect(second.config.providers.email.username).toBe('u'); second.stopHealthDaemon && second.stopHealthDaemon(); }); test('legacy keys absent → no write at all (clean file untouched)', () => { // Fully canonical: matches the merged config after serialization. // Build it by round-tripping: write-back from a minimal legacy file // produces the canonical full shape; feed those exact bytes back. const nm = loadWithFile(ser({ enabled: true }), log); // 1 write (defaults fill-in) const canonicalContents = fs.writeSync.mock.calls[0][1]; nm.stopHealthDaemon && nm.stopHealthDaemon(); fs.renameSync.mockClear(); fs.writeSync.mockClear(); const again = loadWithFile(canonicalContents, log); expect(fs.renameSync).not.toHaveBeenCalled(); again.stopHealthDaemon && again.stopHealthDaemon(); }); test('write failure (EACCES) does not throw out of the constructor and in-memory config stays correct', () => { const legacy = ser({ providers: { email: { user: 'u2', pass: 'p2' } }, events: { workflowDone: true }, }); fs.openSync.mockImplementation(() => { throw new Error('EACCES: permission denied'); }); let nm; expect(() => { nm = loadWithFile(legacy, log); }).not.toThrow(); expect(nm.config.providers.email.username).toBe('u2'); expect(nm.config.events['workflow']).toBe(true); // Warn surfaced, no error-level log (load itself succeeded). expect(log.warn).toHaveBeenCalled(); expect(log.error).not.toHaveBeenCalled(); nm.stopHealthDaemon && nm.stopHealthDaemon(); }); test('no file on disk → no read, no write (fresh install untouched)', () => { fs.existsSync.mockReturnValue(false); const nm = new NotificationManager(makeCtx(log)); expect(fs.readFileSync).not.toHaveBeenCalled(); expect(fs.renameSync).not.toHaveBeenCalled(); nm.stopHealthDaemon && nm.stopHealthDaemon(); }); });