From 3b783c1d9b0f94ccfb3a047c1792e38915b47e59 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 15:20:03 +0200 Subject: [PATCH] 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) --- frontend/e2e/upload-retry.spec.ts | 104 ++++++++++++++++++++++++ frontend/package.json | 2 +- frontend/src/routes/upload/+page.svelte | 88 +++++++++++++------- 3 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 frontend/e2e/upload-retry.spec.ts diff --git a/frontend/e2e/upload-retry.spec.ts b/frontend/e2e/upload-retry.spec.ts new file mode 100644 index 0000000..e575aad --- /dev/null +++ b/frontend/e2e/upload-retry.spec.ts @@ -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); +}); diff --git a/frontend/package.json b/frontend/package.json index 328a465..88ebe81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.0", + "version": "0.128.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/upload/+page.svelte b/frontend/src/routes/upload/+page.svelte index 56659f2..152e16c 100644 --- a/frontend/src/routes/upload/+page.svelte +++ b/frontend/src/routes/upload/+page.svelte @@ -44,11 +44,18 @@ let mangaError = $state(null); let success = $state(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(null); + let createdMangaTitle = $state(null); + const mangaCreated = $derived(createdMangaId != null); + // 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( () => - !success && !submitting && (mangaTitle.trim() !== '' || mangaDescription.trim() !== '' || @@ -148,39 +155,46 @@ submitting = true; mangaError = null; success = null; - let manga; - try { - manga = await createManga( - { - title: mangaTitle.trim(), - status: mangaStatus, - authors: mangaAuthors, - alt_titles: mangaAltTitles, - genre_ids: mangaGenreIds, - description: mangaDescription.trim() || null - }, - coverFile ?? undefined - ); - } catch (e) { - if (e instanceof ApiError && e.status === 401) { - await goto('/login?next=/upload'); + + // Create the manga only once. On a retry after a partial failure the + // row already exists (createdMangaId set), so we skip creation and go + // straight to re-uploading the not-yet-done chapters — creating it again + // would spawn a duplicate manga. + if (createdMangaId == null) { + try { + const manga = await createManga( + { + title: mangaTitle.trim(), + status: mangaStatus, + authors: mangaAuthors, + alt_titles: mangaAltTitles, + genre_ids: mangaGenreIds, + description: mangaDescription.trim() || null + }, + 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; } - mangaError = e instanceof Error ? e.message : String(e); - submitting = false; - return; } - // Manga is created; ship chapters one at a time and surface - // per-row status. Failures don't roll back the manga — the - // user can retry just the failed chapters from the manga - // page's Upload-chapter button. + // Ship the outstanding chapters one at a time, surfacing per-row status. + // Already-succeeded rows are skipped so a retry only re-sends failures. for (const c of stagedChapters) { + if (c.status === 'done') continue; c.status = 'uploading'; c.error = null; try { await createChapter( - manga.id, + createdMangaId, { number: c.number, title: c.title.trim() || null }, c.pages.map((p) => p.file) ); @@ -193,12 +207,11 @@ const failed = stagedChapters.filter((c) => c.status === 'failed'); if (failed.length === 0) { - // All-good — land the user on the manga page where they - // can confirm and continue uploading. - await goto(`/manga/${manga.id}`); + // All-good — land the user on the manga page. + await goto(`/manga/${createdMangaId}`); 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; } @@ -215,6 +228,12 @@

Manga details

+ {#if mangaCreated} +

+ Manga created. Its details are saved — fix any failed chapters + below and submit again to finish; the manga won't be duplicated. +

+ {/if} @@ -435,7 +455,13 @@ disabled={!canSubmit} data-testid="manga-submit" > - {submitting ? 'Submitting…' : 'Create manga'} + {#if submitting} + Submitting… + {:else if mangaCreated} + Retry failed chapters + {:else} + Create manga + {/if} {#if success}

{success}