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>
This commit is contained in:
@@ -8,14 +8,18 @@ const userFixture = {
|
||||
const mangaFixture = {
|
||||
id: 'm1',
|
||||
title: 'Berserk',
|
||||
author: 'Kentaro Miura',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description: null,
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
|
||||
genres: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
async function mockBaseUploadApis(page: Page) {
|
||||
async function stubAuthenticatedAndGenres(page: Page) {
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -23,14 +27,14 @@ async function mockBaseUploadApis(page: Page) {
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/mangas?*', (route) =>
|
||||
await page.route('**/api/v1/genres', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [mangaFixture],
|
||||
page: { limit: 200, offset: 0, total: 1 }
|
||||
})
|
||||
body: JSON.stringify([
|
||||
{ id: 'g-action', name: 'Action' },
|
||||
{ id: 'g-fantasy', name: 'Fantasy' }
|
||||
])
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -45,61 +49,20 @@ test('anonymous user sees sign-in prompt on /upload', async ({ page }) => {
|
||||
})
|
||||
})
|
||||
);
|
||||
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.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('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);
|
||||
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;
|
||||
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' };
|
||||
@@ -112,15 +75,88 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
|
||||
route.fallback();
|
||||
}
|
||||
});
|
||||
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
|
||||
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') {
|
||||
createdChapter = { id: 'c1', number: 1 };
|
||||
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: 'm1',
|
||||
manga_id: 'm3',
|
||||
number: 1,
|
||||
title: null,
|
||||
page_count: 2,
|
||||
@@ -131,62 +167,188 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
|
||||
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');
|
||||
|
||||
// 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 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 expect(page.getByTestId('chapter-pages-list')).toContainText('1.png');
|
||||
await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png');
|
||||
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('chapter-submit').click();
|
||||
await expect(page.getByTestId('chapter-success')).toContainText(
|
||||
'2 pages'
|
||||
);
|
||||
expect(createdChapter).not.toBeNull();
|
||||
await page.getByTestId('manga-submit').click();
|
||||
await expect(page).toHaveURL(/\/manga\/m3$/);
|
||||
expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']);
|
||||
});
|
||||
|
||||
test('client preflight blocks oversized files without hitting the network', async ({ page }) => {
|
||||
await mockBaseUploadApis(page);
|
||||
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('/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.
|
||||
await page.goto('/manga/m1/upload-chapter');
|
||||
const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
|
||||
await page.getByTestId('chapter-pages-input').setInputFiles({
|
||||
await page.getByTestId('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('pages-list')).toContainText('too large');
|
||||
await expect(page.getByTestId('chapter-submit')).toBeDisabled();
|
||||
expect(chapterPostCalls).toBe(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user