Files
cinny/src/app/utils/matrix.declineInvite.test.ts
T
jaredandClaude Opus 4.8 29d74eda8f
CI / Build & Quality Checks (push) Successful in 27m33s
CI / Trigger Desktop Build (push) Successful in 7s
fix(invites): decline invites robustly (no 500, no ghost, friendly error)
Declining a remote invite could show a raw 'MatrixError: [500] Internal server
error' and appear to do nothing. Root causes were client-side: decline called
mx.leave unconditionally, so re-clicking after a slow federated leave hit an
already-left remote room that Synapse 500s on; the room was never forgotten so a
'leave' ghost lingered and re-invited a click; and the raw error string was shown.

Add a shared declineInvite(mx, roomId) helper that only leaves when still in the
room (invite/join/knock) and then forgets it (best-effort, first use of forget in
the app). Route the InviteCard decline and both 'Decline All' paths through it,
and replace the raw error with a friendly message (real error kept in console).

Tests: declineInvite covered (6 cases); typecheck + full suite + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:55:35 -04:00

64 lines
2.2 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { declineInvite } from './matrix';
// declineInvite must only leave when we're still in the room (invite/join/knock)
// — re-leaving an already-left remote room is what Synapse 500s on — and must
// always forget afterwards (best-effort) so the ghost invite can't linger.
const makeMx = (
membership: string | undefined,
opts: { leaveRejects?: boolean; forgetRejects?: boolean } = {},
) => {
const calls: string[] = [];
const mx = {
getRoom: () => (membership === undefined ? null : { getMyMembership: () => membership }),
leave: async () => {
calls.push('leave');
if (opts.leaveRejects) throw new Error('leave failed');
return {};
},
forget: async () => {
calls.push('forget');
if (opts.forgetRejects) throw new Error('forget failed');
return {};
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return { mx, calls };
};
test('declineInvite: invite → leave then forget', async () => {
const { mx, calls } = makeMx('invite');
await declineInvite(mx, '!r:s');
assert.deepEqual(calls, ['leave', 'forget']);
});
test('declineInvite: already left → skips leave, still forgets (no double-leave 500)', async () => {
const { mx, calls } = makeMx('leave');
await declineInvite(mx, '!r:s');
assert.deepEqual(calls, ['forget']);
});
test('declineInvite: join → leave then forget', async () => {
const { mx, calls } = makeMx('join');
await declineInvite(mx, '!r:s');
assert.deepEqual(calls, ['leave', 'forget']);
});
test('declineInvite: no room object → only forget', async () => {
const { mx, calls } = makeMx(undefined);
await declineInvite(mx, '!r:s');
assert.deepEqual(calls, ['forget']);
});
test('declineInvite: forget failure is swallowed (best-effort)', async () => {
const { mx, calls } = makeMx('invite', { forgetRejects: true });
await declineInvite(mx, '!r:s'); // resolves despite forget throwing
assert.deepEqual(calls, ['leave', 'forget']);
});
test('declineInvite: genuine leave failure rejects', async () => {
const { mx } = makeMx('invite', { leaveRejects: true });
await assert.rejects(() => declineInvite(mx, '!r:s'), /leave failed/);
});