Files
Mangalord/frontend/e2e/upload.spec.ts
MechaCat02 c95c1805df feat: upload flow revamp (0.20.0)
- `/upload` is now manga-only with optional N initial chapters
  staged inline.
- Additional chapters from a new `/manga/[id]/upload-chapter` route,
  reached via an "Upload chapter" button on the manga page.
- New `ChapterPagesEditor` component: thumbnails next to each row,
  click-to-preview-modal, drag-drop + reorder.
- Pages renamed to `page-NNN.<ext>` before multipart submission;
  original filenames shown as dimmed reference text during upload
  and dropped on submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:59:22 +02:00

355 lines
12 KiB
TypeScript

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.<ext>)', 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);
});