import { test, expect, type Page } from '@playwright/test'; // These E2E tests run against the SvelteKit dev server, which proxies /api // to the backend. Playwright starts vite via `webServer` (see // playwright.config.ts) unless E2E_BASE_URL points at a different deployment. // // Routes are mocked at the page level so the journeys are deterministic and // don't require a live backend. const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; async function mockAnonymous(page: Page) { // Force public mode so the root +layout.ts doesn't bounce us to /login // (a dev backend with PRIVATE_MODE=true must not leak into E2E runs). await page.route('**/api/v1/auth/config', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) }); }); await page.route('**/api/v1/auth/me', async (route) => { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }); }); } test('home page renders the Mangalord heading and search input', async ({ page }) => { await mockAnonymous(page); await page.route('**/api/v1/mangas*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptyPage) }); }); await page.goto('/'); await expect(page.getByRole('link', { name: 'Mangalord' })).toBeVisible(); await expect(page.getByTestId('search-input')).toBeVisible(); await expect(page.getByTestId('empty')).toContainText('No mangas yet'); }); test('search updates the manga list', async ({ page }) => { await mockAnonymous(page); let lastSearch: string | null = null; await page.route('**/api/v1/mangas*', async (route) => { const url = new URL(route.request().url()); lastSearch = url.searchParams.get('search'); const items = lastSearch === 'berserk' ? [ { id: 'b1', title: 'Berserk', author: 'Kentaro Miura', description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' } ] : []; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items, page: { limit: 50, offset: 0, total: null } }) }); }); await page.goto('/'); await page.getByTestId('search-input').fill('berserk'); await page.getByRole('button', { name: 'Search' }).click(); await expect(page.getByTestId('manga-list')).toContainText('Berserk'); expect(lastSearch).toBe('berserk'); }); test('clicking Next paginates to page 2 and updates the URL', async ({ page }) => { await mockAnonymous(page); // Fake a catalogue of 75 mangas; page 1 is ids 1..50, page 2 is ids 51..75. const TOTAL = 75; function mangaAt(i: number) { return { id: `m${i}`, title: `Manga ${i}`, author: 'Test', description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [] }; } await page.route('**/api/v1/mangas*', async (route) => { const url = new URL(route.request().url()); const limit = Number(url.searchParams.get('limit') ?? '50'); const offset = Number(url.searchParams.get('offset') ?? '0'); const items: ReturnType[] = []; for (let i = offset + 1; i <= Math.min(offset + limit, TOTAL); i++) { items.push(mangaAt(i)); } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items, page: { limit, offset, total: TOTAL } }) }); }); await page.goto('/'); await expect(page.getByTestId('manga-total')).toContainText('Showing 1–50 of 75'); await expect(page.getByTestId('manga-list')).toContainText('Manga 1'); await expect(page.getByTestId('manga-list')).not.toContainText('Manga 75'); await page.getByTestId('manga-pager').getByRole('button', { name: /next/i }).click(); await expect(page).toHaveURL(/[?&]page=2(&|$)/); await expect(page.getByTestId('manga-total')).toContainText('Showing 51–75 of 75'); await expect(page.getByTestId('manga-list')).toContainText('Manga 75'); await expect(page.getByTestId('manga-list')).not.toContainText('Manga 1'); });