import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ get: vi.fn(), create: vi.fn(), createEmailPasswordSession: vi.fn(), deleteSession: vi.fn(), createRecovery: vi.fn(), updateRecovery: vi.fn(), })); vi.mock('../account/appwrite', () => ({ account: mocks })); const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type }); type Page = typeof import('./reset-page'); let mountResetPage: Page['mountResetPage']; let root: HTMLElement; beforeEach(async () => { for (const fn of Object.values(mocks)) fn.mockReset(); vi.resetModules(); document.body.innerHTML = '
'; root = document.getElementById('app-root')!; history.replaceState(null, '', '/passwort-neu'); ({ mountResetPage } = await import('./reset-page')); }); const withLink = () => history.replaceState(null, '', '/passwort-neu?userId=u1&secret=s3cret'); const input = (name: string) => root.querySelector(`input[name="${name}"]`)!; const alertText = () => root.querySelector('[role="alert"]')!.textContent; const submit = () => root.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); const fill = (password: string, repeat = password) => { input('password').value = password; input('password-repeat').value = repeat; }; const button = (text: string) => [...root.querySelectorAll('button')].find((b) => b.textContent === text)!; describe('ohne gültigen Link', () => { it('ohne Parameter: Hinweis und Knopf „Neuen Link anfordern“, der den Dialog öffnet', () => { mountResetPage(root); expect(root.querySelector('h1')!.textContent).toBe('Passwort neu setzen'); expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); expect(root.querySelector('form')).toBeNull(); button('Neuen Link anfordern').click(); const dialog = document.querySelector('dialog.auth-dialog')!; expect(dialog.open).toBe(true); expect(dialog.querySelector('h2')!.textContent).toBe('Passwort vergessen'); }); it('mit nur einem der beiden Parameter ebenso', () => { history.replaceState(null, '', '/passwort-neu?userId=u1'); mountResetPage(root); expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); }); }); describe('mit Link', () => { it('zeigt zwei Passwortfelder mit new-password', () => { withLink(); mountResetPage(root); expect(input('password').autocomplete).toBe('new-password'); expect(input('password-repeat').autocomplete).toBe('new-password'); }); it('prüft vor dem Senden', () => { withLink(); mountResetPage(root); fill('geheim123', 'geheim124'); submit(); expect(alertText()).toBe('Die Passwörter stimmen nicht überein.'); fill('kurz'); submit(); expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.'); expect(input('password').getAttribute('aria-invalid')).toBe('true'); expect(mocks.updateRecovery).not.toHaveBeenCalled(); }); it('setzt das Passwort, zeigt den Erfolg, entfernt das Geheimnis aus der Adresse und bietet „Anmelden“', async () => { withLink(); mocks.updateRecovery.mockResolvedValue({}); mountResetPage(root); fill('neuesPasswort1'); submit(); await vi.waitFor(() => expect(root.querySelector('[role="status"]')!.textContent).toBe('Passwort geändert. Du kannst dich jetzt anmelden.'), ); expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 's3cret', password: 'neuesPasswort1' }); expect(location.search).toBe(''); button('Anmelden').click(); const dialog = document.querySelector('dialog.auth-dialog')!; expect(dialog.open).toBe(true); expect(dialog.querySelector('h2')!.textContent).toBe('Anmelden'); }); it('sperrt das Formular, solange die Anfrage läuft', async () => { withLink(); let release!: () => void; mocks.updateRecovery.mockReturnValue(new Promise((resolve) => (release = resolve))); mountResetPage(root); fill('neuesPasswort1'); submit(); submit(); expect(mocks.updateRecovery).toHaveBeenCalledTimes(1); expect(root.querySelector('button[type="submit"]')!.disabled).toBe(true); release(); await vi.waitFor(() => expect(root.querySelector('[role="status"]')).not.toBeNull()); }); it.each([ ['user_invalid_token', 401, ''], ['user_not_found', 404, ''], ['general_argument_invalid', 400, 'Invalid `userId` param: Parameter must be a valid number'], ['general_argument_invalid', 400, 'Invalid `secret` param: Value must be a valid string'], ])('Link-Fehler %s (%s) zeigt „Link ungültig“ und den Knopf „Neuen Link anfordern“', async (type, code, message) => { withLink(); mocks.updateRecovery.mockRejectedValue(fail(code, type, message)); mountResetPage(root); fill('neuesPasswort1'); submit(); await vi.waitFor(() => expect(root.querySelector('form')).toBeNull()); expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); expect(button('Neuen Link anfordern')).toBeDefined(); }); it('ein Passwortfehler vom Server steht am Feld, das Formular bleibt', async () => { withLink(); mocks.updateRecovery.mockRejectedValue( fail( 400, 'general_argument_invalid', 'Invalid `password` param: Password must be at least 8 characters and should not be one of the commonly used password.', ), ); mountResetPage(root); fill('password123'); submit(); await vi.waitFor(() => expect(alertText()).toBe('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.')); expect(root.querySelector('form')).not.toBeNull(); expect(input('password').getAttribute('aria-invalid')).toBe('true'); expect(root.querySelector('button[type="submit"]')!.disabled).toBe(false); }); it('Netzfehler: Meldung, Formular bleibt', async () => { withLink(); mocks.updateRecovery.mockRejectedValue(new TypeError('Failed to fetch')); mountResetPage(root); fill('neuesPasswort1'); submit(); await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.')); expect(root.querySelector('form')).not.toBeNull(); }); });