Files
cinny/src/app/utils/notifications.test.ts
T

291 lines
10 KiB
TypeScript
Raw Normal View History

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { markAsRead } from './notifications';
// markAsRead sends an unthreaded read receipt at the latest main-timeline event,
// plus a THREADED receipt at each unread thread's latest loaded reply. The
// regression these tests guard against: a thread whose replies aren't loaded
// (lastReply() === null) must NOT produce a receipt for the thread root — that
// resolves to a MAIN receipt at an old event and permanently unreads the room.
type ReceiptCall = { eventId: string; receiptType: ReceiptType; unthreaded?: boolean };
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
const thread = (id: string, lastReply: any, extra: Record<string, unknown> = {}) =>
({ id, lastReply: () => lastReply, ...extra }) as any;
type RoomOpts = {
timeline?: any[];
readUpTo?: string | null;
threads?: any[];
threadUnread?: Record<string, number>;
threadHighlight?: Record<string, number>;
readEvents?: string[];
markedUnread?: boolean;
};
const setup = (opts: RoomOpts) => {
const calls: ReceiptCall[] = [];
const accountDataWrites: Array<{ type: string; content: any }> = [];
const room = {
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
getEventReadUpTo: () => opts.readUpTo ?? null,
getThreads: () => opts.threads ?? [],
hasUserReadEvent: (_userId: string, eventId: string) =>
(opts.readEvents ?? []).includes(eventId),
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
(type === NotificationCountType.Highlight ? opts.threadHighlight : opts.threadUnread)?.[
threadId
] ?? 0,
getAccountData: (type: string) =>
opts.markedUnread && type === 'm.marked_unread'
? { getContent: () => ({ unread: true }) }
: undefined,
};
const mx = {
getRoom: () => room,
getUserId: () => '@me:server',
sendReadReceipt: async (event: any, receiptType: ReceiptType, unthreaded?: boolean) => {
calls.push({ eventId: event.getId(), receiptType, unthreaded });
return {};
},
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
accountDataWrites.push({ type, content });
return {};
},
} as any;
return { mx, calls, accountDataWrites };
};
test('main timeline: unthreaded receipt at the latest event', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b'), evt('c')], readUpTo: 'a' });
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], { eventId: 'c', receiptType: ReceiptType.Read, unthreaded: true });
});
test('REGRESSION: an unread thread with unloaded replies (lastReply null) sends NO root receipt', async () => {
const t = thread('$root', null); // replies not loaded
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [t],
threadUnread: { $root: 3 },
});
await markAsRead(mx, '!r:server', false);
// Only the main unthreaded receipt — never a receipt for the thread root.
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, 'b');
assert.equal(calls[0].unthreaded, true);
assert.ok(!calls.some((c) => c.eventId === '$root'));
});
test('unread thread with a loaded reply sends a threaded receipt at that reply', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [t],
threadUnread: { $root: 1 },
});
await markAsRead(mx, '!r:server', false);
const main = calls.find((c) => c.eventId === 'b');
const threaded = calls.find((c) => c.eventId === '$reply');
assert.ok(main && main.unthreaded === true);
assert.ok(threaded && threaded.unthreaded === false);
assert.equal(calls.length, 2);
});
test('main already read but a thread is unread: no main receipt, threaded receipt only', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b', // latest main event already read → getLatestValidEvent() null
threads: [t],
threadUnread: { $root: 2 },
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, '$reply');
assert.equal(calls[0].unthreaded, false);
});
test('everything read: no receipts sent', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b',
threads: [t],
threadUnread: { $root: 0 }, // thread read too
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0);
});
test('marked-unread + already fully read: clears the flag even though no receipt is sent', async () => {
const { mx, calls, accountDataWrites } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b', // nothing newer → no receipt
markedUnread: true,
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
// ...but the marked-unread flag is cleared directly (both keys, unread:false)
2026-07-11 13:52:36 -04:00
assert.ok(
accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false),
);
});
test('not marked-unread: markAsRead does not touch account data', async () => {
const { mx, accountDataWrites } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
});
await markAsRead(mx, '!r:server', false);
assert.equal(accountDataWrites.length, 0);
});
test('sending thread reply is skipped', async () => {
const t = thread('$root', evt('$reply', true)); // isSending → skip
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b',
threads: [t],
threadUnread: { $root: 1 },
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0);
});
test('private receipt flag uses ReadPrivate', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a' });
await markAsRead(mx, '!r:server', true);
assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate);
});
// [Gitea #217] A passive read (just looking at the room) keeps threads the user
// follows unread; an explicit mark-as-read still clears everything.
const sender = (userId: string) => ({ getSender: () => userId });
test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => {
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
const other = thread('$other', evt('$r2'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [mine, other],
threadUnread: { $mine: 1, $other: 1 },
});
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', false], // main-scoped: unthreaded would also read $mine's reply
['$r2', false],
],
);
});
test('passive: threads I started or was mentioned in stay unread', async () => {
const started = thread('$started', evt('$r1'), { rootEvent: sender('@me:server') });
const byTimeline = thread('$t', evt('$r2'), { timeline: [sender('@me:server')] });
const mentioned = thread('$mention', evt('$r3'));
const { mx, calls } = setup({
timeline: [evt('a')],
readUpTo: 'a',
threads: [started, byTimeline, mentioned],
threadUnread: { $started: 1, $t: 1, $mention: 1 },
threadHighlight: { $mention: 1 },
});
await markAsRead(mx, '!r:server', false, { passive: true });
assert.equal(calls.length, 0);
});
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [other],
threadUnread: { $other: 1 },
});
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', true],
['$r2', false],
],
);
});
test('explicit mark as read clears followed threads too', async () => {
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [mine],
threadUnread: { $mine: 1 },
});
await markAsRead(mx, '!r:server', false);
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', true],
['$r1', false],
],
);
});
test('passive: the thread open in the panel is left to the panel', async () => {
const open = thread('$open', evt('$r1'));
const other = thread('$other', evt('$r2'));
const { mx, calls } = setup({
timeline: [evt('a')],
readUpTo: 'a',
threads: [open, other],
threadUnread: { $open: 1, $other: 1 },
});
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
assert.deepEqual(
calls.map((c) => c.eventId),
['$r2'],
);
});
test('passive: a followed thread whose count lags is still protected', async () => {
// The reply and a newer main message arrived in the same sync; the thread's
// count is still 0 but its latest reply (from bob) is unread.
const reply = { ...evt('$r1'), getSender: () => '@bob:server' };
const mine = thread('$mine', reply, { hasCurrentUserParticipated: true });
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [mine],
threadUnread: { $mine: 0 },
});
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[['b', false]],
);
});
test('passive: followed thread already read → main receipt stays unthreaded', async () => {
const reply = { ...evt('$r1'), getSender: () => '@bob:server' };
const mine = thread('$mine', reply, { hasCurrentUserParticipated: true });
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [mine],
readEvents: ['$r1'],
});
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[['b', true]],
);
});