fix(account): Abmelden nimmt den Merker „Benutzername schon gefragt“ weg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:47:45 +00:00
1 parent 7c55912ebe
commit 53bcecf3cb
2 files changed
+80 -2

No files matched your search

+73
View File
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => {
/** Der angemeldete Nutzer, wie `currentUser`/`currentUserId` ihn nennen. */ /** Der angemeldete Nutzer, wie `currentUser`/`currentUserId` ihn nennen. */
user: null as TestUser | null, user: null as TestUser | null,
authListeners: new Set<(user: TestUser | null) => void>(), 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(), loadMyUsername: vi.fn(),
usernameDialog: vi.fn(), usernameDialog: vi.fn(),
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() }, pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
@@ -28,6 +30,10 @@ vi.mock('./account/auth', () => ({
mocks.authListeners.add(fn); mocks.authListeners.add(fn);
return () => mocks.authListeners.delete(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/login-dialog', () => ({ requireLogin: vi.fn() }));
vi.mock('./account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog })); vi.mock('./account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
@@ -75,6 +81,7 @@ beforeEach(() => {
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“ mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
mocks.user = null; mocks.user = null;
mocks.authListeners.clear(); mocks.authListeners.clear();
mocks.logoutListeners.clear();
mocks.loadMyUsername.mockReset().mockResolvedValue(null); // das Konto hat keinen Namen mocks.loadMyUsername.mockReset().mockResolvedValue(null); // das Konto hat keinen Namen
mocks.usernameDialog.mockReset().mockResolvedValue(null); // „Später“ mocks.usernameDialog.mockReset().mockResolvedValue(null); // „Später“
sessionStorage.clear(); sessionStorage.clear();
@@ -251,6 +258,12 @@ describe('Benutzername abfragen (Spec § 5)', () => {
mocks.user = user; mocks.user = user;
for (const fn of [...mocks.authListeners]) fn(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)); 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 () => { 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); 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 () => { it('Anmeldung, Konto mit Namen: kein Dialog', async () => {
mocks.loadMyUsername.mockResolvedValue('Kenso'); mocks.loadMyUsername.mockResolvedValue('Kenso');
await start('/'); await start('/');
@@ -353,6 +424,7 @@ describe('Benutzername abfragen (Spec § 5)', () => {
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
mocks.authListeners.clear(); // die erste Seite ist weg mocks.authListeners.clear(); // die erste Seite ist weg
mocks.logoutListeners.clear();
mocks.loadMyUsername.mockClear(); mocks.loadMyUsername.mockClear();
await start('/'); await start('/');
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
@@ -367,6 +439,7 @@ describe('Benutzername abfragen (Spec § 5)', () => {
sessionStorage.clear(); // neuer Tab = neue sessionStorage sessionStorage.clear(); // neuer Tab = neue sessionStorage
mocks.authListeners.clear(); mocks.authListeners.clear();
mocks.logoutListeners.clear();
await start('/'); await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
}); });
+7 -2
View File
@@ -2,8 +2,8 @@ import '@fontsource-variable/nunito-sans';
import './styles.css'; import './styles.css';
import { mountAccountMenu, type AccountMenu } from './account/account-menu'; import { mountAccountMenu, type AccountMenu } from './account/account-menu';
import { client } from './account/appwrite'; import { client } from './account/appwrite';
import { currentUserId, getCurrentUser, onAuthChange } from './account/auth'; import { currentUserId, getCurrentUser, onAuthChange, onLogout } from './account/auth';
import { loadMyUsername, markUsernamePrompted, usernamePrompted } from './account/profile'; import { clearUsernamePrompted, loadMyUsername, markUsernamePrompted, usernamePrompted } from './account/profile';
import { usernameDialog } from './account/username-dialog'; import { usernameDialog } from './account/username-dialog';
import { DONATE_URL } from './config'; import { DONATE_URL } from './config';
import { currentLang, onLangChange, setLang } from './i18n/i18n'; import { currentLang, onLangChange, setLang } from './i18n/i18n';
@@ -94,6 +94,11 @@ onAuthChange((user) => {
if (user) void checkUsername(user.$id); 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), // 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. // gilt der Nutzer als abgemeldet.
if (accountSlot) { if (accountSlot) {