Files
cinny/src/app/state/plaintextCaches.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

86 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
// These modules touch localStorage at import/runtime. Provide a minimal mock
// that records removed keys, then import dynamically (a static import would
// hoist above the mock).
const removed: string[] = [];
const store = new Map<string, string>();
// A Proxy so `Object.keys(localStorage)` (used by the prefix sweeps) sees the
// stored keys, like the real Storage object.
const api = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
removed.push(k);
store.delete(k);
},
};
(globalThis as { localStorage?: unknown }).localStorage = new Proxy(api, {
ownKeys: () => Array.from(store.keys()),
getOwnPropertyDescriptor: (target, key) =>
typeof key === 'string' && store.has(key)
? { value: store.get(key), enumerable: true, configurable: true, writable: true }
: Object.getOwnPropertyDescriptor(target, key),
});
const { clearPlaintextCaches } = await import('./plaintextCaches');
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
store.clear();
store.set('cinny_translation_cache_v1', '[]');
store.set('cinny_scheduled_messages_v1', '{}');
store.set('cinny_recent_searches_v1', '[]');
store.set('cinny_recent_forward_targets_v1', '[]');
store.set('cinny_recent_gifs_v1', '[]');
store.set('cinny_recent_stickers_v1', '[]');
store.set('lotus_outbox_v1', '{"userId":"@me:server","entries":[]}');
removed.length = 0;
clearPlaintextCaches();
for (const key of [
'cinny_translation_cache_v1',
'cinny_scheduled_messages_v1',
'cinny_recent_searches_v1',
'cinny_recent_forward_targets_v1',
'cinny_recent_gifs_v1',
'cinny_recent_stickers_v1',
'lotus_outbox_v1', // [Gitea #112] unsent message content
]) {
assert.ok(removed.includes(key), `${key} cleared`);
}
assert.equal(store.size, 0, 'all keys gone from store');
});
test('clearPlaintextCaches clears the per-user nav-path store only when given a userId', () => {
store.clear();
store.set('navToActivePath@me:server', '{}');
removed.length = 0;
clearPlaintextCaches(); // no userId -> nav path untouched
assert.ok(!removed.includes('navToActivePath@me:server'), 'nav path kept without userId');
clearPlaintextCaches('@me:server');
assert.ok(removed.includes('navToActivePath@me:server'), 'nav path cleared with userId');
});
test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but not session keys', () => {
store.clear();
store.set('draft-msg-!room:server', '{"body":"unsent"}');
store.set('lotus-status-msg-@me:server', 'at the dentist');
store.set('lotus-status-expiry-@me:server', '123');
store.set('cinny_session', '{"accessToken":"x"}');
removed.length = 0;
clearPlaintextCaches('@me:server');
assert.ok(removed.includes('draft-msg-!room:server'), 'draft cleared');
assert.ok(removed.includes('lotus-status-msg-@me:server'), 'status message cleared');
assert.ok(removed.includes('lotus-status-expiry-@me:server'), 'status expiry cleared');
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
assert.ok(store.has('cinny_session'));
});