Files
Mangalord/frontend/e2e/bookmarks.spec.ts
MechaCat02 dee7f1d160 bugfix: /bookmarks renders manga title and cover
The bookmarks list was rendering "Manga bookmark <date>" with no
indication of which manga the bookmark referred to. The data is
already in the DB — the list query just wasn't pulling it.

Backend:
- BookmarkSummary gains manga_title (String) and
  manga_cover_image_path (Option<String>). Populated by an INNER JOIN
  on `mangas` in `repo::bookmark::list_for_user`. The JOIN is INNER
  because `bookmarks.manga_id` has ON DELETE CASCADE, so a bookmark
  cannot outlive its manga. Chapter LEFT JOIN unchanged.
- The existing list_me_enriches_chapter_bookmarks_with_chapter_number
  test now also asserts manga_title is populated for both chapter-
  and manga-level bookmarks, and that manga_cover_image_path is null
  when no cover was uploaded.

Frontend:
- Bookmark type carries optional manga_title and
  manga_cover_image_path (optional because POST /bookmarks returns
  the bare Bookmark, not the enriched summary).
- /bookmarks page redesigned as a grid: cover thumbnail (64×96 with
  a placeholder when no cover) on the left, then the manga title (as
  the primary link), then either "Chapter N — page M" linked to the
  reader, "(chapter removed)" for orphan chapter bookmarks, or
  "Whole manga" for manga-level bookmarks. Bookmark date moves to a
  subdued footer.
- E2E fixtures track the enriched shape returned by the list endpoint
  (vs. the bare Bookmark returned by POST). The toggle test now
  asserts the manga title appears on the bookmarks card after the
  bookmark is created.

Also: tighten .gitignore. `/data` only catches the compose volume
root; the dev backend writes to `/backend/data` (default STORAGE_DIR
is `./data/storage` relative to backend cwd), so local uploads were
showing as untracked. Adding `/backend/data` keeps test uploads out
of the index.

Lockstep version bump to 0.11.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:46:01 +02:00

191 lines
6.7 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
const mangaId = '22222222-2222-2222-2222-222222222222';
const userFixture = {
id: 'u1',
username: 'alice',
created_at: '2026-01-01T00:00:00Z'
};
const mangaFixture = {
id: mangaId,
title: 'Berserk',
author: 'Kentaro Miura',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
};
const bookmarkFixture = {
id: 'b1',
user_id: 'u1',
manga_id: mangaId,
chapter_id: null,
page: null,
created_at: '2026-01-01T00:00:00Z'
};
// /me/bookmarks responses are enriched (`BookmarkSummary` on the wire),
// while POST /bookmarks returns the bare `Bookmark`. The mock layer
// returns the bare fixture on POST and the enriched one in the list.
const enrichedBookmarkFixture = {
...bookmarkFixture,
manga_title: 'Berserk',
manga_cover_image_path: null,
chapter_number: null
};
async function setupAuthenticatedBookmarkFlow(page: Page) {
let bookmarks: typeof enrichedBookmarkFixture[] = [];
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: userFixture })
})
);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters?*`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: null } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: null } })
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: bookmarks,
page: { limit: 50, offset: 0, total: null }
})
})
);
await page.route('**/api/v1/bookmarks', (route) => {
if (route.request().method() === 'POST') {
// List endpoint is enriched (BookmarkSummary), POST returns
// the bare Bookmark — but the in-memory list we surface to
// /me/bookmarks uses the enriched fixture so the rendered
// card has the manga title.
bookmarks = [enrichedBookmarkFixture, ...bookmarks];
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(bookmarkFixture)
});
} else {
route.fallback();
}
});
await page.route('**/api/v1/bookmarks/b1', (route) => {
if (route.request().method() === 'DELETE') {
bookmarks = bookmarks.filter((b) => b.id !== 'b1');
route.fulfill({ status: 204 });
} else {
route.fallback();
}
});
}
test('authed user toggles a manga bookmark and sees it in /bookmarks', async ({ page }) => {
await setupAuthenticatedBookmarkFlow(page);
await page.goto(`/manga/${mangaId}`);
const toggle = page.getByTestId('bookmark-toggle');
await expect(toggle).toHaveText('☆ Bookmark');
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
await toggle.click();
await expect(toggle).toHaveText('★ Bookmarked');
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
// The /bookmarks list reflects it.
await page.goto('/bookmarks');
// The /bookmarks page now renders a real card with the manga
// title — the fixture title is "Berserk" (see mangaFixture above).
await expect(page.getByTestId('bookmark-title')).toContainText('Berserk');
await expect(page.getByTestId('bookmark-list')).toContainText('Whole manga');
// Toggle off from the manga page.
await page.goto(`/manga/${mangaId}`);
const toggle2 = page.getByTestId('bookmark-toggle');
await expect(toggle2).toHaveText('★ Bookmarked');
await toggle2.click();
await expect(toggle2).toHaveText('☆ Bookmark');
});
test('anonymous user sees a sign-in CTA instead of a toggle', async ({ page }) => {
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters?*`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: null } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: null } })
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
})
);
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('bookmark-signin')).toBeVisible();
await expect(page.getByTestId('bookmark-toggle')).toHaveCount(0);
});
test('/bookmarks page prompts anonymous users to sign in', async ({ page }) => {
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
})
);
await page.goto('/bookmarks');
await expect(page.getByTestId('bookmarks-signin')).toBeVisible();
});