diff --git a/web/src/main.test.ts b/web/src/main.test.ts index 5b42f8e..abd2140 100644 --- a/web/src/main.test.ts +++ b/web/src/main.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => { /** Der angemeldete Nutzer, wie `currentUser`/`currentUserId` ihn nennen. */ user: null as TestUser | null, authListeners: new Set<(user: TestUser | null) => void>(), + /** Die Hörer von `onLogout`: sie hören nur das Abmelden durch den Nutzer, nicht jede Änderung des Nutzers. */ + logoutListeners: new Set<() => void>(), loadMyUsername: vi.fn(), usernameDialog: vi.fn(), pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() }, @@ -28,6 +30,10 @@ vi.mock('./account/auth', () => ({ mocks.authListeners.add(fn); return () => mocks.authListeners.delete(fn); }, + onLogout: (fn: () => void) => { + mocks.logoutListeners.add(fn); + return () => mocks.logoutListeners.delete(fn); + }, })); vi.mock('./account/login-dialog', () => ({ requireLogin: vi.fn() })); vi.mock('./account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog })); @@ -75,6 +81,7 @@ beforeEach(() => { mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“ mocks.user = null; mocks.authListeners.clear(); + mocks.logoutListeners.clear(); mocks.loadMyUsername.mockReset().mockResolvedValue(null); // das Konto hat keinen Namen mocks.usernameDialog.mockReset().mockResolvedValue(null); // „Später“ sessionStorage.clear(); @@ -251,6 +258,12 @@ describe('Benutzername abfragen (Spec § 5)', () => { mocks.user = user; for (const fn of [...mocks.authListeners]) fn(user); }; + /** Der Nutzer meldet sich ab: wie in auth.ts erst der Nutzer weg (onAuthChange), dann die Abmelde-Hörer (onLogout). */ + const userLogsOut = () => { + mocks.user = null; + for (const fn of [...mocks.authListeners]) fn(null); + for (const fn of [...mocks.logoutListeners]) fn(); + }; const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); it('Start mit Sitzung, Konto ohne Namen: der Dialog „prompt“ erscheint genau einmal; der Merker liegt in sessionStorage', async () => { @@ -313,6 +326,64 @@ describe('Benutzername abfragen (Spec § 5)', () => { expect(mocks.usernameDialog).toHaveBeenCalledTimes(1); }); + it('Abmelden nimmt den Merker weg: ein zweites Konto ohne Namen im selben Tab wird gefragt', async () => { + const mia: TestUser = { $id: 'u2', email: 'mia@example.com' }; + sessionAtStart(); + await start('/'); + await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1)); + expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1'); + + userLogsOut(); + expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull(); + + userSignsIn(mia); + await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2)); + expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1'); // und für dieses Konto gilt es wieder + userSignsIn(mia); + await settle(); + expect(mocks.usernameDialog).toHaveBeenCalledTimes(2); + }); + + it('auch dasselbe Konto wird nach dem Abmelden und erneuten Anmelden (weiter ohne Namen) wieder gefragt', async () => { + sessionAtStart(); + await start('/'); + await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1)); + userLogsOut(); + userSignsIn(kenso); + await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2)); + }); + + it('nur das Abmelden räumt den Merker weg, nicht jede Änderung des Nutzers (das Anmelden beim Registrieren räumt eine alte Sitzung weg)', async () => { + sessionAtStart(); + await start('/'); + await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1)); + mocks.user = null; + for (const fn of [...mocks.authListeners]) fn(null); // abgelaufene Sitzung: kein onLogout + expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1'); + }); + + it('Abmelden ohne gesetzten Merker ist harmlos', async () => { + mocks.loadMyUsername.mockResolvedValue('Kenso'); + sessionAtStart(); + await start('/'); + await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1)); + expect(() => userLogsOut()).not.toThrow(); + expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull(); + }); + + it('ist sessionStorage gesperrt, wirft das Abmelden nichts', async () => { + const denied = () => { + throw new DOMException('blockiert', 'SecurityError'); + }; + vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied }); + sessionAtStart(); + await start('/'); + await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1)); + expect(() => userLogsOut()).not.toThrow(); + userSignsIn(); // der Merker im Speicher ist mit weg: gefragt wird wieder + await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2)); + }); + it('Anmeldung, Konto mit Namen: kein Dialog', async () => { mocks.loadMyUsername.mockResolvedValue('Kenso'); await start('/'); @@ -353,6 +424,7 @@ describe('Benutzername abfragen (Spec § 5)', () => { await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1)); mocks.authListeners.clear(); // die erste Seite ist weg + mocks.logoutListeners.clear(); mocks.loadMyUsername.mockClear(); await start('/'); await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1)); @@ -367,6 +439,7 @@ describe('Benutzername abfragen (Spec § 5)', () => { sessionStorage.clear(); // neuer Tab = neue sessionStorage mocks.authListeners.clear(); + mocks.logoutListeners.clear(); await start('/'); await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2)); }); diff --git a/web/src/main.ts b/web/src/main.ts index a4f9f59..b63ce57 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,8 +2,8 @@ import '@fontsource-variable/nunito-sans'; import './styles.css'; import { mountAccountMenu, type AccountMenu } from './account/account-menu'; import { client } from './account/appwrite'; -import { currentUserId, getCurrentUser, onAuthChange } from './account/auth'; -import { loadMyUsername, markUsernamePrompted, usernamePrompted } from './account/profile'; +import { currentUserId, getCurrentUser, onAuthChange, onLogout } from './account/auth'; +import { clearUsernamePrompted, loadMyUsername, markUsernamePrompted, usernamePrompted } from './account/profile'; import { usernameDialog } from './account/username-dialog'; import { DONATE_URL } from './config'; import { currentLang, onLangChange, setLang } from './i18n/i18n'; @@ -94,6 +94,11 @@ onAuthChange((user) => { if (user) void checkUsername(user.$id); }); +// Der Merker gilt je Anmeldung: wer sich abmeldet, nimmt ihn mit, und ein weiteres Konto ohne Namen im selben Tab wird gefragt. Nur beim +// Abmelden, nicht bei jeder Änderung des Nutzers: das Anmelden beim Registrieren räumt auch eine alte Sitzung weg (onAuthChange(null)), und +// die Registrierung hat den Merker da schon gesetzt (login-dialog.ts). +onLogout(clearUsernamePrompted); + // Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), // gilt der Nutzer als abgemeldet. if (accountSlot) {