fix: make bookmark add idempotent instead of 409
Re-adding an already-bookmarked manga returned 409, which the UI surfaced as a false "Could not add bookmark" toast — collections are idempotent, bookmarks weren't. On a unique violation, fetch and return the existing bookmark, and answer 200 (vs 201 for a fresh insert). The frontend toggle already treats a successful POST as done, so the false toast disappears with no client change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,7 @@ async fn create_then_list_returns_only_own(pool: PgPool) {
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_returns_409_on_duplicate_manga_level(pool: PgPool) {
|
||||
async fn create_is_idempotent_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;
|
||||
@@ -64,12 +64,26 @@ async fn create_returns_409_on_duplicate_manga_level(pool: PgPool) {
|
||||
&cookie,
|
||||
)
|
||||
};
|
||||
// First add creates (201); re-adding is an idempotent no-op (200) that
|
||||
// returns the SAME bookmark rather than a 409 — collections behave this way
|
||||
// too, so the UI shouldn't surface a false "Could not add bookmark" error.
|
||||
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");
|
||||
let first_body = common::body_json(first).await;
|
||||
|
||||
let second = h.app.clone().oneshot(make()).await.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
let second_body = common::body_json(second).await;
|
||||
assert_eq!(second_body["id"], first_body["id"], "same bookmark returned");
|
||||
|
||||
// Exactly one row exists.
|
||||
let list = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(list).await;
|
||||
assert_eq!(body["items"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
@@ -225,13 +239,16 @@ async fn concurrent_manga_bookmarks_serialised_by_unique_index(pool: PgPool) {
|
||||
|
||||
let (s1, s2) = tokio::join!(f1, f2);
|
||||
let statuses = [s1.unwrap(), s2.unwrap()];
|
||||
// The unique index serialises the two inserts: one wins with 201, the other
|
||||
// hits the violation and — now idempotent — returns the existing bookmark
|
||||
// with 200 rather than a 409.
|
||||
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:?}"
|
||||
statuses.contains(&StatusCode::OK),
|
||||
"expected the loser to return 200 (idempotent), got {statuses:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user