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>
438 lines
14 KiB
Rust
438 lines
14 KiB
Rust
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_rejects_non_positive_page_with_422(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(), "page": 0 }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "validation_failed");
|
|
assert!(body["error"]["details"]["page"].is_string());
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/bookmarks",
|
|
json!({ "manga_id": manga_id.to_string(), "page": -1 }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
#[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 concurrent_manga_bookmarks_serialised_by_unique_index(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 app_a = h.app.clone();
|
|
let cookie_a = cookie.clone();
|
|
let mid_a = manga_id.to_string();
|
|
let app_b = h.app.clone();
|
|
let cookie_b = cookie.clone();
|
|
let mid_b = manga_id.to_string();
|
|
|
|
let f1 = tokio::spawn(async move {
|
|
app_a
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/bookmarks",
|
|
json!({ "manga_id": mid_a }),
|
|
&cookie_a,
|
|
))
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
});
|
|
let f2 = tokio::spawn(async move {
|
|
app_b
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/bookmarks",
|
|
json!({ "manga_id": mid_b }),
|
|
&cookie_b,
|
|
))
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
});
|
|
|
|
let (s1, s2) = tokio::join!(f1, f2);
|
|
let statuses = [s1.unwrap(), s2.unwrap()];
|
|
assert!(
|
|
statuses.contains(&StatusCode::CREATED),
|
|
"expected one winner with 201, got {statuses:?}"
|
|
);
|
|
assert!(
|
|
statuses.contains(&StatusCode::CONFLICT),
|
|
"expected one loser with 409 (the partial unique index), got {statuses:?}"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn bookmark_create_accepts_bearer_token(pool: PgPool) {
|
|
// Bot scripts use Authorization: Bearer; cover that path on a
|
|
// *write* endpoint to make sure CurrentUser resolves identically
|
|
// whether the credential is a cookie or a bearer.
|
|
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/auth/tokens",
|
|
json!({ "name": "ci-bot" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let bearer = common::body_json(resp).await["bearer"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_bearer(
|
|
"/api/v1/bookmarks",
|
|
json!({ "manga_id": manga_id.to_string() }),
|
|
&bearer,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
}
|
|
|
|
#[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_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
|
// Seed a chapter directly so we know its number without uploading pages.
|
|
mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand"))
|
|
.await
|
|
.unwrap();
|
|
// Look up its id so we can bookmark it.
|
|
let chapter_id: Uuid = sqlx::query_scalar(
|
|
"SELECT id FROM chapters WHERE manga_id = $1 AND number = $2",
|
|
)
|
|
.bind(manga_id)
|
|
.bind(7_i32)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Bookmark the chapter at page 4.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/bookmarks",
|
|
json!({
|
|
"manga_id": manga_id.to_string(),
|
|
"chapter_id": chapter_id.to_string(),
|
|
"page": 4
|
|
}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
// Also bookmark the manga at the manga level — chapter_number must
|
|
// come back null for it, populated for the chapter one.
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/bookmarks",
|
|
json!({ "manga_id": manga_id.to_string() }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
let items = body["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
|
|
let chapter_bookmark = items
|
|
.iter()
|
|
.find(|b| !b["chapter_id"].is_null())
|
|
.expect("chapter-level bookmark present");
|
|
assert_eq!(chapter_bookmark["chapter_number"], 7);
|
|
assert_eq!(chapter_bookmark["page"], 4);
|
|
// Manga title is JOINed in too so the /bookmarks page can render
|
|
// a real card instead of "Manga bookmark".
|
|
assert_eq!(chapter_bookmark["manga_title"], "Berserk");
|
|
// No cover was uploaded for the seeded manga — null is correct.
|
|
assert!(chapter_bookmark["manga_cover_image_path"].is_null());
|
|
|
|
let manga_bookmark = items
|
|
.iter()
|
|
.find(|b| b["chapter_id"].is_null())
|
|
.expect("manga-level bookmark present");
|
|
assert!(
|
|
manga_bookmark["chapter_number"].is_null(),
|
|
"manga-level bookmark must have a null chapter_number"
|
|
);
|
|
assert_eq!(manga_bookmark["manga_title"], "Berserk");
|
|
}
|
|
|
|
#[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());
|
|
}
|