Files
cinny/src/app/utils/outbox.test.ts
T
Lotus CIandClaude Opus 5.5 02d86caeb0
CI / Build & Quality Checks (pull_request) Successful in 6m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 10m28s
feat: offline outbox — unsent messages survive reload and retry (#112)
Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).

- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
  sends (text, stickers, reactions, polls; not call signalling or
  redactions) are mirrored to localStorage from their first local echo until
  the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
  same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
  same txnId; older ones come back as "Failed to send" for the user to
  retry or cancel. Ones the server already has (transaction id seen in
  /sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
  the connection returns (sync recovers or the browser goes back online),
  and after a blip while online (5 s, backing off, max 10 per message).
  Oldest first, in order per room. 4xx / consent / encryption failures are
  left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
  when you're back online" (thread view too), not the red ✕. The ✕ is now
  a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
  decrypted, like drafts).

Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 21:02:45 -04:00

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],
);
});