From 7c55912ebee97371772f4db58c02eab18d9dd42f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:46:47 +0000 Subject: [PATCH] fix(account): ein vor dem Umbenennen gestartetes Lesen meldet den alten Namen nicht mehr Co-Authored-By: Claude Opus 5.5 --- web/src/account/profile.test.ts | 56 +++++++++++++++++++++++++++++++++ web/src/account/profile.ts | 14 +++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/web/src/account/profile.test.ts b/web/src/account/profile.test.ts index e99a57e..28f60e8 100644 --- a/web/src/account/profile.test.ts +++ b/web/src/account/profile.test.ts @@ -512,6 +512,62 @@ describe('loadMyUsername', () => { mocks.getRow.mockRejectedValue(offline); await expect(profile.loadMyUsername()).rejects.toBe(offline); }); + + it('eine Antwort, die vor dem Umbenennen abgeschickt wurde, meldet den alten Namen nicht noch einmal: der neuere Name gilt', async () => { + mocks.userId = 'u1'; + let answer!: (row: unknown) => void; + mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve))); + const listener = vi.fn(); + profile.onUsernameChange(listener); + const loading = profile.loadMyUsername(); // das Lesen läuft (Stand vor dem Umbenennen) + mocks.apiPost.mockResolvedValue({ username: 'Neu' }); + await profile.saveUsername('Neu'); // währenddessen umbenannt + answer({ $id: 'u1', username: 'Alt' }); // die Antwort ist älter als das Umbenennen + await expect(loading).resolves.toBe('Neu'); + expect(profile.myUsername()).toBe('Neu'); + expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Neu'); // „Alt“ wurde nie gemeldet + expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]); + }); + + it('dasselbe gilt für eine Antwort „kein Profil“, die vor dem Speichern abgeschickt wurde (Registrierung)', async () => { + mocks.userId = 'u1'; + let answer!: (error: unknown) => void; + mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject))); + const listener = vi.fn(); + profile.onUsernameChange(listener); + const loading = profile.loadMyUsername(); + mocks.apiPost.mockResolvedValue({ username: 'Kenso' }); + await profile.saveUsername('Kenso'); + answer(notFound()); + await expect(loading).resolves.toBe('Kenso'); + expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso'); + }); + + it('ein Lesen, das erst nach dem Umbenennen beginnt, gilt wieder (der Name kann auf einem anderen Gerät geändert worden sein)', async () => { + mocks.userId = 'u1'; + mocks.apiPost.mockResolvedValue({ username: 'Neu' }); + await profile.saveUsername('Neu'); + mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Anderswo' }); + const listener = vi.fn(); + profile.onUsernameChange(listener); + await expect(profile.loadMyUsername()).resolves.toBe('Anderswo'); + expect(profile.myUsername()).toBe('Anderswo'); + expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Anderswo'); + }); + + it('das Umbenennen eines anderen Kontos macht ein laufendes Lesen nicht hinfällig', async () => { + mocks.userId = 'u1'; + let answer!: (row: unknown) => void; + mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve))); + const loading = profile.loadMyUsername(); + mocks.userId = 'u2'; // der Aufruf gehört einem anderen Konto + mocks.apiPost.mockResolvedValue({ username: 'Mia' }); + await profile.saveUsername('Mia'); + mocks.userId = 'u1'; + answer({ $id: 'u1', username: 'Kenso' }); + await expect(loading).resolves.toBe('Kenso'); + expect(profile.myUsername()).toBe('Kenso'); + }); }); describe('Merker „in dieser Sitzung schon gefragt“', () => { diff --git a/web/src/account/profile.ts b/web/src/account/profile.ts index f2474fa..91b3b19 100644 --- a/web/src/account/profile.ts +++ b/web/src/account/profile.ts @@ -79,6 +79,9 @@ export function validateUsername(name: string): string | null { /** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */ const names = new Map(); +/** Zählt die Namen, die diese Seite gespeichert hat; `savedAt` merkt je Konto den Stand beim letzten Speichern (siehe `loadMyUsername`). */ +let saves = 0; +const savedAt = new Map(); const listeners = new Set<(userId: string, username: string) => void>(); /** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */ @@ -188,9 +191,11 @@ export async function isUsernameTaken(name: string): Promise { export async function loadMyUsername(): Promise { const userId = currentUserId(); if (!userId) return null; + const started = saves; const name = await getUsername(userId); - // Kein Profil: war es inzwischen gespeichert (Registrierung: die Abfrage war schneller als das Speichern), gilt der gemerkte Name. - if (name === null) return names.get(userId) ?? null; + // Wurde der Name währenddessen gespeichert (Umbenennen, Registrierung: die Abfrage war schneller als das Speichern), ist die Antwort + // älter als der gemerkte Name: er gilt, und der alte wird nicht noch einmal gemeldet. Kein Profil heißt sonst ebenfalls „der gemerkte“. + if (name === null || (savedAt.get(userId) ?? 0) > started) return names.get(userId) ?? null; announce(userId, name); return name; } @@ -208,7 +213,10 @@ export async function saveUsername(name: string): Promise { const saved = res.username; // Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen. if (typeof saved !== 'string' || saved === '') throw unavailableError(); - if (userId) announce(userId, saved); + if (userId) { + savedAt.set(userId, ++saves); + announce(userId, saved); + } return saved; }