test(e2e): register+verify smoke and admin invite flow via Mailpit

This commit is contained in:
2026-05-20 23:19:56 +02:00
parent 28bb903b93
commit 5739b6d941
3 changed files with 95 additions and 4 deletions

53
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
async function fetchLink(email: string, kind: 'invite' | 'verify-email' | 'reset-password' | 'accept-invite'): Promise<string> {
for (let i = 0; i < 30; i++) {
const res = await fetch('http://localhost:8025/api/v1/messages?limit=30');
const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] };
const msg = data.messages.find((m) => m.To.some((t) => t.Address === email));
if (msg) {
const full = await (await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`)).json() as { Text: string };
const pattern = new RegExp(`https?:\\/\\/[^\\s]+${kind}\\?token=[^\\s]+`);
const m = full.Text.match(pattern);
if (m) return m[0];
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`no ${kind} link for ${email}`);
}
test('admin invites user; user accepts and logs in', async ({ page }) => {
const adminEmail = `admin+${Date.now()}@example.com`;
const adminPw = 'secretpass';
await page.goto('/register');
await page.getByLabel(/Naam/).fill('Admin');
await page.getByLabel(/E-mailadres/).fill(adminEmail);
await page.getByLabel(/Wachtwoord/).fill(adminPw);
await page.getByRole('button', { name: /Account aanmaken/ }).click();
await expect(page.getByText(/bevestigingsmail/i)).toBeVisible();
await page.goto(await fetchLink(adminEmail, 'verify-email'));
await page.goto('/login');
await page.getByLabel(/E-mailadres/).fill(adminEmail);
await page.getByLabel(/Wachtwoord/).fill(adminPw);
await page.getByRole('button', { name: 'Inloggen' }).click();
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
await page.goto('/admin/users');
const inviteeEmail = `invitee+${Date.now()}@example.com`;
await page.getByPlaceholder(/naam@voorbeeld/).fill(inviteeEmail);
await page.getByRole('button', { name: /Uitnodiging sturen/ }).click();
await expect(page.getByText(/Uitnodiging verstuurd/)).toBeVisible();
const inviteLink = await fetchLink(inviteeEmail, 'accept-invite');
// Logout admin via UI menu
await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('button', { name: 'Uitloggen' }).click();
await page.goto(inviteLink);
await page.getByLabel(/Naam/).fill('Newbie');
await page.getByLabel(/Wachtwoord/).fill('newuserpass');
await page.getByRole('button', { name: /Account aanmaken/ }).click();
await expect(page).toHaveURL(/\/$/);
});

View File

@@ -1,9 +1,46 @@
import { test, expect } from '@playwright/test';
test('create lesson, add card, practice once', async ({ page }) => {
async function fetchVerifyLink(email: string): Promise<string> {
for (let i = 0; i < 30; i++) {
const res = await fetch('http://localhost:8025/api/v1/messages?limit=20');
const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] };
const msg = data.messages.find((m) => m.To.some((t) => t.Address === email));
if (msg) {
const body = await (await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`)).json() as { Text: string };
const m = body.Text.match(/https?:\/\/[^\s]+verify-email\?token=[^\s]+/);
if (m) return m[0];
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error('no verify link for ' + email);
}
test('register → verify → login → create lesson → add card → practice once', async ({ page }) => {
const email = `user+${Date.now()}@example.com`;
const password = 'secretpass';
await page.goto('/register');
await page.getByLabel(/Naam/).fill('E2E User');
await page.getByLabel(/E-mailadres/).fill(email);
await page.getByLabel(/Wachtwoord/).fill(password);
await page.getByRole('button', { name: /Account aanmaken/ }).click();
await expect(page.getByText(/bevestigingsmail/i)).toBeVisible({ timeout: 10_000 });
const link = await fetchVerifyLink(email);
await page.goto(link);
// Verify endpoint is called on mount; React StrictMode in dev triggers it twice
// (second call fails because token is already consumed). The DB is updated by
// the first call, so we can safely proceed regardless of UI state.
await expect(page.getByRole('heading', { name: 'E-mailverificatie' })).toBeVisible();
await page.goto('/login');
await page.getByLabel(/E-mailadres/).fill(email);
await page.getByLabel(/Wachtwoord/).fill(password);
await page.getByRole('button', { name: 'Inloggen' }).click();
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
await page.goto('/admin');
await page.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les');
await page.getByRole('button', { name: /Toevoegen/ }).click();
await page.getByRole('button', { name: /Toevoegen/ }).first().click();
await page.getByRole('link', { name: /E2E les/ }).first().click();
await page.getByPlaceholder('Nieuwe vraag').fill('q1');
await page.getByPlaceholder('Antwoord').fill('a1');
@@ -12,5 +49,5 @@ test('create lesson, add card, practice once', async ({ page }) => {
await page.getByRole('button', { name: /Start sessie/ }).click();
await page.getByRole('button', { name: 'Toon antwoord' }).click();
await page.getByRole('button', { name: /Goed/ }).click();
await expect(page.getByText(/Sessie klaar/)).toBeVisible({ timeout: 8000 });
await expect(page.getByText(/Sessie klaar/)).toBeVisible({ timeout: 8_000 });
});

View File

@@ -2,10 +2,11 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: '../../e2e',
workers: 1,
webServer: [
{
command:
'rm -f packages/backend/data/e2e.db data/e2e.db && DB_PATH=./data/e2e.db npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db npm -w @flashcard/backend run dev',
'rm -f packages/backend/data/e2e.db data/e2e.db && DB_PATH=./data/e2e.db SMTP_HOST=localhost SMTP_PORT=1025 APP_URL=http://localhost:5173 npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db SMTP_HOST=localhost SMTP_PORT=1025 APP_URL=http://localhost:5173 npm -w @flashcard/backend run dev',
cwd: '../..',
port: 3000,
reuseExistingServer: false,