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