CI / Build & Quality Checks (pull_request) Successful in 1m43s
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 13m4s
Production Synapse caps delayed events at 24h, but the dialog let any later time be picked and then showed the raw server error. The date input now has min/max (today .. now+24h), a too-late time shows "You can schedule up to 24 hours ahead." as soon as it is picked (and blocks Schedule), and ORG.MATRIX.MSC4140_DELAY_TOO_LARGE maps to the same text. Fixes #275 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
194 lines
7.0 KiB
TypeScript
194 lines
7.0 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);
|
|
});
|
|
|
|
test('[#275] the server limit maps to friendly text; other errors keep theirs', async () => {
|
|
const { describeScheduleError, SCHEDULE_TOO_FAR_TEXT, MAX_SCHEDULE_AHEAD_MS } =
|
|
await import('./scheduledMessages');
|
|
assert.equal(MAX_SCHEDULE_AHEAD_MS, 86_400_000);
|
|
const tooLarge = Object.assign(new Error('The requested delay (172800000ms) exceeds…'), {
|
|
errcode: 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE',
|
|
});
|
|
assert.equal(describeScheduleError(tooLarge), SCHEDULE_TOO_FAR_TEXT);
|
|
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
|
|
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
|
|
});
|