Files
cinny/src/app/utils/redactOwnMessages.test.ts
T
jaredandClaude Opus 5 edb4624796
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m40s
feat(privacy): delete all my messages in a room (#169)
Room Settings → General → Privacy: 'Your messages in this room' with a
Delete all… flow. The confirm dialog first counts your events with a
server-side sender-filtered /messages walk (live count), then asks to confirm
with the number — typing the room name above 50 — and offers 'Leave the room
afterwards'. Files are called out as not purged by a redaction.

The job runs outside React (closing settings is fine): sequential redactEvent
with 429 back-off, 404/already-redacted skipped, progress on the tile with
Cancel, pending ids persisted per room so a reload shows Resume/Discard, a
toast when done. State events are never touched; reactions, edits and thread
replies you sent are included; encrypted rooms work the same (nothing is
decrypted). Own events need no power level, so it is purely self-service.

Unit tests cover candidate filtering, the server filter, pagination, 429/404
handling and cancel. Verified headless: 62 of bob's events (60 messages, a
reaction, a thread reply) redacted in ~34 s while alice's 10 stayed; cancel at
17/40 → reload → Resume → 'Deleted 40 messages.'

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:33:33 -04:00

134 lines
4.1 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
import {
collectOwnEventIds,
isRedactableOwnEvent,
ownMessagesFilter,
redactPending,
RedactJobState,
} from './redactOwnMessages';
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
test('state events, redactions and already-redacted events are not candidates', () => {
assert.equal(isRedactableOwnEvent({ type: 'm.room.message', content: { body: 'x' } }), true);
assert.equal(isRedactableOwnEvent({ type: 'm.reaction', content: { 'm.relates_to': {} } }), true);
assert.equal(
isRedactableOwnEvent({
type: 'm.room.member',
state_key: '@me',
content: { membership: 'join' },
}),
false,
);
assert.equal(isRedactableOwnEvent({ type: 'm.room.redaction', content: {} }), false);
assert.equal(
isRedactableOwnEvent({
type: 'm.room.message',
content: {},
unsigned: { redacted_because: {} },
}),
false,
);
assert.equal(isRedactableOwnEvent({ type: 'm.room.message', content: {} }), false);
});
test('the server-side filter asks only for my events', () => {
const f = ownMessagesFilter('@me:hs');
assert.deepEqual(f.getRoomTimelineFilterComponent()?.toJSON(), { senders: ['@me:hs'] });
});
test('collect paginates until the end token stops moving and keeps only my redactable events', async () => {
const pages: Record<string, { chunk: unknown[]; end?: string }> = {
start: {
chunk: [
{ event_id: '$1', sender: '@me:hs', type: 'm.room.message', content: { body: 'a' } },
{ event_id: '$2', sender: '@other:hs', type: 'm.room.message', content: { body: 'b' } },
],
end: 't1',
},
t1: {
chunk: [
{
event_id: '$3',
sender: '@me:hs',
type: 'm.room.member',
state_key: '@me:hs',
content: {},
},
{ event_id: '$4', sender: '@me:hs', type: 'm.sticker', content: { url: 'mxc://x' } },
],
end: 't2',
},
t2: { chunk: [], end: 't2' },
};
const mx = {
createMessagesRequest: async (_r: string, from: string | null) => pages[from ?? 'start'],
} as unknown as MatrixClient;
const progress: number[] = [];
const ids = await collectOwnEventIds(mx, '!r:hs', '@me:hs', (n) => progress.push(n));
assert.deepEqual(ids, ['$1', '$4']);
assert.deepEqual(progress, [1, 2, 2]);
});
test('redactPending is sequential, skips 404s, honours 429 and persists progress', async () => {
const calls: string[] = [];
let first429 = true;
const mx = {
redactEvent: async (_r: string, id: string) => {
calls.push(id);
if (id === '$gone') throw new MatrixError({ errcode: 'M_NOT_FOUND', error: 'nope' }, 404);
if (id === '$slow' && first429) {
first429 = false;
throw new MatrixError(
{ errcode: 'M_LIMIT_EXCEEDED', error: 'slow', retry_after_ms: 5 },
429,
);
}
return { event_id: '$red' };
},
} as unknown as MatrixClient;
const state: RedactJobState = {
roomId: '!r:hs',
phase: 'redacting',
pending: ['$a', '$gone', '$slow'],
found: 3,
redacted: 0,
skipped: 0,
leaveAfter: false,
};
const seen: number[] = [];
await redactPending(mx, state, (s) => seen.push(s.pending.length));
assert.deepEqual(calls, ['$a', '$gone', '$slow', '$slow']);
assert.equal(state.redacted, 2);
assert.equal(state.skipped, 1);
assert.deepEqual(seen, [2, 1, 0]);
});
test('cancel aborts between events', async () => {
const ctrl = new AbortController();
const mx = {
redactEvent: async () => {
ctrl.abort();
return {};
},
} as unknown as MatrixClient;
const state: RedactJobState = {
roomId: '!r:hs',
phase: 'redacting',
pending: ['$a', '$b'],
found: 2,
redacted: 0,
skipped: 0,
leaveAfter: false,
};
await assert.rejects(() => redactPending(mx, state, () => undefined, ctrl.signal), /cancelled/);
assert.equal(state.redacted, 1);
assert.deepEqual(state.pending, ['$b']);
});