diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 242da73..55c05b4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.115.0" +version = "0.115.1" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index dc5a7b1..f065cd7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.115.0" +version = "0.115.1" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/detail-bookmark.spec.ts b/frontend/e2e/detail-bookmark.spec.ts new file mode 100644 index 0000000..01297aa --- /dev/null +++ b/frontend/e2e/detail-bookmark.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Page } from './fixtures'; + +// The detail-page bookmark toggle should feel instant (optimistic) and never +// fail silently: a rejected create/delete must roll the button back and +// surface a toast. + +const mangaId = 'b1111111-1111-1111-1111-111111111111'; + +async function mockDetail(page: Page) { + 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: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } + }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + ); + 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/mangas/${mangaId}/chapters*`, (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) }) + ); + await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) => + r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) => + r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route(`**/api/v1/mangas/${mangaId}`, (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: mangaId, 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: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 + }) + }) + ); +} + +test('bookmark toggles optimistically before the request resolves', async ({ page }) => { + await mockDetail(page); + + // Hold the create request open so we can observe the pre-response state. + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + await page.route('**/api/v1/bookmarks', async (route) => { + await gate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'bk1', user_id: 'u1', manga_id: mangaId, chapter_id: null, page: null, + created_at: '2026-01-01T00:00:00Z' + }) + }); + }); + + await page.goto(`/manga/${mangaId}`); + const btn = page.getByTestId('bookmark-toggle'); + await expect(btn).toHaveAttribute('aria-pressed', 'false'); + + await btn.click(); + // Optimistic: reflects bookmarked state while the POST is still pending. + await expect(btn).toHaveAttribute('aria-pressed', 'true'); + + release(); + // Stays bookmarked once the server confirms. + await expect(btn).toHaveAttribute('aria-pressed', 'true'); +}); + +test('a failed bookmark rolls back and shows an error toast', async ({ page }) => { + await mockDetail(page); + await page.route('**/api/v1/bookmarks', (route) => + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'internal', message: 'boom' } }) + }) + ); + + await page.goto(`/manga/${mangaId}`); + const btn = page.getByTestId('bookmark-toggle'); + await btn.click(); + + // Rolls back to un-bookmarked, and the failure is surfaced (not silent). + await expect(btn).toHaveAttribute('aria-pressed', 'false'); + await expect(page.getByTestId('toast')).toBeVisible(); +}); diff --git a/frontend/package.json b/frontend/package.json index fe1587b..24385fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.115.0", + "version": "0.115.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index b9a5b35..6bc1612 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -18,6 +18,7 @@ import { listTags, type Tag } from '$lib/api/tags'; import type { ContentWarning } from '$lib/api/page_tags'; import { session } from '$lib/session.svelte'; + import { toast } from '$lib/toast.svelte'; import Chip from '$lib/components/Chip.svelte'; import MangaCard from '$lib/components/MangaCard.svelte'; import ReactionButtons from '$lib/components/ReactionButtons.svelte'; @@ -155,17 +156,36 @@ }); async function toggleBookmark() { - if (!session.user) return; + if (!session.user || busy) return; + const existing = mangaBookmark; + // Snapshot for rollback — the update below is applied optimistically + // so the button flips instantly, then reconciled/reverted. + const snapshot = bookmarks; busy = true; try { - if (mangaBookmark) { - const id = mangaBookmark.id; - await deleteBookmark(id); - bookmarks = bookmarks.filter((b) => b.id !== id); + if (existing) { + bookmarks = bookmarks.filter((b) => b.id !== existing.id); + await deleteBookmark(existing.id); } else { + // Temporary placeholder so the derived `mangaBookmark` flips + // immediately; swapped for the server row (with its real id) + // once the POST returns. + const tempId = `optimistic-${crypto.randomUUID()}`; + const placeholder: Bookmark = { + id: tempId, + user_id: session.user.id, + manga_id: manga.id, + chapter_id: null, + page: null, + created_at: new Date().toISOString() + }; + bookmarks = [placeholder, ...bookmarks]; const b = await createBookmark({ manga_id: manga.id }); - bookmarks = [b, ...bookmarks]; + bookmarks = [b, ...bookmarks.filter((x) => x.id !== tempId)]; } + } catch { + bookmarks = snapshot; + toast.error(existing ? 'Could not remove bookmark.' : 'Could not add bookmark.'); } finally { busy = false; }