The profile overview's bookmark counter showed 0 even when the user had bookmarks because /me/bookmarks left page.total null. Repo now returns the count alongside the rows; handler uses with_total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
441 lines
14 KiB
Rust
441 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"), None)
|
|
.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);
|
|
// `total` is the unfiltered row count, returned so callers (e.g.
|
|
// the profile overview's bookmark counter) can show a number
|
|
// without paging through.
|
|
assert_eq!(body["page"]["total"], 0);
|
|
}
|