import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { getMutedThreads, getThreadNotificationMode, pruneThreadNotifications, shouldNotifyThreadReply, ThreadDefaultBehavior, ThreadNotificationMode, ThreadNotificationsContent, ThreadNotifyDecision, } from './threadNotifications'; const DAY = 24 * 60 * 60 * 1000; const decide = ( overrides: Partial[0]>, ): ThreadNotifyDecision => shouldNotifyThreadReply({ mode: ThreadNotificationMode.Default, defaultBehavior: 'participating', participated: false, highlight: false, notify: false, roomMuted: false, ...overrides, }); describe('shouldNotifyThreadReply', () => { it('roomMuted trumps everything, even mode All + highlight', () => { assert.equal( decide({ roomMuted: true, mode: ThreadNotificationMode.All, highlight: true }), 'none', ); }); it('roomMuted trumps Default + participating + participated', () => { assert.equal(decide({ roomMuted: true, participated: true }), 'none'); }); it('mode Mute is none regardless of highlight/participation', () => { assert.equal( decide({ mode: ThreadNotificationMode.Mute, highlight: true, participated: true }), 'none', ); }); it('mode All + highlight => loud', () => { assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: true }), 'loud'); }); it('mode All + no highlight => notify', () => { assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: false }), 'notify'); }); it('mode MentionsOnly + highlight => loud', () => { assert.equal(decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: true }), 'loud'); }); it('mode MentionsOnly + no highlight => none (even if participated)', () => { assert.equal( decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: false, participated: true }), 'none', ); }); it('Default + behavior all + highlight => loud', () => { assert.equal(decide({ defaultBehavior: 'all', highlight: true }), 'loud'); }); it('Default + behavior all + no highlight => notify', () => { assert.equal(decide({ defaultBehavior: 'all', highlight: false }), 'notify'); }); it('Default + participating + highlight => loud (even if not participated)', () => { assert.equal( decide({ defaultBehavior: 'participating', highlight: true, participated: false }), 'loud', ); }); it('Default + participating + no highlight + participated => notify', () => { assert.equal( decide({ defaultBehavior: 'participating', highlight: false, participated: true }), 'notify', ); }); it('Default + participating + no highlight + not participated => none', () => { assert.equal( decide({ defaultBehavior: 'participating', highlight: false, participated: false }), 'none', ); }); it('ignores the `notify` input entirely in v1', () => { // notify=true must not upgrade a "none" decision. assert.equal( decide({ defaultBehavior: 'participating', participated: false, notify: true }), 'none', ); // notify=false must not downgrade an "all" mode notify. assert.equal(decide({ mode: ThreadNotificationMode.All, notify: false }), 'notify'); }); }); describe('getThreadNotificationMode', () => { it('returns Default for undefined content', () => { assert.equal(getThreadNotificationMode(undefined, '!r', '$t'), ThreadNotificationMode.Default); }); it('returns Default when room or thread is absent', () => { const content: ThreadNotificationsContent = { rooms: { '!r': { $other: { mode: ThreadNotificationMode.All, ts: 1 } } }, }; assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Default); assert.equal(getThreadNotificationMode(content, '!x', '$t'), ThreadNotificationMode.Default); }); it('returns the stored mode', () => { const content: ThreadNotificationsContent = { rooms: { '!r': { $t: { mode: ThreadNotificationMode.Mute, ts: 1 } } }, }; assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Mute); }); it('is safe against malformed entries', () => { const bad = { rooms: { '!r': { $badMode: { mode: 'nonsense', ts: 1 }, $noTs: { mode: ThreadNotificationMode.All }, $notObj: 'oops', $nullEntry: null, }, }, } as unknown as ThreadNotificationsContent; assert.equal(getThreadNotificationMode(bad, '!r', '$badMode'), ThreadNotificationMode.Default); assert.equal(getThreadNotificationMode(bad, '!r', '$noTs'), ThreadNotificationMode.Default); assert.equal(getThreadNotificationMode(bad, '!r', '$notObj'), ThreadNotificationMode.Default); assert.equal( getThreadNotificationMode(bad, '!r', '$nullEntry'), ThreadNotificationMode.Default, ); }); it('is safe when rooms is not an object', () => { const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent; assert.equal(getThreadNotificationMode(bad, '!r', '$t'), ThreadNotificationMode.Default); }); }); describe('getMutedThreads', () => { it('returns empty set for undefined/absent room', () => { assert.deepEqual(getMutedThreads(undefined, '!r'), new Set()); assert.deepEqual(getMutedThreads({ rooms: {} }, '!r'), new Set()); }); it('collects only Mute entries', () => { const content: ThreadNotificationsContent = { rooms: { '!r': { $a: { mode: ThreadNotificationMode.Mute, ts: 1 }, $b: { mode: ThreadNotificationMode.All, ts: 1 }, $c: { mode: ThreadNotificationMode.Mute, ts: 1 }, }, }, }; assert.deepEqual(getMutedThreads(content, '!r'), new Set(['$a', '$c'])); }); it('ignores malformed entries', () => { const bad = { rooms: { '!r': { $a: { mode: 'mute-ish', ts: 1 }, $b: null } }, } as unknown as ThreadNotificationsContent; assert.deepEqual(getMutedThreads(bad, '!r'), new Set()); }); }); describe('pruneThreadNotifications', () => { const now = 1_000_000_000_000; it('drops rooms not in joinedRoomIds', () => { const content: ThreadNotificationsContent = { rooms: { '!keep': { $t: { mode: ThreadNotificationMode.All, ts: now } }, '!left': { $t: { mode: ThreadNotificationMode.All, ts: now } }, }, }; const out = pruneThreadNotifications(content, new Set(['!keep']), now); assert.deepEqual(Object.keys(out.rooms ?? {}), ['!keep']); }); it('drops entries older than 180 days', () => { const content: ThreadNotificationsContent = { rooms: { '!r': { $fresh: { mode: ThreadNotificationMode.All, ts: now - 179 * DAY }, $old: { mode: ThreadNotificationMode.All, ts: now - 181 * DAY }, }, }, }; const out = pruneThreadNotifications(content, new Set(['!r']), now); assert.deepEqual(Object.keys(out.rooms?.['!r'] ?? {}), ['$fresh']); }); it('caps a room at 200 entries, evicting oldest ts first', () => { const entries: Record = {}; // 205 entries, ts ascending with the id index. for (let i = 0; i < 205; i += 1) { entries[`$t${i}`] = { mode: ThreadNotificationMode.All, ts: now - (205 - i) * 1000 }; } const content: ThreadNotificationsContent = { rooms: { '!r': entries } }; const out = pruneThreadNotifications(content, new Set(['!r']), now); const kept = out.rooms?.['!r'] ?? {}; assert.equal(Object.keys(kept).length, 200); // Oldest 5 ($t0..$t4) evicted; newest retained. assert.equal(kept.$t0, undefined); assert.equal(kept.$t4, undefined); assert.notEqual(kept.$t5, undefined); assert.notEqual(kept.$t204, undefined); }); it('drops rooms left with no entries', () => { const content: ThreadNotificationsContent = { rooms: { '!r': { $old: { mode: ThreadNotificationMode.All, ts: now - 200 * DAY } }, }, }; const out = pruneThreadNotifications(content, new Set(['!r']), now); assert.equal(out.rooms, undefined); }); it('preserves the default behavior field', () => { const behavior: ThreadDefaultBehavior = 'all'; const content: ThreadNotificationsContent = { default: behavior, rooms: {} }; const out = pruneThreadNotifications(content, new Set(), now); assert.equal(out.default, 'all'); }); it('never mutates the input', () => { const content: ThreadNotificationsContent = { default: 'participating', rooms: { '!r': { $t: { mode: ThreadNotificationMode.All, ts: now } }, '!left': { $t: { mode: ThreadNotificationMode.All, ts: now } }, }, }; const snapshot = JSON.parse(JSON.stringify(content)); const out = pruneThreadNotifications(content, new Set(['!r']), now); assert.deepEqual(content, snapshot); // Output room objects are fresh, not shared references with the input. assert.notEqual(out.rooms?.['!r'], content.rooms?.['!r']); }); it('handles malformed rooms container safely', () => { const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent; assert.deepEqual(pruneThreadNotifications(bad, new Set(['!r']), now), {}); }); });