- appwrite 22.4.1 (exakt gepinnt); Client mit Endpoint aus VITE_APPWRITE_ENDPOINT bzw. location.origin + /v1, Projekt aus VITE_APPWRITE_PROJECT_ID bzw. aircard - auth.ts: getCurrentUser (401 -> null, ungültige Antwort -> Fehler), login, register, logout, Passwort-Link anfordern/abschließen, onAuthChange, validatePassword - errors.ts: deutsche Meldungen nach type, dann code (Tabelle aus dem Plan) - Login-Dialog (Anmelden/Registrieren/Passwort vergessen) als gemeinsames <dialog>, Konto-Menü im Seitenkopf, Seite /passwort-neu - Tests: errors, auth, Dialog, Passwort-neu-Seite Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
6.2 KiB
TypeScript
154 lines
6.2 KiB
TypeScript
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 = '<main id="app-root"></main>';
|
|
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<HTMLInputElement>(`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<HTMLDialogElement>('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<HTMLDialogElement>('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<void>((resolve) => (release = resolve)));
|
|
mountResetPage(root);
|
|
fill('neuesPasswort1');
|
|
submit();
|
|
submit();
|
|
expect(mocks.updateRecovery).toHaveBeenCalledTimes(1);
|
|
expect(root.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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();
|
|
});
|
|
});
|