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 = { 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']); });