diff --git a/apps/mana/apps/web/e2e/events-public-rsvp.spec.ts b/apps/mana/apps/web/e2e/events-public-rsvp.spec.ts new file mode 100644 index 000000000..b57fdf223 --- /dev/null +++ b/apps/mana/apps/web/e2e/events-public-rsvp.spec.ts @@ -0,0 +1,126 @@ +/** + * Public RSVP page — end-to-end smoke test. + * + * Bypasses the host's JWT publish flow by seeding events.events_published + * directly in Postgres, then exercises the unauthenticated /rsvp/[token] + * page like a real guest would. + * + * Requires: Postgres + mana-events service running. Both are booted by + * the playwright.config.ts webServer block in local dev. + */ + +import { test, expect } from '@playwright/test'; +import postgres from 'postgres'; + +const DATABASE_URL = + process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform'; +const EVENTS_URL = process.env.PUBLIC_MANA_EVENTS_URL || 'http://localhost:3065'; + +const sql = postgres(DATABASE_URL, { max: 2 }); + +const TEST_TOKEN = 'E2E_PUBLIC_RSVP_TOKEN'; +const TEST_EVENT_ID = '00000000-0000-0000-0000-000000000e2e'; + +test.afterAll(async () => { + await sql`DELETE FROM events.events_published WHERE token = ${TEST_TOKEN}`; + await sql.end(); +}); + +test.describe('Public RSVP page', () => { + test.beforeEach(async () => { + // Wipe + reseed so each test starts clean + await sql`DELETE FROM events.events_published WHERE token = ${TEST_TOKEN}`; + const startAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const endAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString(); + await sql` + INSERT INTO events.events_published + (token, event_id, user_id, title, description, location, start_at, end_at, all_day, color) + VALUES + (${TEST_TOKEN}, ${TEST_EVENT_ID}, 'e2e-host', 'E2E Public Party', + 'Bring snacks!', 'Café am See', + ${startAt}::timestamptz, ${endAt}::timestamptz, false, '#f43f5e') + `; + }); + + test('renders the seeded event snapshot', async ({ page }) => { + await page.goto(`/rsvp/${TEST_TOKEN}`, { waitUntil: 'networkidle' }); + + await expect(page.getByRole('heading', { name: 'E2E Public Party' })).toBeVisible(); + await expect(page.getByText('Café am See')).toBeVisible(); + await expect(page.getByText('Bring snacks!')).toBeVisible(); + }); + + test('submits an RSVP and shows the success state', async ({ page }) => { + await page.goto(`/rsvp/${TEST_TOKEN}`, { waitUntil: 'networkidle' }); + + // Fill the form (labels rendered in EN because Playwright sends Accept-Language: en) + await page.getByRole('textbox', { name: /Your name|Dein Name/ }).fill('Tante Erika'); + await page + .getByRole('textbox', { name: /Email \(optional\)|E-Mail \(optional\)/ }) + .fill('erika@example.com'); + + // Status defaults to "yes" — explicit click and bring 2 plus-ones + await page.getByRole('button', { name: /Ja, komme|Yes, coming/i }).click(); + await page.locator('input[type="range"]').fill('2'); + + await page + .getByRole('textbox', { name: /Note \(optional\)|Notiz \(optional\)/ }) + .fill('Komme erst um 20 Uhr'); + await page.getByRole('button', { name: /Antwort senden|Send reply/i }).click(); + + // Success card should appear + await expect(page.getByRole('heading', { name: /Danke|Thanks/ })).toBeVisible({ + timeout: 5_000, + }); + + // Verify the row landed in Postgres + const rows = await sql< + { name: string; status: string; plus_ones: number; note: string | null }[] + >` + SELECT name, status, plus_ones, note + FROM events.public_rsvps + WHERE token = ${TEST_TOKEN} + `; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: 'Tante Erika', + status: 'yes', + plus_ones: 2, + note: 'Komme erst um 20 Uhr', + }); + }); + + test('upserts when the same person submits twice', async ({ page }) => { + const nameField = page.getByRole('textbox', { name: /Your name|Dein Name/ }); + + // First submission + await page.goto(`/rsvp/${TEST_TOKEN}`, { waitUntil: 'networkidle' }); + await nameField.fill('Onkel Klaus'); + await page.getByRole('button', { name: /Vielleicht|Maybe/i }).click(); + await page.getByRole('button', { name: /Antwort senden|Send reply/i }).click(); + await expect(page.getByRole('heading', { name: /Danke|Thanks/i })).toBeVisible(); + + // Click "Change answer" to flip back to the form. Svelte 5 keeps the + // script-scoped $state across the {#if} branches, so name is still + // "Onkel Klaus" — no need to refill. Just wait for the form input to + // be visible again before changing the status. + await page.getByRole('button', { name: /Antwort ändern|Change answer/i }).click(); + await expect(nameField).toBeVisible(); + await page.getByRole('button', { name: /✕ Nein|✕ No/i }).click(); + await page.getByRole('button', { name: /Antwort senden|Send reply/i }).click(); + await expect(page.getByRole('heading', { name: /Danke|Thanks/i })).toBeVisible(); + + // DB should still have exactly one row for Klaus, status now 'no' + const rows = await sql<{ status: string }[]>` + SELECT status FROM events.public_rsvps + WHERE token = ${TEST_TOKEN} AND name = 'Onkel Klaus' + `; + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe('no'); + }); + + test('returns 404 for an unknown token', async ({ page }) => { + const response = await page.goto('/rsvp/THIS_TOKEN_DOES_NOT_EXIST'); + expect(response?.status()).toBe(404); + }); +}); diff --git a/apps/mana/apps/web/e2e/events.spec.ts b/apps/mana/apps/web/e2e/events.spec.ts new file mode 100644 index 000000000..ec4feff3e --- /dev/null +++ b/apps/mana/apps/web/e2e/events.spec.ts @@ -0,0 +1,127 @@ +/** + * Events module — end-to-end smoke test. + * + * Covers the local-first happy path (guest mode, no login): + * 1. Open /events on a fresh IndexedDB + * 2. Create an event via the inline form + * 3. Open the detail view + * 4. Add a guest, change RSVP status, see the summary update + * 5. Delete the event + * + * The "publish + public RSVP" flow is exercised separately in + * events-public-rsvp.spec.ts so we don't need a real auth dance here. + */ + +import { test, expect, type Page } from '@playwright/test'; + +/** + * The unified Mana app shows a guest-welcome modal on first load that + * intercepts every click. Always dismiss it before doing anything else. + */ +async function dismissWelcomeModal(page: Page) { + const dialog = page.locator('[role="dialog"][aria-labelledby="welcome-title"]'); + // Wait up to 10s for the modal to appear (it's mounted after AuthGate finishes) + try { + await dialog.waitFor({ state: 'visible', timeout: 10_000 }); + } catch { + // No modal — already dismissed in a previous test or guest-mode disabled + return; + } + await dialog.getByRole('button', { name: /Weiter als Gast|Continue as Guest/i }).click(); + await dialog.waitFor({ state: 'hidden' }); +} + +// Each test gets its own browser context so IndexedDB starts empty. +test.describe('Events module — local flow', () => { + test('create, edit guest list, delete an event in guest mode', async ({ page }) => { + // 1. Land on /events + await page.goto('/events', { waitUntil: 'networkidle' }); + + // AuthGate may show a guest-welcome modal — dismiss it if present. + await dismissWelcomeModal(page); + + // Heading should appear + await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); + + // 2. Create event via the inline form + await page.getByRole('button', { name: '+ Neues Event' }).click(); + + const title = `E2E Test Party ${Date.now()}`; + await page.getByPlaceholder("Worum geht's?", { exact: false }).fill(title); + + // Date input — use a fixed future date + const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + const dateValue = future.toISOString().slice(0, 10); + await page.locator('input[type="date"]').fill(dateValue); + + // Time input is pre-filled to 19:00, leave it + await page.getByPlaceholder('Ort (optional)').fill('Café am See'); + await page.getByRole('button', { name: 'Event anlegen' }).click(); + + // After create, ListView callback navigates to /events/{id} + await page.waitForURL(/\/events\/[a-f0-9-]+$/); + + // 3. Detail view should show the title + await expect(page.getByRole('heading', { name: title })).toBeVisible(); + await expect(page.getByText('Café am See')).toBeVisible(); + + // 4. Add a guest + await page.getByPlaceholder('Name', { exact: false }).first().fill('Tante Erika'); + await page + .getByPlaceholder(/E-Mail/i) + .first() + .fill('erika@example.com'); + await page.getByRole('button', { name: 'Hinzufügen' }).click(); + + // Guest row should appear + await expect(page.getByText('Tante Erika')).toBeVisible(); + await expect(page.getByText('erika@example.com')).toBeVisible(); + + // Set Erika to "Ja" + const rsvpSelect = page.locator('select.rsvp-select').first(); + await rsvpSelect.selectOption('yes'); + + // Summary should reflect 1 yes / 1 attending (no plus-ones) + await expect(page.locator('.rsvp-summary .badge.yes .count')).toHaveText('1'); + + // Set 2 plus-ones + await page.locator('input[type="number"]').first().fill('2'); + // Blur to commit (onchange) + await page.locator('input[type="number"]').first().blur(); + + // totalAttending should now be 3 (1 + 2 plus-ones) + await expect(page.locator('.rsvp-summary .total strong')).toHaveText('3'); + + // 5. Delete the event — confirm dialog + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Löschen' }).click(); + + // We should land back on the events list + await page.waitForURL(/\/events\/?$/); + await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); + + // The deleted event should no longer appear + await expect(page.getByText(title)).not.toBeVisible(); + }); + + test('quick-input adapter creates an event from the title bar', async ({ page }) => { + await page.goto('/events', { waitUntil: 'networkidle' }); + + await dismissWelcomeModal(page); + + // Sanity: page loaded + await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); + + // Create via the visible "+ Neues Event" form (we don't depend on the + // global QuickInputBar here — it lives outside the events route). + await page.getByRole('button', { name: '+ Neues Event' }).click(); + const title = `Quick ${Date.now()}`; + await page.getByPlaceholder("Worum geht's?", { exact: false }).fill(title); + const future = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + await page.locator('input[type="date"]').fill(future.toISOString().slice(0, 10)); + await page.getByRole('button', { name: 'Event anlegen' }).click(); + + await page.waitForURL(/\/events\/[a-f0-9-]+$/); + await expect(page.getByRole('heading', { name: title })).toBeVisible(); + }); +}); diff --git a/apps/mana/apps/web/playwright.config.ts b/apps/mana/apps/web/playwright.config.ts new file mode 100644 index 000000000..f02beb3c9 --- /dev/null +++ b/apps/mana/apps/web/playwright.config.ts @@ -0,0 +1,75 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for the unified Mana web app. + * + * Tests live in ./e2e and exercise the Svelte 5 routes against a local + * dev server. Postgres + Redis + MinIO must be running (`pnpm docker:up` + * from the monorepo root). The webServer block boots both the SvelteKit + * dev server and the mana-events backend so RSVP flows can be exercised + * end to end. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, // Tests share IndexedDB / Postgres state + forbidOnly: !!process.env.CI, + // Local: 1 retry to absorb cold-start HMR flakes (Vite recompiling + // during the first navigation). CI uses 2 retries. + retries: process.env.CI ? 2 : 1, + workers: 1, + // Default per-test timeout 30s — bumped to 60s so a Vite cold compile + // during the first test of a fresh dev server doesn't time out. + timeout: 60_000, + expect: { + // Default 5s — bumped so locator polls survive a single HMR pause. + timeout: 10_000, + }, + reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + + use: { + baseURL: process.env.BASE_URL || 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + // Wait until Vite has finished any in-flight HMR before navigating. + navigationTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: process.env.CI + ? undefined + : [ + { + command: 'cd ../../../../services/mana-auth && bun run src/index.ts', + url: 'http://localhost:3001/health', + reuseExistingServer: true, + timeout: 60_000, + }, + { + command: 'pnpm dev', + url: 'http://localhost:5173', + reuseExistingServer: true, + timeout: 120_000, + }, + { + command: 'cd ../../../../services/mana-events && bun run src/index.ts', + url: 'http://localhost:3065/health', + reuseExistingServer: true, + timeout: 60_000, + env: { + PORT: '3065', + DATABASE_URL: + process.env.DATABASE_URL || + 'postgresql://mana:devpassword@localhost:5432/mana_platform', + MANA_AUTH_URL: 'http://localhost:3001', + CORS_ORIGINS: 'http://localhost:5173', + }, + }, + ], +});