Local room names did a read-modify-write of io.lotus.room_names against the SDK's local cache, which is stale until the /sync echo, so a second rename issued before the first echoed overwrote it. Route through createAccountDataListStore like user notes. Unit-tested with a client whose setAccountData does not update the local store. Fixes #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
94 lines
3.5 KiB
TypeScript
94 lines
3.5 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { MatrixClient } from 'matrix-js-sdk';
|
|
import { getLocalRoomNamesContent, setLocalRoomName } from './useRoomMeta';
|
|
|
|
// Minimal fake client. Mirrors the real SDK behavior that matters here:
|
|
// setAccountData resolves WITHOUT updating what getAccountData returns — the
|
|
// local cache only updates once the /sync echo is delivered via the
|
|
// AccountData listener. This is exactly the staleness that let two
|
|
// back-to-back renames clobber each other before the fix (issue #17).
|
|
const makeFakeMx = () => {
|
|
const accountData: Record<string, unknown> = {};
|
|
const listeners: Array<(e: { getType: () => string; getContent: () => unknown }) => void> = [];
|
|
const setAccountDataCalls: Array<{ type: string; content: unknown }> = [];
|
|
|
|
const mx = {
|
|
getAccountData: (type: string) => {
|
|
const content = accountData[type];
|
|
return content ? { getContent: () => content } : undefined;
|
|
},
|
|
setAccountData: (type: string, content: unknown) => {
|
|
setAccountDataCalls.push({ type, content });
|
|
// Deliberately do NOT update `accountData` here — the real SDK doesn't
|
|
// either. It only updates on the emitted echo below.
|
|
return Promise.resolve();
|
|
},
|
|
on: (_event: unknown, h: (e: { getType: () => string; getContent: () => unknown }) => void) => {
|
|
listeners.push(h);
|
|
},
|
|
removeListener: (
|
|
_event: unknown,
|
|
h: (e: { getType: () => string; getContent: () => unknown }) => void,
|
|
) => {
|
|
const i = listeners.indexOf(h);
|
|
if (i >= 0) listeners.splice(i, 1);
|
|
},
|
|
};
|
|
|
|
const emitEcho = (type: string, content: unknown) => {
|
|
accountData[type] = content;
|
|
listeners.forEach((h) => h({ getType: () => type, getContent: () => content }));
|
|
};
|
|
|
|
return {
|
|
mx: mx as unknown as MatrixClient,
|
|
emitEcho,
|
|
setAccountDataCalls,
|
|
};
|
|
};
|
|
|
|
test('back-to-back renames of different rooms both survive with no echo in between', async () => {
|
|
const { mx } = makeFakeMx();
|
|
|
|
// Rename room A, then room B, before either write's /sync echo has landed —
|
|
// the exact scenario from issue #17.
|
|
const writeA = setLocalRoomName(mx, '!a:example.org', 'Room A renamed');
|
|
const writeB = setLocalRoomName(mx, '!b:example.org', 'Room B renamed');
|
|
await Promise.all([writeA, writeB]);
|
|
|
|
const content = getLocalRoomNamesContent(mx);
|
|
assert.deepEqual(content.rooms, {
|
|
'!a:example.org': 'Room A renamed',
|
|
'!b:example.org': 'Room B renamed',
|
|
});
|
|
});
|
|
|
|
test("writes are serialized: the second write computes from the first write's result", async () => {
|
|
const { mx, setAccountDataCalls } = makeFakeMx();
|
|
|
|
await Promise.all([
|
|
setLocalRoomName(mx, '!a:example.org', 'A'),
|
|
setLocalRoomName(mx, '!b:example.org', 'B'),
|
|
]);
|
|
|
|
// The last PUT to the server must carry both renames — proof the second
|
|
// write's compute() saw the first write's in-memory result rather than a
|
|
// stale snapshot from before it landed.
|
|
const lastCall = setAccountDataCalls[setAccountDataCalls.length - 1];
|
|
assert.deepEqual(lastCall.content, {
|
|
rooms: { '!a:example.org': 'A', '!b:example.org': 'B' },
|
|
});
|
|
});
|
|
|
|
test('clearing a local name removes only that room', async () => {
|
|
const { mx } = makeFakeMx();
|
|
|
|
await setLocalRoomName(mx, '!a:example.org', 'A');
|
|
await setLocalRoomName(mx, '!b:example.org', 'B');
|
|
await setLocalRoomName(mx, '!a:example.org', '');
|
|
|
|
const content = getLocalRoomNamesContent(mx);
|
|
assert.deepEqual(content.rooms, { '!b:example.org': 'B' });
|
|
});
|