CI / Build & Quality Checks (pull_request) Successful in 2m56s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m43s
The tray only knew what this browser had scheduled (localStorage), but Synapse lists every pending one via MSC4140 GET /delayed_events. Load it when the tray mounts and on window focus (localStorage stays as a first- paint cache), keeping only non-state m.room.message events. Logout now says "N scheduled messages still send after you log out" and offers to cancel them. Fixes #322 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
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);
|
|
});
|