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>
105 lines
4.5 KiB
TypeScript
105 lines
4.5 KiB
TypeScript
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);
|
|
});
|