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', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [{ id: 'a1', name: 'Kentaro Miura' }], genres: [], tags: [] }; async function stubAuthenticatedAndGenres(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/genres', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 'g-action', name: 'Action' }, { id: 'g-fantasy', name: 'Fantasy' } ]) }) ); } 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/genres', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) ); await page.goto('/upload'); await expect(page.getByTestId('upload-signin')).toBeVisible(); }); test('/upload creates a manga with no staged chapters and lands on the manga page', async ({ page }) => { await stubAuthenticatedAndGenres(page); let createdManga: typeof mangaFixture | 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/m2', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...mangaFixture, id: 'm2', title: 'Naruto' }) }) ); await page.route('**/api/v1/mangas/m2/chapters*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.route('**/api/v1/me/bookmarks*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.route('**/api/v1/me/read-progress/m2', (route) => route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no progress' } }) }) ); await page.goto('/upload'); await page.getByTestId('manga-title').fill('Naruto'); await page.getByTestId('manga-submit').click(); // After create, success → navigate to /manga/{id}. await expect(page).toHaveURL(/\/manga\/m2$/); expect(createdManga).not.toBeNull(); }); test('/upload stages a chapter with renamed page files (page-NNN.)', async ({ page }) => { await stubAuthenticatedAndGenres(page); let createdManga: typeof mangaFixture | null = null; let submittedPageNames: string[] = []; await page.route('**/api/v1/mangas', (route) => { if (route.request().method() === 'POST') { createdManga = { ...mangaFixture, id: 'm3', title: 'Vinland Saga' }; route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(createdManga) }); } else { route.fallback(); } }); await page.route('**/api/v1/mangas/m3/chapters', (route) => { if (route.request().method() === 'POST') { const post = route.request().postDataBuffer()?.toString('binary') ?? ''; // Pull every Content-Disposition filename out of the // multipart body — that's what the server (and proxies, // logs) would see. We expect only renamed `page-NNN.*` // entries, never the original filenames. const matches = [ ...post.matchAll(/filename="([^"]+)"/g) ].map((m) => m[1]); submittedPageNames = matches.filter((n) => n.startsWith('page-')); route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'c1', manga_id: 'm3', number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z' }) }); } else { route.fallback(); } }); await page.route('**/api/v1/mangas/m3', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...mangaFixture, id: 'm3', title: 'Vinland Saga' }) }) ); await page.route('**/api/v1/mangas/m3/chapters?*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.route('**/api/v1/me/bookmarks*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.route('**/api/v1/me/read-progress/m3', (route) => route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no progress' } }) }) ); await page.goto('/upload'); await page.getByTestId('manga-title').fill('Vinland Saga'); await page.getByTestId('add-chapter').click(); const pngBytes = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]); await page .getByTestId('staged-chapter-pages-input') .setInputFiles([ { name: 'IMG_2837.png', mimeType: 'image/png', buffer: pngBytes }, { name: 'random_file.png', mimeType: 'image/png', buffer: pngBytes } ]); // The list renders "Page 001" / "Page 002" not the original filenames. const list = page.getByTestId('staged-chapter-pages-list'); await expect(list).toContainText('Page 001'); await expect(list).toContainText('Page 002'); // Original filenames are visible as a dimmed caption (uploader- // reference; dropped after the row). await expect(list).toContainText('IMG_2837.png'); await page.getByTestId('manga-submit').click(); await expect(page).toHaveURL(/\/manga\/m3$/); expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']); }); test('/manga/[id]/upload-chapter happy path uploads renamed pages', async ({ page }) => { await stubAuthenticatedAndGenres(page); await page.route('**/api/v1/mangas/m1', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mangaFixture) }) ); await page.route('**/api/v1/mangas/m1/chapters?*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ id: 'c0', manga_id: 'm1', number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' }], page: { limit: 200, offset: 0, total: 1 } }) }) ); let submitted: string[] = []; await page.route('**/api/v1/mangas/m1/chapters', (route) => { if (route.request().method() === 'POST') { const post = route.request().postDataBuffer()?.toString('binary') ?? ''; submitted = [...post.matchAll(/filename="([^"]+)"/g)].map((m) => m[1]); route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'c-new', manga_id: 'm1', number: 2, title: null, page_count: 1, created_at: '2026-01-01T00:00:00Z' }) }); } else { route.fallback(); } }); await page.route('**/api/v1/me/bookmarks*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.route('**/api/v1/me/read-progress/m1', (route) => route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no progress' } }) }) ); await page.goto('/manga/m1/upload-chapter'); // Default chapter number is the next free one (existing max 1 → 2). await expect(page.getByTestId('chapter-number')).toHaveValue('2'); const pngBytes = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]); await page.getByTestId('pages-input').setInputFiles({ name: 'whatever.png', mimeType: 'image/png', buffer: pngBytes }); await expect(page.getByTestId('pages-list')).toContainText('Page 001'); await page.getByTestId('chapter-submit').click(); await expect(page).toHaveURL(/\/manga\/m1$/); expect(submitted.filter((n) => n.startsWith('page-'))).toEqual([ 'page-001.png' ]); }); test('chapter upload client preflight blocks oversized files', async ({ page }) => { await stubAuthenticatedAndGenres(page); await page.route('**/api/v1/mangas/m1', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mangaFixture) }) ); await page.route('**/api/v1/mangas/m1/chapters?*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) }) ); let chapterPostCalls = 0; await page.route('**/api/v1/mangas/m1/chapters', (route) => { if (route.request().method() === 'POST') chapterPostCalls += 1; route.fallback(); }); await page.goto('/manga/m1/upload-chapter'); const big = Buffer.alloc(21 * 1024 * 1024, 0xff); await page.getByTestId('pages-input').setInputFiles({ name: 'huge.png', mimeType: 'image/png', buffer: big }); await expect(page.getByTestId('pages-list')).toContainText('too large'); await expect(page.getByTestId('chapter-submit')).toBeDisabled(); expect(chapterPostCalls).toBe(0); });