261 lines
9.1 KiB
TypeScript
261 lines
9.1 KiB
TypeScript
|
|
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<Parameters<typeof shouldNotifyThreadReply>[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<string, { mode: ThreadNotificationMode.All; ts: number }> = {};
|
||
|
|
// 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), {});
|
||
|
|
});
|
||
|
|
});
|