fix: upload retry reuses the created manga instead of duplicating it
After a partial upload failure (manga created, a chapter failed), re-submitting re-ran createManga and spawned a duplicate manga. Track the created manga id: a retry skips creation, re-sends only the not-yet-done chapters, disables the now-saved title, relabels the button "Retry failed chapters", and shows a note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
104
frontend/e2e/upload-retry.spec.ts
Normal file
104
frontend/e2e/upload-retry.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// A partial upload failure (manga created, a chapter fails) must NOT create a
|
||||||
|
// duplicate manga when the user retries — the second submit reuses the created
|
||||||
|
// manga and only re-sends the failed chapter.
|
||||||
|
|
||||||
|
const userFixture = {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'uploader',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
is_admin: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const manga = {
|
||||||
|
id: 'm9',
|
||||||
|
title: 'Retry Saga',
|
||||||
|
status: 'ongoing',
|
||||||
|
description: null,
|
||||||
|
authors: [],
|
||||||
|
alt_titles: [],
|
||||||
|
genres: [],
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function stub(page: Page, counters: { mangaPosts: number; chapterPosts: number }) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: userFixture }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/genres', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }));
|
||||||
|
|
||||||
|
await page.route('**/api/v1/mangas', (r) => {
|
||||||
|
if (r.request().method() === 'POST') {
|
||||||
|
counters.mangaPosts += 1;
|
||||||
|
return r.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(manga) });
|
||||||
|
}
|
||||||
|
return r.fallback();
|
||||||
|
});
|
||||||
|
// Chapter POST: fail the first attempt, succeed on the retry.
|
||||||
|
await page.route('**/api/v1/mangas/m9/chapters', (r) => {
|
||||||
|
if (r.request().method() === 'POST') {
|
||||||
|
counters.chapterPosts += 1;
|
||||||
|
if (counters.chapterPosts === 1) {
|
||||||
|
return r.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'internal_error', message: 'boom' } })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ id: 'c1', manga_id: 'm9', number: 1, title: null, page_count: 1, created_at: '2026-01-01T00:00:00Z' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.fallback();
|
||||||
|
});
|
||||||
|
// Post-success destination + its data.
|
||||||
|
await page.route('**/api/v1/mangas/m9', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(manga) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/mangas/m9/chapters?*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.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/m9', (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'x' } }) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('retrying a partially-failed upload does not create a duplicate manga', async ({ page }) => {
|
||||||
|
const counters = { mangaPosts: 0, chapterPosts: 0 };
|
||||||
|
await stub(page, counters);
|
||||||
|
|
||||||
|
await page.goto('/upload');
|
||||||
|
await page.getByTestId('manga-title').fill('Retry Saga');
|
||||||
|
await page.getByTestId('add-chapter').click();
|
||||||
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
await page
|
||||||
|
.getByTestId('staged-chapter-pages-input')
|
||||||
|
.setInputFiles([{ name: 'p.png', mimeType: 'image/png', buffer: png }]);
|
||||||
|
|
||||||
|
// First submit: manga created, chapter fails → the retry note + relabeled button.
|
||||||
|
await page.getByTestId('manga-submit').click();
|
||||||
|
await expect(page.getByTestId('manga-success')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('manga-created-note')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('manga-submit')).toHaveText('Retry failed chapters');
|
||||||
|
expect(counters.mangaPosts).toBe(1);
|
||||||
|
|
||||||
|
// Retry: no second manga POST, chapter re-sent, navigate to the manga.
|
||||||
|
await page.getByTestId('manga-submit').click();
|
||||||
|
await expect(page).toHaveURL(/\/manga\/m9$/);
|
||||||
|
expect(counters.mangaPosts).toBe(1);
|
||||||
|
expect(counters.chapterPosts).toBe(2);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.0",
|
"version": "0.128.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -44,11 +44,18 @@
|
|||||||
let mangaError = $state<string | null>(null);
|
let mangaError = $state<string | null>(null);
|
||||||
let success = $state<string | null>(null);
|
let success = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Set once the manga row is created. A retry after a partial chapter
|
||||||
|
// failure reuses this id instead of creating a duplicate manga, and the
|
||||||
|
// form switches into "finish uploading chapters" mode.
|
||||||
|
let createdMangaId = $state<string | null>(null);
|
||||||
|
let createdMangaTitle = $state<string | null>(null);
|
||||||
|
const mangaCreated = $derived(createdMangaId != null);
|
||||||
|
|
||||||
// Warn before navigating away with an in-progress upload. Suppressed while
|
// Warn before navigating away with an in-progress upload. Suppressed while
|
||||||
// submitting and after a successful save (which redirects).
|
// submitting (the post-save redirect). After a partial failure the created
|
||||||
|
// manga's failed chapters are still unsaved staged work, so we keep warning.
|
||||||
guardUnsavedChanges(
|
guardUnsavedChanges(
|
||||||
() =>
|
() =>
|
||||||
!success &&
|
|
||||||
!submitting &&
|
!submitting &&
|
||||||
(mangaTitle.trim() !== '' ||
|
(mangaTitle.trim() !== '' ||
|
||||||
mangaDescription.trim() !== '' ||
|
mangaDescription.trim() !== '' ||
|
||||||
@@ -148,39 +155,46 @@
|
|||||||
submitting = true;
|
submitting = true;
|
||||||
mangaError = null;
|
mangaError = null;
|
||||||
success = null;
|
success = null;
|
||||||
let manga;
|
|
||||||
try {
|
// Create the manga only once. On a retry after a partial failure the
|
||||||
manga = await createManga(
|
// row already exists (createdMangaId set), so we skip creation and go
|
||||||
{
|
// straight to re-uploading the not-yet-done chapters — creating it again
|
||||||
title: mangaTitle.trim(),
|
// would spawn a duplicate manga.
|
||||||
status: mangaStatus,
|
if (createdMangaId == null) {
|
||||||
authors: mangaAuthors,
|
try {
|
||||||
alt_titles: mangaAltTitles,
|
const manga = await createManga(
|
||||||
genre_ids: mangaGenreIds,
|
{
|
||||||
description: mangaDescription.trim() || null
|
title: mangaTitle.trim(),
|
||||||
},
|
status: mangaStatus,
|
||||||
coverFile ?? undefined
|
authors: mangaAuthors,
|
||||||
);
|
alt_titles: mangaAltTitles,
|
||||||
} catch (e) {
|
genre_ids: mangaGenreIds,
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
description: mangaDescription.trim() || null
|
||||||
await goto('/login?next=/upload');
|
},
|
||||||
|
coverFile ?? undefined
|
||||||
|
);
|
||||||
|
createdMangaId = manga.id;
|
||||||
|
createdMangaTitle = manga.title;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
|
await goto('/login?next=/upload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mangaError = e instanceof Error ? e.message : String(e);
|
||||||
|
submitting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mangaError = e instanceof Error ? e.message : String(e);
|
|
||||||
submitting = false;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manga is created; ship chapters one at a time and surface
|
// Ship the outstanding chapters one at a time, surfacing per-row status.
|
||||||
// per-row status. Failures don't roll back the manga — the
|
// Already-succeeded rows are skipped so a retry only re-sends failures.
|
||||||
// user can retry just the failed chapters from the manga
|
|
||||||
// page's Upload-chapter button.
|
|
||||||
for (const c of stagedChapters) {
|
for (const c of stagedChapters) {
|
||||||
|
if (c.status === 'done') continue;
|
||||||
c.status = 'uploading';
|
c.status = 'uploading';
|
||||||
c.error = null;
|
c.error = null;
|
||||||
try {
|
try {
|
||||||
await createChapter(
|
await createChapter(
|
||||||
manga.id,
|
createdMangaId,
|
||||||
{ number: c.number, title: c.title.trim() || null },
|
{ number: c.number, title: c.title.trim() || null },
|
||||||
c.pages.map((p) => p.file)
|
c.pages.map((p) => p.file)
|
||||||
);
|
);
|
||||||
@@ -193,12 +207,11 @@
|
|||||||
|
|
||||||
const failed = stagedChapters.filter((c) => c.status === 'failed');
|
const failed = stagedChapters.filter((c) => c.status === 'failed');
|
||||||
if (failed.length === 0) {
|
if (failed.length === 0) {
|
||||||
// All-good — land the user on the manga page where they
|
// All-good — land the user on the manga page.
|
||||||
// can confirm and continue uploading.
|
await goto(`/manga/${createdMangaId}`);
|
||||||
await goto(`/manga/${manga.id}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
success = `"${manga.title}" was created, but ${failed.length} of ${stagedChapters.length} chapters failed. Fix them and retry from the manga page.`;
|
success = `"${createdMangaTitle}" was created, but ${failed.length} of ${stagedChapters.length} chapters still failed. Fix them and retry — the manga won't be duplicated.`;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -215,6 +228,12 @@
|
|||||||
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
|
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Manga details</h2>
|
<h2>Manga details</h2>
|
||||||
|
{#if mangaCreated}
|
||||||
|
<p class="status" data-testid="manga-created-note">
|
||||||
|
Manga created. Its details are saved — fix any failed chapters
|
||||||
|
below and submit again to finish; the manga won't be duplicated.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Title <span aria-hidden="true">*</span></span>
|
<span>Title <span aria-hidden="true">*</span></span>
|
||||||
<input
|
<input
|
||||||
@@ -222,6 +241,7 @@
|
|||||||
bind:value={mangaTitle}
|
bind:value={mangaTitle}
|
||||||
required
|
required
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
|
disabled={mangaCreated}
|
||||||
data-testid="manga-title"
|
data-testid="manga-title"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -435,7 +455,13 @@
|
|||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
data-testid="manga-submit"
|
data-testid="manga-submit"
|
||||||
>
|
>
|
||||||
{submitting ? 'Submitting…' : 'Create manga'}
|
{#if submitting}
|
||||||
|
Submitting…
|
||||||
|
{:else if mangaCreated}
|
||||||
|
Retry failed chapters
|
||||||
|
{:else}
|
||||||
|
Create manga
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if success}
|
{#if success}
|
||||||
<p class="success" data-testid="manga-success">{success}</p>
|
<p class="success" data-testid="manga-success">{success}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user