import { test, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { Method } from 'matrix-js-sdk'; import { scheduleMessage, cancelScheduledMessage, restartScheduledMessage, parseDelayedEvents, groupScheduledByRoom, } from './scheduledMessages'; // Minimal MatrixClient stub: records every authedRequest call and returns a // canned { delay_id } so we can assert the request shape MSC4140 expects. type Call = unknown[]; const makeMx = (response: unknown = { delay_id: 'delay-123' }) => { const calls: Call[] = []; const mx = { http: { authedRequest: (...args: unknown[]) => { calls.push(args); return Promise.resolve(response); }, }, }; return { mx: mx as never, calls }; }; const FIXED_NOW = 1_700_000_000_000; let realNow: () => number; beforeEach(() => { realNow = Date.now; Date.now = () => FIXED_NOW; }); afterEach(() => { Date.now = realNow; }); // ── scheduleMessage ───────────────────────────────────────────────────────── test('scheduleMessage sends PUT to the room message endpoint with the delay query', async () => { const { mx, calls } = makeMx(); const content = { msgtype: 'm.text', body: 'later' }; const delayId = await scheduleMessage(mx, '!room:lotusguild.org', content, FIXED_NOW + 5000); assert.equal(calls.length, 1); const [method, path, query, body] = calls[0]; assert.equal(method, Method.Put); // encodeURIComponent leaves '!' untouched but encodes ':'. assert.ok( (path as string).startsWith('/rooms/!room%3Alotusguild.org/send/m.room.message/sched_'), `unexpected path: ${path as string}`, ); assert.deepEqual(query, { 'org.matrix.msc4140.delay': 5000 }); assert.equal(body, content); // content passed through unchanged assert.equal(delayId, 'delay-123'); // returns server delay_id }); test('scheduleMessage rounds a fractional delay', async () => { const { mx, calls } = makeMx(); await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 1500.6); assert.deepEqual(calls[0][2], { 'org.matrix.msc4140.delay': 1501 }); }); test('scheduleMessage floors the delay at 1000ms when target is too soon', async () => { const { mx, calls } = makeMx(); await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 300); assert.deepEqual(calls[0][2], { 'org.matrix.msc4140.delay': 1000 }); }); test('scheduleMessage floors at 1000ms even for a target in the past', async () => { const { mx, calls } = makeMx(); await scheduleMessage(mx, '!r:x', {}, FIXED_NOW - 60_000); assert.deepEqual(calls[0][2], { 'org.matrix.msc4140.delay': 1000 }); }); test('scheduleMessage uses a unique sched_ transaction id per call', async () => { const { mx, calls } = makeMx(); await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 2000); await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 2000); const txn = (p: unknown) => (p as string).split('/').pop() as string; const a = txn(calls[0][1]); const b = txn(calls[1][1]); assert.ok(a.startsWith('sched_')); assert.ok(b.startsWith('sched_')); assert.notEqual(a, b); }); test('scheduleMessage url-encodes the room id', async () => { const { mx, calls } = makeMx(); await scheduleMessage(mx, '!a/b:c.org', {}, FIXED_NOW + 2000); assert.ok((calls[0][1] as string).startsWith('/rooms/!a%2Fb%3Ac.org/send/')); }); // ── cancel / restart ──────────────────────────────────────────────────────── test('cancelScheduledMessage POSTs action:cancel to the delayed_events endpoint', async () => { const { mx, calls } = makeMx(); await cancelScheduledMessage(mx, 'delay-xyz'); assert.equal(calls.length, 1); const [method, path, query, body, opts] = calls[0]; assert.equal(method, Method.Post); assert.equal(path, '/delayed_events/delay-xyz'); assert.equal(query, undefined); assert.deepEqual(body, { action: 'cancel' }); assert.deepEqual(opts, { prefix: '/_matrix/client/unstable/org.matrix.msc4140' }); }); test('restartScheduledMessage POSTs action:restart to the delayed_events endpoint', async () => { const { mx, calls } = makeMx(); await restartScheduledMessage(mx, 'delay-xyz'); const [method, path, , body, opts] = calls[0]; assert.equal(method, Method.Post); assert.equal(path, '/delayed_events/delay-xyz'); assert.deepEqual(body, { action: 'restart' }); assert.deepEqual(opts, { prefix: '/_matrix/client/unstable/org.matrix.msc4140' }); }); test('cancel/restart url-encode the delay id', async () => { const { mx, calls } = makeMx(); await cancelScheduledMessage(mx, 'a/b c'); assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c'); }); test('parseDelayedEvents: Synapse shape, messages only, sendAt from running_since + delay (#322)', () => { const res = { delayed_events: [ { delay_id: 'syd_a', room_id: '!r:x', type: 'm.room.message', content: { msgtype: 'm.text', body: 'hi' }, delay: 60000, running_since: 1000, }, { delay_id: 'syd_call', room_id: '!r:x', type: 'org.matrix.msc3401.call.member', state_key: '_@a:x_D', content: {}, delay: 8000, running_since: 1000, }, { delay_id: 'syd_bad', room_id: '!r:x', type: 'm.room.message', content: {} }, null, ], }; assert.deepEqual(parseDelayedEvents(res), [ { delayId: 'syd_a', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' }, sendAt: 61000 }, ]); }); test('parseDelayedEvents: MSC `scheduled` shape and garbage', () => { const item = { delay_id: 'd', room_id: '!r:x', type: 'm.room.message', content: { body: 'b' }, delay_ms: 5, delayed_since_ts: 10, }; assert.equal(parseDelayedEvents({ scheduled: [item] })[0].sendAt, 15); assert.deepEqual(parseDelayedEvents(undefined), []); assert.deepEqual(parseDelayedEvents({ delayed_events: 'nope' }), []); }); test('groupScheduledByRoom: groups and sorts by sendAt', () => { const m = (delayId: string, roomId: string, sendAt: number) => ({ delayId, roomId, content: {}, sendAt, }); const g = groupScheduledByRoom([m('b', '!1', 20), m('a', '!1', 10), m('c', '!2', 5)]); assert.deepEqual( g.get('!1')?.map((x) => x.delayId), ['a', 'b'], ); assert.equal(g.get('!2')?.length, 1); });