feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage
- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor` gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil, Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der Merker `aircard.usernamePrompted`. `IDS.profiles`. - Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`. - Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto); danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und der Namensdialog fragt. - Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername ändern“ bzw. „Benutzername wählen“. - main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist). - Texte in `en.ts`, Stile in `account.css`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
19c8151c7e
commit
3e4bc21f00
14 files changed
+2660
-40
No files matched your search
+232
-6
@@ -1,12 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type TestUser = { $id: string; email: string };
|
||||
|
||||
// Der Einstieg läuft mit echtem Router, echter Kopfzeile, Banner, Fußzeile und Konto-Menü; Seiten, Anmeldung und Appwrite sind Attrappen.
|
||||
// Vom Benutzernamen läuft das Echte (Merker in sessionStorage); ersetzt sind nur das Lesen des Profils und der Dialog.
|
||||
const mocks = vi.hoisted(() => {
|
||||
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
|
||||
return {
|
||||
setLocale: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
authListeners: new Set<(user: null) => void>(),
|
||||
/** Der angemeldete Nutzer, wie `currentUser`/`currentUserId` ihn nennen. */
|
||||
user: null as TestUser | null,
|
||||
authListeners: new Set<(user: TestUser | null) => void>(),
|
||||
loadMyUsername: vi.fn(),
|
||||
usernameDialog: vi.fn(),
|
||||
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
|
||||
};
|
||||
});
|
||||
@@ -14,14 +21,16 @@ const mocks = vi.hoisted(() => {
|
||||
vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
|
||||
vi.mock('./account/auth', () => ({
|
||||
getCurrentUser: mocks.getCurrentUser,
|
||||
currentUser: () => null,
|
||||
currentUser: () => mocks.user,
|
||||
currentUserId: () => mocks.user?.$id ?? null,
|
||||
logout: vi.fn(),
|
||||
onAuthChange: (fn: (user: null) => void) => {
|
||||
onAuthChange: (fn: (user: TestUser | null) => void) => {
|
||||
mocks.authListeners.add(fn);
|
||||
return () => mocks.authListeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
vi.mock('./account/login-dialog', () => ({ requireLogin: vi.fn() }));
|
||||
vi.mock('./account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
|
||||
vi.mock('./editor/auth-reset', () => ({ bindEditorToAuth: vi.fn() }));
|
||||
vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor }));
|
||||
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards }));
|
||||
@@ -32,6 +41,9 @@ vi.mock('./pages/home-page', () => ({ mountHomePage: mocks.pages.home }));
|
||||
const DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
|
||||
const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app';
|
||||
|
||||
/** Abonnenten von onAuthChange nach dem Start: das Konto-Menü und die Abfrage des Benutzernamens (main.ts); ein Sprachwechsel ändert die Zahl nicht. */
|
||||
const AUTH_LISTENERS = 2;
|
||||
|
||||
const slot = () => document.getElementById('account-slot') as HTMLElement;
|
||||
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
|
||||
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
|
||||
@@ -41,6 +53,12 @@ const signIn = (label = 'Anmelden') => [...slot().querySelectorAll('button')].fi
|
||||
/** Startet main.ts frisch (es läuft beim Import) auf der Adresse `path`; `storedLang` ist die gespeicherte Sprachwahl (null: keine). */
|
||||
async function start(path = '/', storedLang: string | null = 'de') {
|
||||
vi.resetModules();
|
||||
// Das Profil: nur das Lesen im Netz ist ersetzt. Neu nach jedem Zurücksetzen, damit jede Kopie von main.ts ihren eigenen Merker
|
||||
// „schon gefragt“ im Speicher hat (ein `vi.mock` bliebe bei der ersten Kopie von profile.ts stehen).
|
||||
vi.doMock('./account/profile', async () => ({
|
||||
...(await vi.importActual<typeof import('./account/profile')>('./account/profile')),
|
||||
loadMyUsername: mocks.loadMyUsername,
|
||||
}));
|
||||
localStorage.clear();
|
||||
if (storedLang) localStorage.setItem('aircard.lang', storedLang);
|
||||
history.replaceState(null, '', path);
|
||||
@@ -55,7 +73,11 @@ async function start(path = '/', storedLang: string | null = 'de') {
|
||||
beforeEach(() => {
|
||||
mocks.setLocale.mockReset();
|
||||
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
|
||||
mocks.user = null;
|
||||
mocks.authListeners.clear();
|
||||
mocks.loadMyUsername.mockReset().mockResolvedValue(null); // das Konto hat keinen Namen
|
||||
mocks.usernameDialog.mockReset().mockResolvedValue(null); // „Später“
|
||||
sessionStorage.clear();
|
||||
for (const page of Object.values(mocks.pages)) page.mockReset();
|
||||
});
|
||||
|
||||
@@ -170,12 +192,12 @@ describe('Sprachwechsel', () => {
|
||||
it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||
expect(mocks.authListeners.size).toBe(1);
|
||||
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
|
||||
|
||||
langButton().click();
|
||||
|
||||
expect(signIn('Sign in')).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung, jetzt auf Englisch
|
||||
expect(mocks.authListeners.size).toBe(1); // das alte Menü hat sich abgemeldet
|
||||
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS); // das alte Menü hat sich abgemeldet
|
||||
expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
|
||||
});
|
||||
|
||||
@@ -187,7 +209,7 @@ describe('Sprachwechsel', () => {
|
||||
|
||||
langButton().click();
|
||||
expect(slot().childElementCount).toBe(0);
|
||||
expect(mocks.authListeners.size).toBe(1);
|
||||
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
|
||||
|
||||
answer(null);
|
||||
await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined());
|
||||
@@ -214,3 +236,207 @@ describe('Sprachwechsel', () => {
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Benutzername abfragen (Spec § 5)', () => {
|
||||
const kenso: TestUser = { $id: 'u1', email: 'kenso@example.com' };
|
||||
const PROMPTED_KEY = 'aircard.usernamePrompted';
|
||||
|
||||
/** Das Konto ist schon beim Start angemeldet (Sitzung im Browser). */
|
||||
const sessionAtStart = (user: TestUser = kenso) => {
|
||||
mocks.user = user;
|
||||
mocks.getCurrentUser.mockResolvedValue(user);
|
||||
};
|
||||
/** Der Nutzer meldet sich an: erst steht er fest, dann hören es alle Abonnenten (wie auth.ts). */
|
||||
const userSignsIn = (user: TestUser = kenso) => {
|
||||
mocks.user = user;
|
||||
for (const fn of [...mocks.authListeners]) fn(user);
|
||||
};
|
||||
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 () => {
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('der Merker steht schon, wenn der Dialog erscheint (ein zweiter Auslöser, solange er offen ist, fragt nicht noch einmal)', async () => {
|
||||
let flagWhenShown: string | null | undefined;
|
||||
let close!: () => void;
|
||||
mocks.usernameDialog.mockImplementation(() => {
|
||||
flagWhenShown = sessionStorage.getItem(PROMPTED_KEY);
|
||||
return new Promise<null>((resolve) => (close = () => resolve(null)));
|
||||
});
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
expect(flagWhenShown).toBe('1');
|
||||
userSignsIn(); // während der Dialog offen ist
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
close();
|
||||
await settle();
|
||||
userSignsIn(); // auch nach „Später“
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start mit Sitzung, Konto mit Namen: kein Dialog, der Merker bleibt leer', async () => {
|
||||
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalled());
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('Gast: nichts wird gelesen und nichts gefragt', async () => {
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||
await settle();
|
||||
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Anmeldung nach dem Start, Konto ohne Namen: „prompt“ genau einmal; eine weitere Anmeldung in derselben Sitzung fragt nicht noch einmal', async () => {
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||
userSignsIn();
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
|
||||
userSignsIn({ $id: 'u1', email: 'kenso@example.com' }); // z. B. ein Labelwechsel des Kontos
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Anmeldung, Konto mit Namen: kein Dialog', async () => {
|
||||
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||
await start('/');
|
||||
userSignsIn();
|
||||
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Abmelden löst nichts aus', async () => {
|
||||
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||
mocks.user = null;
|
||||
for (const fn of [...mocks.authListeners]) fn(null);
|
||||
await settle();
|
||||
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Start und Anmeldemeldung für dasselbe Konto zugleich (wie im echten Start): ein Lesen, ein Dialog', async () => {
|
||||
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(null), 5)));
|
||||
mocks.getCurrentUser.mockImplementation(async () => {
|
||||
userSignsIn(); // getCurrentUser meldet den neuen Nutzer an alle Abonnenten, bevor es ihn zurückgibt
|
||||
return kenso;
|
||||
});
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
await settle();
|
||||
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Neuladen in derselben Sitzung: der Merker aus sessionStorage hält die Abfrage zurück, der Name wird trotzdem gelesen', async () => {
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
|
||||
mocks.authListeners.clear(); // die erste Seite ist weg
|
||||
mocks.loadMyUsername.mockClear();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('eine neue Sitzung (anderer Tab) fragt wieder', async () => {
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
|
||||
sessionStorage.clear(); // neuer Tab = neue sessionStorage
|
||||
mocks.authListeners.clear();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('der Name lässt sich nicht lesen: kein Dialog (das Konto könnte einen haben), kein Merker, kein Absturz', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||
expect(slot().childElementCount).toBeGreaterThan(0); // die Seite steht
|
||||
|
||||
// Beim nächsten Versuch (neue Anmeldemeldung) wird gefragt, wenn der Name lesbar ist.
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
userSignsIn();
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('scheitert der Dialog beim Öffnen, bleibt es bei einer Meldung in der Konsole', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(slot().childElementCount).toBeGreaterThan(0);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('wird der Nutzer abgemeldet, während sein Name gelesen wird, erscheint kein Dialog', async () => {
|
||||
let answer!: (name: string | null) => void;
|
||||
mocks.loadMyUsername.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
await start('/');
|
||||
userSignsIn();
|
||||
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||
mocks.user = null; // abgemeldet, bevor die Antwort da ist
|
||||
answer(null);
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('wechselt das Konto, während gelesen wird, wird das andere Konto gefragt, nicht das alte', async () => {
|
||||
const mia: TestUser = { $id: 'u2', email: 'mia@example.com' };
|
||||
const answers: Array<(name: string | null) => void> = [];
|
||||
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
|
||||
await start('/');
|
||||
userSignsIn(kenso);
|
||||
await vi.waitFor(() => expect(answers).toHaveLength(1));
|
||||
userSignsIn(mia); // Kontowechsel, bevor die erste Antwort da ist
|
||||
await vi.waitFor(() => expect(answers).toHaveLength(2));
|
||||
answers[0](null); // die Antwort für das alte Konto: ohne Wirkung
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
answers[1](null);
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('ist sessionStorage gesperrt, wirft nichts, und gefragt wird einmal pro Seitenaufruf', 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));
|
||||
userSignsIn();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user