diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7fda42b..986d61f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8539c8a..7034098 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.6.0" +version = "0.7.0" edition = "2021" [lib] diff --git a/backend/migrations/0004_bookmarks_unique.sql b/backend/migrations/0004_bookmarks_unique.sql new file mode 100644 index 0000000..46d3e99 --- /dev/null +++ b/backend/migrations/0004_bookmarks_unique.sql @@ -0,0 +1,12 @@ +-- Tighten the bookmarks uniqueness for manga-level bookmarks. +-- +-- The 0001 UNIQUE constraint is (user_id, manga_id, chapter_id), but +-- PostgreSQL treats NULL as distinct under NULLS DISTINCT (the default), +-- so two manga-level bookmarks for the same (user, manga) were both +-- allowed. The partial index below blocks that exact case, while letting +-- a manga-level bookmark coexist with chapter-level bookmarks for the +-- same manga (which is the intended UX). + +CREATE UNIQUE INDEX bookmarks_user_manga_no_chapter_uniq + ON bookmarks (user_id, manga_id) + WHERE chapter_id IS NULL; diff --git a/backend/src/api/bookmarks.rs b/backend/src/api/bookmarks.rs new file mode 100644 index 0000000..04220d4 --- /dev/null +++ b/backend/src/api/bookmarks.rs @@ -0,0 +1,103 @@ +//! Bookmarks — owned by a `CurrentUser`. Reads + writes both require +//! auth; the listing endpoint is scoped under `/me/bookmarks` so the +//! URL itself can't be reused to peek at another user's bookmarks. + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::api::pagination::PagedResponse; +use crate::app::AppState; +use crate::auth::extractor::CurrentUser; +use crate::domain::Bookmark; +use crate::error::{AppError, AppResult}; +use crate::repo; + +pub fn routes() -> Router { + Router::new() + .route("/bookmarks", post(create)) + .route("/bookmarks/:id", delete(delete_one)) + .route("/me/bookmarks", get(list_me)) +} + +#[derive(Debug, Deserialize)] +pub struct NewBookmark { + pub manga_id: Uuid, + #[serde(default)] + pub chapter_id: Option, + #[serde(default)] + pub page: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ListParams { + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + 50 +} + +async fn create( + State(state): State, + CurrentUser(user): CurrentUser, + Json(input): Json, +) -> AppResult<(StatusCode, Json)> { + // Surface 404 on a non-existent manga / chapter rather than letting + // the foreign-key violation collapse into a generic 500. + repo::manga::get(&state.db, input.manga_id).await?; + if let Some(chapter_id) = input.chapter_id { + let exists: Option<(Uuid,)> = sqlx::query_as( + "SELECT id FROM chapters WHERE id = $1 AND manga_id = $2", + ) + .bind(chapter_id) + .bind(input.manga_id) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(AppError::NotFound); + } + } + + let bookmark = repo::bookmark::create( + &state.db, + user.id, + input.manga_id, + input.chapter_id, + input.page, + ) + .await?; + Ok((StatusCode::CREATED, Json(bookmark))) +} + +async fn delete_one( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> AppResult { + match repo::bookmark::find_owner(&state.db, id).await? { + None => Err(AppError::NotFound), + Some(owner) if owner != user.id => Err(AppError::Forbidden), + Some(_) => { + repo::bookmark::delete(&state.db, id).await?; + Ok(StatusCode::NO_CONTENT) + } + } +} + +async fn list_me( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let items = repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?; + Ok(Json(PagedResponse::new(items, limit, offset))) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 2801ae0..a0fc1b6 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod bookmarks; pub mod chapters; pub mod files; pub mod health; @@ -16,4 +17,5 @@ pub fn routes() -> Router { .merge(chapters::routes()) .merge(files::routes()) .merge(auth::routes()) + .merge(bookmarks::routes()) } diff --git a/backend/src/repo/bookmark.rs b/backend/src/repo/bookmark.rs new file mode 100644 index 0000000..4efd4f6 --- /dev/null +++ b/backend/src/repo/bookmark.rs @@ -0,0 +1,85 @@ +//! Bookmark persistence. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::Bookmark; +use crate::error::{AppError, AppResult}; + +pub async fn create( + pool: &PgPool, + user_id: Uuid, + manga_id: Uuid, + chapter_id: Option, + page: Option, +) -> AppResult { + let result = sqlx::query_as::<_, Bookmark>( + r#" + INSERT INTO bookmarks (user_id, manga_id, chapter_id, page) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, manga_id, chapter_id, page, created_at + "#, + ) + .bind(user_id) + .bind(manga_id) + .bind(chapter_id) + .bind(page) + .fetch_one(pool) + .await; + + match result { + Ok(b) => Ok(b), + Err(e) if is_unique_violation(&e) => Err(AppError::Conflict( + "bookmark already exists for this manga/chapter".into(), + )), + Err(e) => Err(AppError::Database(e)), + } +} + +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> AppResult> { + let rows = sqlx::query_as::<_, Bookmark>( + r#" + SELECT id, user_id, manga_id, chapter_id, page, created_at + FROM bookmarks + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult> { + let row: Option<(Uuid,)> = + sqlx::query_as("SELECT user_id FROM bookmarks WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(uid,)| uid)) +} + +pub async fn delete(pool: &PgPool, id: Uuid) -> AppResult<()> { + sqlx::query("DELETE FROM bookmarks WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +fn is_unique_violation(err: &sqlx::Error) -> bool { + if let sqlx::Error::Database(db_err) = err { + db_err.code().as_deref() == Some("23505") + } else { + false + } +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index 26c91b4..5a7dbe0 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -1,4 +1,5 @@ pub mod api_token; +pub mod bookmark; pub mod chapter; pub mod manga; pub mod page; diff --git a/backend/tests/api_bookmarks.rs b/backend/tests/api_bookmarks.rs new file mode 100644 index 0000000..bc1fb3f --- /dev/null +++ b/backend/tests/api_bookmarks.rs @@ -0,0 +1,239 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +#[sqlx::test(migrations = "./migrations")] +async fn create_then_list_returns_only_own(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie_a) = common::register_user(&h.app).await; + let (_, cookie_b) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await; + + // User A bookmarks the manga. + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/bookmarks", + json!({ "manga_id": manga_id.to_string() }), + &cookie_a, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let body = common::body_json(resp).await; + assert_eq!(body["manga_id"], manga_id.to_string()); + + // User B sees nothing. + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_b)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["items"], json!([])); + + // User A sees their bookmark. + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_a)) + .await + .unwrap(); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["manga_id"], manga_id.to_string()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_returns_409_on_duplicate_manga_level(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + let make = || { + common::post_json_with_cookie( + "/api/v1/bookmarks", + json!({ "manga_id": manga_id.to_string() }), + &cookie, + ) + }; + let first = h.app.clone().oneshot(make()).await.unwrap(); + assert_eq!(first.status(), StatusCode::CREATED); + let second = h.app.oneshot(make()).await.unwrap(); + assert_eq!(second.status(), StatusCode::CONFLICT); + let body = common::body_json(second).await; + assert_eq!(body["error"]["code"], "conflict"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_404_on_unknown_manga(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let unknown = Uuid::nil(); + let resp = h + .app + .oneshot(common::post_json_with_cookie( + "/api/v1/bookmarks", + json!({ "manga_id": unknown.to_string() }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_requires_authentication(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + // Unauthenticated request → 401. + let resp = h + .app + .oneshot(common::post_json( + "/api/v1/bookmarks", + json!({ "manga_id": manga_id.to_string() }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "./migrations")] +async fn user_a_cannot_delete_user_b_bookmark(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie_a) = common::register_user(&h.app).await; + let (_, cookie_b) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await; + + // User A creates a bookmark. + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/bookmarks", + json!({ "manga_id": manga_id.to_string() }), + &cookie_a, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + let id = body["id"].as_str().unwrap().to_string(); + + // User B tries to delete → 403. + let resp = h + .app + .clone() + .oneshot(common::delete_with_cookie( + &format!("/api/v1/bookmarks/{id}"), + &cookie_b, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "forbidden"); + + // User A succeeds. + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/bookmarks/{id}"), + &cookie_a, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_unknown_bookmark_is_404(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::delete_with_cookie( + "/api/v1/bookmarks/00000000-0000-0000-0000-000000000000", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_already_deleted_bookmark_is_404(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/bookmarks", + json!({ "manga_id": manga_id.to_string() }), + &cookie, + )) + .await + .unwrap(); + let id = common::body_json(resp).await["id"].as_str().unwrap().to_string(); + + let resp = h + .app + .clone() + .oneshot(common::delete_with_cookie( + &format!("/api/v1/bookmarks/{id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Deleting again → 404, not 500. + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/bookmarks/{id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_me_requires_authentication(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/me/bookmarks")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_me_returns_paged_envelope(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert!(body["items"].is_array()); + assert_eq!(body["page"]["limit"], 50); + assert_eq!(body["page"]["offset"], 0); + assert!(body["page"]["total"].is_null()); +} diff --git a/frontend/e2e/bookmarks.spec.ts b/frontend/e2e/bookmarks.spec.ts new file mode 100644 index 0000000..98573d4 --- /dev/null +++ b/frontend/e2e/bookmarks.spec.ts @@ -0,0 +1,174 @@ +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' +}; + +async function setupAuthenticatedBookmarkFlow(page: Page) { + let bookmarks: typeof bookmarkFixture[] = []; + + 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') { + bookmarks = [bookmarkFixture, ...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'); + await expect(page.getByTestId('bookmark-list')).toContainText('Manga bookmark'); + + // 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(); +}); diff --git a/frontend/e2e/reader.spec.ts b/frontend/e2e/reader.spec.ts index 78fcdd0..5e95829 100644 --- a/frontend/e2e/reader.spec.ts +++ b/frontend/e2e/reader.spec.ts @@ -52,6 +52,13 @@ async function mockReaderApis(page: Page) { 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.route(`**/api/v1/mangas/${mangaId}`, (route) => route.fulfill({ status: 200, @@ -111,7 +118,7 @@ test('manga overview shows title, cover, and a chapter list', async ({ page }) = await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura'); await expect(page.getByTestId('manga-cover')).toBeVisible(); await expect(page.getByTestId('chapter-list')).toContainText('Chapter 1'); - await expect(page.getByTestId('bookmark-placeholder')).toBeDisabled(); + await expect(page.getByTestId('bookmark-signin')).toBeVisible(); }); test('reader paginates with arrow keys and j/k, and preloads the next page', async ({ page }) => { diff --git a/frontend/package.json b/frontend/package.json index 20c3b4b..813c667 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/bookmarks.test.ts b/frontend/src/lib/api/bookmarks.test.ts new file mode 100644 index 0000000..058d62c --- /dev/null +++ b/frontend/src/lib/api/bookmarks.test.ts @@ -0,0 +1,102 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance +} from 'vitest'; +import { + createBookmark, + deleteBookmark, + listMyBookmarks, + listMyBookmarksOrEmpty +} from './bookmarks'; + +function ok(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +function envelope(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +const bookmarkFixture = { + id: 'b1', + user_id: 'u1', + manga_id: 'm1', + chapter_id: null, + page: null, + created_at: '2026-01-01T00:00:00Z' +}; + +describe('bookmarks api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('createBookmark POSTs JSON to /v1/bookmarks', async () => { + fetchSpy.mockResolvedValueOnce(ok(bookmarkFixture, 201)); + const b = await createBookmark({ manga_id: 'm1' }); + expect(b).toEqual(bookmarkFixture); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/bookmarks$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm1' }); + }); + + it('createBookmark surfaces 409 conflict', async () => { + fetchSpy.mockResolvedValueOnce(envelope(409, 'conflict', 'already bookmarked')); + await expect(createBookmark({ manga_id: 'm1' })).rejects.toMatchObject({ + status: 409, + code: 'conflict' + }); + }); + + it('deleteBookmark DELETEs /v1/bookmarks/{id} and handles 204', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await expect(deleteBookmark('b1')).resolves.toBeUndefined(); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/bookmarks\/b1$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + }); + + it('listMyBookmarks hits /v1/me/bookmarks and returns paged envelope', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [bookmarkFixture], page: { limit: 50, offset: 0, total: null } }) + ); + const result = await listMyBookmarks(); + expect(result.items).toHaveLength(1); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/me\/bookmarks$/); + }); + + it('listMyBookmarksOrEmpty returns empty page on 401', async () => { + fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated')); + const result = await listMyBookmarksOrEmpty(); + expect(result.items).toEqual([]); + }); + + it('listMyBookmarksOrEmpty re-throws non-401', async () => { + fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'oops')); + await expect(listMyBookmarksOrEmpty()).rejects.toMatchObject({ status: 500 }); + }); +}); diff --git a/frontend/src/lib/api/bookmarks.ts b/frontend/src/lib/api/bookmarks.ts new file mode 100644 index 0000000..247d311 --- /dev/null +++ b/frontend/src/lib/api/bookmarks.ts @@ -0,0 +1,60 @@ +import { ApiError, request, type Page } from './client'; + +export type Bookmark = { + id: string; + user_id: string; + manga_id: string; + chapter_id: string | null; + page: number | null; + created_at: string; +}; + +export type BookmarksPage = { + items: Bookmark[]; + page: Page; +}; + +export type NewBookmark = { + manga_id: string; + chapter_id?: string | null; + page?: number | null; +}; + +export async function createBookmark(input: NewBookmark): Promise { + return request('/v1/bookmarks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input) + }); +} + +export async function deleteBookmark(id: string): Promise { + await request(`/v1/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export type ListMyOptions = { limit?: number; offset?: number }; + +export async function listMyBookmarks( + opts: ListMyOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request(`/v1/me/bookmarks${qs ? `?${qs}` : ''}`); +} + +/** + * Returns the user's bookmarks, or an empty page if they're not + * authenticated. Re-throws any non-401 error. + */ +export async function listMyBookmarksOrEmpty(): Promise { + try { + return await listMyBookmarks(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { items: [], page: { limit: 50, offset: 0, total: null } }; + } + throw e; + } +} diff --git a/frontend/src/routes/bookmarks/+page.svelte b/frontend/src/routes/bookmarks/+page.svelte new file mode 100644 index 0000000..fdb419b --- /dev/null +++ b/frontend/src/routes/bookmarks/+page.svelte @@ -0,0 +1,50 @@ + + + + Bookmarks — Mangalord + + +

Bookmarks

+ +{#if !authenticated} +

+ Sign in to see your bookmarks. +

+{:else if bookmarks.length === 0} +

No bookmarks yet.

+{:else} + +{/if} + + diff --git a/frontend/src/routes/bookmarks/+page.ts b/frontend/src/routes/bookmarks/+page.ts new file mode 100644 index 0000000..dc7a694 --- /dev/null +++ b/frontend/src/routes/bookmarks/+page.ts @@ -0,0 +1,17 @@ +import { listMyBookmarks } from '$lib/api/bookmarks'; +import { ApiError } from '$lib/api/client'; +import type { PageLoad } from './$types'; + +export const ssr = false; + +export const load: PageLoad = async () => { + try { + const page = await listMyBookmarks(); + return { bookmarks: page.items, authenticated: true }; + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { bookmarks: [], authenticated: false }; + } + throw e; + } +}; diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index e225ef2..ee26917 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -1,9 +1,41 @@ @@ -29,16 +61,24 @@ {#if manga.description}

{manga.description}

{/if} - + + {#if session.user} + + {:else} + + Sign in to bookmark + + {/if} @@ -89,7 +129,23 @@ white-space: pre-wrap; } .bookmark { + display: inline-block; margin-top: 0.5rem; + padding: 0.4rem 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + background: #fafafa; + color: inherit; + text-decoration: none; + cursor: pointer; + } + .bookmark:focus-visible { + outline: 2px solid #06f; + outline-offset: 2px; + } + .bookmark.active { + background: #ffeebb; + border-color: #d6a800; } .chapter-list { padding-left: 1.5rem; diff --git a/frontend/src/routes/manga/[id]/+page.ts b/frontend/src/routes/manga/[id]/+page.ts index bb33555..550f901 100644 --- a/frontend/src/routes/manga/[id]/+page.ts +++ b/frontend/src/routes/manga/[id]/+page.ts @@ -1,13 +1,15 @@ import { getManga } from '$lib/api/mangas'; import { listChapters } from '$lib/api/chapters'; +import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks'; import type { PageLoad } from './$types'; export const ssr = false; export const load: PageLoad = async ({ params }) => { - const [manga, chapters] = await Promise.all([ + const [manga, chapters, bookmarks] = await Promise.all([ getManga(params.id), - listChapters(params.id) + listChapters(params.id), + listMyBookmarksOrEmpty() ]); - return { manga, chapters: chapters.items }; + return { manga, chapters: chapters.items, bookmarks: bookmarks.items }; };