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(), /** 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() }, }; }); vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } })); vi.mock('./account/auth', () => ({ getCurrentUser: mocks.getCurrentUser, currentUser: () => mocks.user, currentUserId: () => mocks.user?.$id ?? null, logout: vi.fn(), onAuthChange: (fn: (user: TestUser | null) => void) => { 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 })); 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 })); vi.mock('./pages/community-page', () => ({ mountCommunityPage: mocks.pages.community })); vi.mock('./pages/reset-page', () => ({ mountResetPage: mocks.pages.reset })); 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('button.lang-btn')!; const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent); // Der Anmelden-Knopf des Konto-Menüs in der Sprache der Seite („Anmelden“ auf Deutsch, „Sign in“ nach dem Wechsel auf Englisch). const signIn = (label = 'Anmelden') => [...slot().querySelectorAll('button')].find((b) => b.textContent === label); /** 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('./account/profile')), loadMyUsername: mocks.loadMyUsername, })); localStorage.clear(); if (storedLang) localStorage.setItem('aircard.lang', storedLang); history.replaceState(null, '', path); document.body.innerHTML = '' + '
AirCard-IOS
' + '
' + '
'; await import('./main'); } beforeEach(() => { mocks.setLocale.mockReset(); 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(); for (const page of Object.values(mocks.pages)) page.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); history.replaceState(null, '', '/'); }); describe('Start', () => { it('Rahmen in der gespeicherten Sprache; der Appwrite-Client bekommt sie; die Seite der Adresse wird aufgebaut', async () => { await start('/gestalten'); await vi.waitFor(() => expect(signIn()).toBeDefined()); expect(document.documentElement.lang).toBe('de'); expect(mocks.setLocale).toHaveBeenCalledExactlyOnceWith('de'); expect(navTexts()).toEqual(['Community', 'Gestalten', 'Meine Karten']); expect(document.querySelector('.donate-banner-link')?.textContent).toBe('Unterstützen →'); expect(document.getElementById('app-footer')?.textContent).toContain(DE_FOOTER); expect(langButton().textContent).toBe('EN'); expect(document.title).toBe('Gestalten – AirCard-IOS'); expect(mocks.pages.editor).toHaveBeenCalledTimes(1); }); it('ohne gespeicherte Wahl entscheidet die Browsersprache; der Start speichert nichts', async () => { vi.stubGlobal('navigator', { languages: ['en-US'], language: 'en-US' }); await start('/karten', null); expect(document.documentElement.lang).toBe('en'); expect(mocks.setLocale).toHaveBeenCalledExactlyOnceWith('en'); expect(navTexts()).toEqual(['Community', 'Create', 'My cards']); expect(document.getElementById('app-footer')?.textContent).toContain(EN_FOOTER); expect(document.title).toBe('My cards – AirCard-IOS'); expect(langButton().textContent).toBe('DE'); expect(localStorage.getItem('aircard.lang')).toBeNull(); }); }); describe('Sprachwechsel', () => { it('Kopfzeile, Banner, Fußzeile und Titel wechseln ohne Neuladen; der Client bekommt die Sprache', async () => { await start('/gestalten'); const root = document.getElementById('app-root'); langButton().click(); expect(document.documentElement.lang).toBe('en'); expect(localStorage.getItem('aircard.lang')).toBe('en'); expect(mocks.setLocale).toHaveBeenLastCalledWith('en'); expect(navTexts()).toEqual(['Community', 'Create', 'My cards']); expect(document.querySelector('.donate-banner-link')?.textContent).toBe('Support us →'); expect(document.getElementById('app-footer')?.textContent).toContain(EN_FOOTER); expect(langButton().textContent).toBe('DE'); expect(document.title).toBe('Create – AirCard-IOS'); expect(document.getElementById('app-root')).toBe(root); // dasselbe Dokument, die Seite wurde nicht neu geladen expect(location.pathname).toBe('/gestalten'); // die Adresse bleibt deutsch und unverändert langButton().click(); // und zurück expect(document.documentElement.lang).toBe('de'); expect(mocks.setLocale).toHaveBeenLastCalledWith('de'); expect(navTexts()).toEqual(['Community', 'Gestalten', 'Meine Karten']); expect(document.getElementById('app-footer')?.textContent).toContain(DE_FOOTER); }); it('die aktuelle Seite wird aufgeräumt und im selben Container neu gebaut; der Editor-Store bleibt dasselbe Objekt', async () => { const cleanup = vi.fn(); mocks.pages.editor.mockImplementation(() => cleanup); await start('/gestalten'); const [root, store] = mocks.pages.editor.mock.calls[0] as [HTMLElement, { get(): { cardName: string }; set(patch: object): void }]; store.set({ cardName: 'Mein Entwurf' }); // ein ungespeicherter Entwurf langButton().click(); expect(cleanup).toHaveBeenCalledTimes(1); expect(mocks.pages.editor).toHaveBeenCalledTimes(2); const [rootAfter, storeAfter] = mocks.pages.editor.mock.calls[1] as [HTMLElement, typeof store]; expect(rootAfter).toBe(root); expect(storeAfter).toBe(store); expect(storeAfter.get().cardName).toBe('Mein Entwurf'); }); it('ein geschlossenes Spenden-Banner kommt beim Sprachwechsel nicht zurück, auch wenn der Speicher gesperrt ist', async () => { await start('/'); const denied = () => { throw new DOMException('blockiert', 'SecurityError'); }; vi.stubGlobal('localStorage', { getItem: denied, setItem: denied }); document.querySelector('.donate-banner-close')!.click(); expect(document.querySelector('.donate-banner')).toBeNull(); langButton().click(); expect(document.documentElement.lang).toBe('en'); expect(document.querySelector('.donate-banner')).toBeNull(); }); it('der unberührte Standardname der Karte wechselt mit der Sprache, schon bevor die Seite neu aufgebaut wird; ein eigener Name bleibt', async () => { const seen: string[] = []; mocks.pages.editor.mockImplementation((_root: unknown, store: unknown) => { seen.push((store as { get(): { cardName: string } }).get().cardName); return vi.fn(); }); await start('/gestalten'); const store = mocks.pages.editor.mock.calls[0][1] as { get(): { cardName: string }; set(patch: object): void }; expect(store.get().cardName).toBe('Meine Karte'); langButton().click(); expect(store.get().cardName).toBe('My card'); expect(seen).toEqual(['Meine Karte', 'My card']); // die neu gebaute Seite sieht den Namen der neuen Sprache store.set({ cardName: 'Mein Urlaub' }); langButton().click(); // zurück auf Deutsch expect(store.get().cardName).toBe('Mein Urlaub'); }); 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(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(AUTH_LISTENERS); // das alte Menü hat sich abgemeldet expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt }); it('kommt der Wechsel vor dem Anmeldestatus, bleibt das neue Konto-Menü leer, bis er da ist', async () => { let answer!: (user: null) => void; mocks.getCurrentUser.mockReturnValue(new Promise((resolve) => (answer = resolve))); await start('/'); expect(slot().childElementCount).toBe(0); langButton().click(); expect(slot().childElementCount).toBe(0); expect(mocks.authListeners.size).toBe(AUTH_LISTENERS); answer(null); await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined()); }); it('der Fokus bleibt auf dem Sprachknopf, auch wenn die Kopfzeile neu gebaut wird', async () => { await start('/'); const old = langButton(); old.focus(); expect(document.activeElement).toBe(old); old.click(); expect(langButton()).not.toBe(old); expect(document.activeElement).toBe(langButton()); }); it('ohne Fokus auf dem Sprachknopf wird kein Fokus gesetzt', async () => { await start('/'); (document.activeElement as HTMLElement | null)?.blur(); langButton().click(); 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); }; /** 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 () => { 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((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('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('/'); 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.logoutListeners.clear(); 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(); mocks.logoutListeners.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); }); });