feat(reactions): per-user like/dislike on mangas

Add a private per-user reaction resource: a manga_reactions table (one row
per user+manga, like/dislike, cascades) and endpoints PUT/DELETE
/mangas/:id/reaction plus GET /me/reactions/:manga_id, wired on the existing
per-user seam (mirrors bookmarks/read_progress). Unknown manga → 404 via the
FK-violation mapping; an invalid reaction value → 422. Reactions are never
exposed publicly — this is a taste signal for the upcoming content-based
recommendations. Integration tests cover set/toggle/clear/read, per-user
isolation, and the 404/422/401 paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-05 16:53:59 +02:00
parent 9c4a93c058
commit 258a536254
12 changed files with 332 additions and 5 deletions

View File

@@ -0,0 +1,133 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
async fn put_reaction(
app: &axum::Router,
cookie: &str,
manga_id: Uuid,
reaction: &str,
) -> StatusCode {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/reaction"),
json!({ "reaction": reaction }),
cookie,
))
.await
.unwrap();
resp.status()
}
async fn get_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid) -> serde_json::Value {
let resp = app
.clone()
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/reactions/{manga_id}"),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
common::body_json(resp).await
}
#[sqlx::test(migrations = "./migrations")]
async fn put_creates_and_reads_back(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;
assert_eq!(put_reaction(&h.app, &cookie, manga_id, "like").await, StatusCode::OK);
let body = get_reaction(&h.app, &cookie, manga_id).await;
assert_eq!(body["reaction"], "like");
assert_eq!(body["manga_id"], manga_id.to_string());
}
#[sqlx::test(migrations = "./migrations")]
async fn put_toggles_like_to_dislike(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 _ = put_reaction(&h.app, &cookie, manga_id, "like").await;
assert_eq!(put_reaction(&h.app, &cookie, manga_id, "dislike").await, StatusCode::OK);
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], "dislike");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_clears_the_reaction(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 _ = put_reaction(&h.app, &cookie, manga_id, "like").await;
let resp = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/mangas/{manga_id}/reaction"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], serde_json::Value::Null);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_unset_returns_null(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;
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], serde_json::Value::Null);
}
#[sqlx::test(migrations = "./migrations")]
async fn reactions_are_per_user(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &a, "Berserk").await;
let _ = put_reaction(&h.app, &a, manga_id, "like").await;
// B sees no reaction of their own.
assert_eq!(get_reaction(&h.app, &b, manga_id).await["reaction"], serde_json::Value::Null);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_unknown_manga_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
assert_eq!(
put_reaction(&h.app, &cookie, Uuid::new_v4(), "like").await,
StatusCode::NOT_FOUND
);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_invalid_reaction_is_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;
assert_eq!(
put_reaction(&h.app, &cookie, manga_id, "meh").await,
StatusCode::UNPROCESSABLE_ENTITY
);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.clone()
.oneshot(common::get(&format!("/api/v1/me/reactions/{}", Uuid::new_v4())))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}