2026-07-02 17:09:28 -04:00
|
|
|
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) => ({ id, lastReply: () => lastReply }) as any;
|
|
|
|
|
|
|
|
|
|
type RoomOpts = {
|
|
|
|
|
timeline?: any[];
|
|
|
|
|
readUpTo?: string | null;
|
|
|
|
|
threads?: any[];
|
|
|
|
|
threadUnread?: Record<string, number>;
|
2026-07-03 22:07:21 -04:00
|
|
|
markedUnread?: boolean;
|
2026-07-02 17:09:28 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setup = (opts: RoomOpts) => {
|
|
|
|
|
const calls: ReceiptCall[] = [];
|
2026-07-03 22:07:21 -04:00
|
|
|
const accountDataWrites: Array<{ type: string; content: any }> = [];
|
2026-07-02 17:09:28 -04:00
|
|
|
const room = {
|
|
|
|
|
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
|
|
|
|
getEventReadUpTo: () => opts.readUpTo ?? null,
|
|
|
|
|
getThreads: () => opts.threads ?? [],
|
|
|
|
|
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
|
|
|
|
opts.threadUnread?.[threadId] ?? 0,
|
2026-07-03 22:07:21 -04:00
|
|
|
getAccountData: (type: string) =>
|
|
|
|
|
opts.markedUnread && type === 'm.marked_unread'
|
|
|
|
|
? { getContent: () => ({ unread: true }) }
|
|
|
|
|
: undefined,
|
2026-07-02 17:09:28 -04:00
|
|
|
};
|
|
|
|
|
const mx = {
|
|
|
|
|
getRoom: () => room,
|
|
|
|
|
getUserId: () => '@me:server',
|
|
|
|
|
sendReadReceipt: async (event: any, receiptType: ReceiptType, unthreaded?: boolean) => {
|
|
|
|
|
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
|
|
|
|
return {};
|
|
|
|
|
},
|
2026-07-03 22:07:21 -04:00
|
|
|
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
|
|
|
|
|
accountDataWrites.push({ type, content });
|
|
|
|
|
return {};
|
|
|
|
|
},
|
2026-07-02 17:09:28 -04:00
|
|
|
} as any;
|
2026-07-03 22:07:21 -04:00
|
|
|
return { mx, calls, accountDataWrites };
|
2026-07-02 17:09:28 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-03 22:07:21 -04:00
|
|
|
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)
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-02 17:09:28 -04:00
|
|
|
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);
|
|
|
|
|
});
|