132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
import { test } from 'node:test';
|
|||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import {
|
||
|
|
OUTBOX_AUTOSEND_MS,
|
||
|
|
OUTBOX_MAX_ENTRIES,
|
||
|
|
OutboxEntry,
|
||
|
|
isRetryableSendError,
|
||
|
|
outboxRetryDelayMs,
|
||
|
|
parseOutbox,
|
||
|
|
planRestore,
|
||
|
|
shouldKeepInOutbox,
|
||
|
|
withEntry,
|
||
|
|
withoutEntry,
|
||
|
|
} from './outbox';
|
||
|
|
|
||
|
|
const entry = (txnId: string, ts: number, roomId = '!a:hs'): OutboxEntry => ({
|
||
|
|
txnId,
|
||
|
|
roomId,
|
||
|
|
threadId: null,
|
||
|
|
type: 'm.room.message',
|
||
|
|
content: { msgtype: 'm.text', body: txnId },
|
||
|
|
ts,
|
||
|
|
});
|
||
|
|
|
||
|
|
test('keeps message-like events, not call signalling or redactions', () => {
|
||
|
|
assert.equal(shouldKeepInOutbox('m.room.message', { body: 'hi' }), true);
|
||
|
|
assert.equal(shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '$x' } }), true);
|
||
|
|
assert.equal(shouldKeepInOutbox('org.matrix.msc3381.poll.start', {}), true);
|
||
|
|
assert.equal(shouldKeepInOutbox('org.matrix.msc4075.rtc.notification', {}), false);
|
||
|
|
assert.equal(shouldKeepInOutbox('m.call.invite', {}), false);
|
||
|
|
assert.equal(shouldKeepInOutbox('m.room.redaction', {}), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('skips events that point at another unsent message (local id)', () => {
|
||
|
|
assert.equal(
|
||
|
|
shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '~!a:hs:m123' } }),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
shouldKeepInOutbox('m.room.message', {
|
||
|
|
body: 'reply',
|
||
|
|
'm.relates_to': { 'm.in_reply_to': { event_id: '~!a:hs:m1' } },
|
||
|
|
}),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
shouldKeepInOutbox('m.room.message', {
|
||
|
|
body: 'reply',
|
||
|
|
'm.relates_to': { 'm.in_reply_to': { event_id: '$real' } },
|
||
|
|
}),
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('retryable: connection loss, timeouts, rate limits, server errors', () => {
|
||
|
|
const connectionError = new Error('fetch failed');
|
||
|
|
Object.defineProperty(connectionError, 'name', { value: 'ConnectionError' });
|
||
|
|
assert.equal(isRetryableSendError(connectionError), true);
|
||
|
|
assert.equal(isRetryableSendError({ httpStatus: 429 }), true);
|
||
|
|
assert.equal(isRetryableSendError({ httpStatus: 408 }), true);
|
||
|
|
assert.equal(isRetryableSendError({ httpStatus: 502 }), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('not retryable: client errors, consent, unknown or missing errors', () => {
|
||
|
|
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_FORBIDDEN' }), false);
|
||
|
|
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_CONSENT_NOT_GIVEN' }), false);
|
||
|
|
assert.equal(isRetryableSendError({ httpStatus: 400 }), false);
|
||
|
|
assert.equal(isRetryableSendError(new Error('encryption failed')), false);
|
||
|
|
assert.equal(isRetryableSendError(undefined), false);
|
||
|
|
assert.equal(isRetryableSendError(null), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('parse: only this user, only well-formed entries, junk tolerated', () => {
|
||
|
|
const good = entry('m1', 1);
|
||
|
|
const raw = JSON.stringify({ userId: '@me:hs', entries: [good, { txnId: 5 }, null] });
|
||
|
|
assert.deepEqual(parseOutbox(raw, '@me:hs'), [good]);
|
||
|
|
assert.deepEqual(parseOutbox(raw, '@other:hs'), []);
|
||
|
|
assert.deepEqual(parseOutbox('{not json', '@me:hs'), []);
|
||
|
|
assert.deepEqual(parseOutbox(null, '@me:hs'), []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('withEntry: first write wins, sorted, capped (oldest dropped)', () => {
|
||
|
|
const a = entry('a', 2);
|
||
|
|
let list = withEntry([], a);
|
||
|
|
assert.equal(withEntry(list, { ...a, content: { body: 'changed' } }), list);
|
||
|
|
list = withEntry(list, entry('b', 1));
|
||
|
|
assert.deepEqual(
|
||
|
|
list.map((e) => e.txnId),
|
||
|
|
['b', 'a'],
|
||
|
|
);
|
||
|
|
let many: OutboxEntry[] = [];
|
||
|
|
for (let i = 0; i < OUTBOX_MAX_ENTRIES + 5; i += 1) many = withEntry(many, entry(`t${i}`, i));
|
||
|
|
assert.equal(many.length, OUTBOX_MAX_ENTRIES);
|
||
|
|
assert.equal(many[0].txnId, 't5');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('withoutEntry: removes, and returns the same list when absent', () => {
|
||
|
|
const list = [entry('a', 1), entry('b', 2)];
|
||
|
|
assert.deepEqual(
|
||
|
|
withoutEntry(list, 'a').map((e) => e.txnId),
|
||
|
|
['b'],
|
||
|
|
);
|
||
|
|
assert.equal(withoutEntry(list, 'zzz'), list);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('restore plan: drops delivered / left rooms, auto-sends only recent ones', () => {
|
||
|
|
const now = 10 * OUTBOX_AUTOSEND_MS;
|
||
|
|
const recent = entry('recent', now - 60_000);
|
||
|
|
const old = entry('old', now - OUTBOX_AUTOSEND_MS - 1);
|
||
|
|
const delivered = entry('delivered', now - 1000);
|
||
|
|
const left = entry('left', now - 1000, '!left:hs');
|
||
|
|
const plan = planRestore(
|
||
|
|
[recent, left, old, delivered],
|
||
|
|
now,
|
||
|
|
(roomId) => roomId !== '!left:hs',
|
||
|
|
(e) => e.txnId === 'delivered',
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
plan.restore.map((e) => e.txnId),
|
||
|
|
['old', 'recent'],
|
||
|
|
);
|
||
|
|
assert.deepEqual([...plan.autoSend], ['recent']);
|
||
|
|
assert.deepEqual(plan.drop.map((e) => e.txnId).sort(), ['delivered', 'left']);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('blip retry delay backs off and is capped at a minute', () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
[0, 1, 2, 3, 4, 9].map(outboxRetryDelayMs),
|
||
|
|
[5000, 10000, 20000, 40000, 60000, 60000],
|
||
|
|
);
|
||
|
|
});
|