import { test, expect, type Page } from '@playwright/test'; const userFixture = { id: 'u1', username: 'alice', created_at: '2026-01-01T00:00:00Z' }; const mangaFixture = { id: 'm1', 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' }; async function mockBaseUploadApis(page: Page) { await page.route('**/api/v1/auth/me', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: userFixture }) }) ); await page.route('**/api/v1/mangas?*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [mangaFixture], page: { limit: 200, offset: 0, total: 1 } }) }) ); } test('anonymous user sees sign-in prompt on /upload', async ({ page }) => { await page.route('**/api/v1/auth/me', (route) => route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }) ); await page.route('**/api/v1/mangas?*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) }) ); await page.goto('/upload'); await expect(page.getByTestId('upload-signin')).toBeVisible(); }); test('uploading a non-image page surfaces the backend 415 message', async ({ page }) => { await mockBaseUploadApis(page); // Backend rejects with 415 unsupported_media_type — we want to see // the human message rendered as the chapter error. await page.route('**/api/v1/mangas/m1/chapters', (route) => route.fulfill({ status: 415, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unsupported_media_type', message: 'page[0]: unsupported image type application/pdf' } }) }) ); await page.goto('/upload'); await page.getByTestId('chapter-manga').selectOption('m1'); await page.getByTestId('chapter-number').fill('1'); // Client validator allows image/png; we lie about the file type so // the request actually reaches the (mocked) backend, exercising the // 415 envelope path. await page.getByTestId('chapter-pages-input').setInputFiles({ name: 'fake.png', mimeType: 'image/png', buffer: Buffer.from('%PDF-1.4', 'utf-8') }); await page.getByTestId('chapter-submit').click(); await expect(page.getByTestId('chapter-error')).toContainText( 'unsupported image type' ); }); test('happy path: create manga + upload chapter (mocked)', async ({ page }) => { await mockBaseUploadApis(page); let createdManga: typeof mangaFixture | null = null; let createdChapter: { id: string; number: number } | null = null; await page.route('**/api/v1/mangas', (route) => { if (route.request().method() === 'POST') { createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' }; route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(createdManga) }); } else { route.fallback(); } }); await page.route('**/api/v1/mangas/m1/chapters', (route) => { if (route.request().method() === 'POST') { createdChapter = { id: 'c1', number: 1 }; route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'c1', manga_id: 'm1', number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z' }) }); } else { route.fallback(); } }); await page.goto('/upload'); // Create manga. await page.getByTestId('manga-title').fill('Naruto'); await page.getByTestId('manga-submit').click(); await expect(page.getByTestId('manga-success')).toContainText('Created'); expect(createdManga).not.toBeNull(); // Upload chapter with two pages. await page.getByTestId('chapter-manga').selectOption('m1'); await page.getByTestId('chapter-number').fill('1'); await page.getByTestId('chapter-pages-input').setInputFiles([ { name: '1.png', mimeType: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }, { name: '2.png', mimeType: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) } ]); await expect(page.getByTestId('chapter-pages-list')).toContainText('1.png'); await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png'); await page.getByTestId('chapter-submit').click(); await expect(page.getByTestId('chapter-success')).toContainText( '2 pages' ); expect(createdChapter).not.toBeNull(); }); test('client preflight blocks oversized files without hitting the network', async ({ page }) => { await mockBaseUploadApis(page); let chapterPostCalls = 0; await page.route('**/api/v1/mangas/m1/chapters', (route) => { if (route.request().method() === 'POST') chapterPostCalls += 1; route.fallback(); }); await page.goto('/upload'); await page.getByTestId('chapter-manga').selectOption('m1'); await page.getByTestId('chapter-number').fill('1'); // A ~21 MiB buffer — exceeds the 20 MiB client cap. const big = Buffer.alloc(21 * 1024 * 1024, 0xff); await page.getByTestId('chapter-pages-input').setInputFiles({ name: 'huge.png', mimeType: 'image/png', buffer: big }); await expect(page.getByTestId('chapter-pages-list')).toContainText('too large'); await expect(page.getByTestId('chapter-submit')).toBeDisabled(); expect(chapterPostCalls).toBe(0); });