feat(account): Login, Registrierung, Passwort zurücksetzen
- 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>
This commit is contained in:
1 parent
d1d4d621a7
commit
10c55ca933
15 files changed
+1866
-10
No files matched your search
@@ -0,0 +1,234 @@
|
||||
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('./appwrite', () => ({ account: mocks }));
|
||||
|
||||
type Auth = typeof import('./auth');
|
||||
|
||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||
|
||||
// Der Nutzer-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
|
||||
async function freshAuth(): Promise<Auth> {
|
||||
vi.resetModules();
|
||||
return import('./auth');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('liefert den Nutzer und merkt sich die ID', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
|
||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
|
||||
it('401 heißt: nicht angemeldet', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.getCurrentUser();
|
||||
mocks.get.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope'));
|
||||
await expect(auth.getCurrentUser()).resolves.toBeNull();
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it('currentUserId ist vor dem ersten Aufruf null', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it('eine Antwort ohne $id/email (z. B. HTML) ist ein Fehler und ändert den Cache nicht', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.getCurrentUser();
|
||||
mocks.get.mockResolvedValueOnce({ message: '<!doctype html>' });
|
||||
await expect(auth.getCurrentUser()).rejects.toThrow('Unerwartete Antwort');
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
|
||||
it('andere Fehler werden weitergereicht', async () => {
|
||||
const auth = await freshAuth();
|
||||
const error = new TypeError('Failed to fetch');
|
||||
mocks.get.mockRejectedValue(error);
|
||||
await expect(auth.getCurrentUser()).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('benachrichtigt nur, wenn sich der Nutzer ändert', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
});
|
||||
|
||||
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
const error = fail(401, 'user_invalid_credentials');
|
||||
mocks.createEmailPasswordSession.mockRejectedValue(error);
|
||||
await expect(auth.login('a@b.de', 'falsch123')).rejects.toBe(error);
|
||||
expect(mocks.get).not.toHaveBeenCalled();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('legt das Konto an (Name aus der E-Mail) und meldet danach an', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
|
||||
const params = mocks.create.mock.calls[0][0];
|
||||
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
|
||||
expect(typeof params.userId).toBe('string');
|
||||
expect(params.userId.length).toBeGreaterThan(0);
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'neu@b.de', password: 'geheim123' });
|
||||
expect(auth.currentUserId()).toBe('u2');
|
||||
});
|
||||
|
||||
it('kürzt den Namen auf 128 Zeichen', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'x@b.de' });
|
||||
await auth.register(`${'a'.repeat(200)}@b.de`, 'geheim123');
|
||||
expect(mocks.create.mock.calls[0][0].name).toBe('a'.repeat(128));
|
||||
});
|
||||
|
||||
it('meldet nicht an, wenn das Konto nicht angelegt werden konnte', async () => {
|
||||
const auth = await freshAuth();
|
||||
const error = fail(409, 'user_already_exists');
|
||||
mocks.create.mockRejectedValue(error);
|
||||
await expect(auth.register('a@b.de', 'geheim123')).rejects.toBe(error);
|
||||
expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('löscht die aktuelle Sitzung, leert den Cache und benachrichtigt', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.login('a@b.de', 'geheim123');
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
await auth.logout();
|
||||
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
expect(listener).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('401 (Sitzung war schon weg) löst auf und ruft onAuthChange(null)', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
await expect(auth.logout()).resolves.toBeUndefined();
|
||||
expect(listener).toHaveBeenCalledWith(null);
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it('andere Fehler (z. B. Netz) fliegen weiter, der Cache bleibt', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.login('a@b.de', 'geheim123');
|
||||
mocks.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await expect(auth.logout()).rejects.toBeInstanceOf(TypeError);
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAuthChange', () => {
|
||||
it('gibt eine Abmelde-Funktion zurück', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
const off = auth.onAuthChange(listener);
|
||||
off();
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
await auth.logout();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein Abonnent darf sich beim Benachrichtigen abmelden', async () => {
|
||||
const auth = await freshAuth();
|
||||
const second = vi.fn();
|
||||
const off = auth.onAuthChange(() => off());
|
||||
auth.onAuthChange(second);
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
await auth.logout();
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Passwort zurücksetzen', () => {
|
||||
it('requestPasswordReset sendet die Adresse und die Rücksprung-URL', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createRecovery.mockResolvedValue({});
|
||||
await auth.requestPasswordReset('a@b.de');
|
||||
expect(mocks.createRecovery).toHaveBeenCalledWith({ email: 'a@b.de', url: `${location.origin}/passwort-neu` });
|
||||
});
|
||||
|
||||
it('completePasswordReset sendet userId, secret und neues Passwort', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.updateRecovery.mockResolvedValue({});
|
||||
await auth.completePasswordReset('u1', 'geheim-token', 'neuesPasswort1');
|
||||
expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 'geheim-token', password: 'neuesPasswort1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('lehnt zu kurze Passwörter mit einer Meldung ab', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.validatePassword('kurz')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(auth.validatePassword('')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(auth.validatePassword('1234567')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('nimmt 8 bis 256 Zeichen an', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.validatePassword('12345678')).toBeNull();
|
||||
expect(auth.validatePassword('a'.repeat(256))).toBeNull();
|
||||
});
|
||||
|
||||
it('lehnt mehr als 256 Zeichen ab', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.validatePassword('a'.repeat(257))).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user