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>
134 lines
4.5 KiB
Rust
134 lines
4.5 KiB
Rust
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);
|
|
}
|