Files
Mangalord/backend/tests/api_recommendations.rs
MechaCat02 d6a109df2d feat(recommendations): content-based "Recommended for you" endpoint
Add GET /me/recommendations: rank mangas by weighted tag overlap with the
user's taste — explicit like +1.0, bookmark +0.5, dislike −1.0 (reaction
overrides bookmark). Per-tag affinities are summed, candidates scored by
their tags' affinity normalized by tag count (anti-tag-stuffing, à la
list_similar), with already-reacted/bookmarked/read mangas excluded and
net-negative candidates dropped (dislike down-ranks, not browse-hides).
Reuses manga_cols/cards_from_rows. Integration tests cover like-driven recs,
dislike down-rank, bookmark half-weight ordering, seen-exclusion, empty
cold-start, and auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:42:05 +02:00

183 lines
6.9 KiB
Rust

mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: Uuid, name: &str) {
let resp = app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/tags"),
json!({ "name": name }),
cookie,
))
.await
.unwrap();
assert!(resp.status().is_success(), "attach_tag: {}", resp.status());
}
async fn set_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid, reaction: &str) {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/reaction"),
json!({ "reaction": reaction }),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
async fn bookmark(app: &axum::Router, cookie: &str, manga_id: Uuid) {
let resp = app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
async fn mark_read(app: &axum::Router, cookie: &str, manga_id: Uuid) {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
/// Recommended manga titles, in ranked order.
async fn recommend(app: &axum::Router, cookie: &str) -> Vec<String> {
let resp = app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/recommendations", cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
body["items"]
.as_array()
.unwrap()
.iter()
.map(|m| m["title"].as_str().unwrap().to_string())
.collect()
}
#[sqlx::test(migrations = "./migrations")]
async fn likes_drive_recommendations(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let similar = common::seed_manga_via_api(&h.app, &cookie, "Similar").await;
let unrelated = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await;
attach_tag(&h.app, &cookie, liked, "action").await;
attach_tag(&h.app, &cookie, similar, "action").await;
attach_tag(&h.app, &cookie, unrelated, "sports").await;
set_reaction(&h.app, &cookie, liked, "like").await;
let recs = recommend(&h.app, &cookie).await;
assert!(recs.contains(&"Similar".to_string()), "recs: {recs:?}");
assert!(!recs.contains(&"Unrelated".to_string()), "recs: {recs:?}");
// The liked manga itself is not recommended back.
assert!(!recs.contains(&"Liked".to_string()), "recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn dislike_downranks_shared_tags(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let disliked = common::seed_manga_via_api(&h.app, &cookie, "Disliked").await;
let good = common::seed_manga_via_api(&h.app, &cookie, "Good").await;
let bad = common::seed_manga_via_api(&h.app, &cookie, "Bad").await;
attach_tag(&h.app, &cookie, liked, "action").await;
attach_tag(&h.app, &cookie, good, "action").await; // shares the liked tag
attach_tag(&h.app, &cookie, disliked, "gore").await;
attach_tag(&h.app, &cookie, bad, "gore").await; // shares the disliked tag
set_reaction(&h.app, &cookie, liked, "like").await;
set_reaction(&h.app, &cookie, disliked, "dislike").await;
let recs = recommend(&h.app, &cookie).await;
assert!(recs.contains(&"Good".to_string()), "recs: {recs:?}");
// Net-negative (disliked-tag) candidate is dropped.
assert!(!recs.contains(&"Bad".to_string()), "recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn bookmark_counts_as_half_a_like(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let booked = common::seed_manga_via_api(&h.app, &cookie, "Booked").await;
let from_like = common::seed_manga_via_api(&h.app, &cookie, "FromLike").await;
let from_bookmark = common::seed_manga_via_api(&h.app, &cookie, "FromBookmark").await;
attach_tag(&h.app, &cookie, liked, "tliked").await;
attach_tag(&h.app, &cookie, from_like, "tliked").await; // affinity 1.0
attach_tag(&h.app, &cookie, booked, "tbooked").await;
attach_tag(&h.app, &cookie, from_bookmark, "tbooked").await; // affinity 0.5
set_reaction(&h.app, &cookie, liked, "like").await;
bookmark(&h.app, &cookie, booked).await;
let recs = recommend(&h.app, &cookie).await;
// Both recommended, but the like-derived one outranks the bookmark-derived.
let i_like = recs.iter().position(|t| t == "FromLike");
let i_book = recs.iter().position(|t| t == "FromBookmark");
assert!(i_like.is_some() && i_book.is_some(), "recs: {recs:?}");
assert!(i_like < i_book, "like should outrank bookmark; recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn excludes_already_seen(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let fresh = common::seed_manga_via_api(&h.app, &cookie, "Fresh").await;
let already_read = common::seed_manga_via_api(&h.app, &cookie, "AlreadyRead").await;
for m in [liked, fresh, already_read] {
attach_tag(&h.app, &cookie, m, "action").await;
}
set_reaction(&h.app, &cookie, liked, "like").await;
mark_read(&h.app, &cookie, already_read).await;
let recs = recommend(&h.app, &cookie).await;
assert!(recs.contains(&"Fresh".to_string()), "recs: {recs:?}");
assert!(!recs.contains(&"AlreadyRead".to_string()), "recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn empty_without_signals(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let m = common::seed_manga_via_api(&h.app, &cookie, "Whatever").await;
attach_tag(&h.app, &cookie, m, "action").await;
assert!(recommend(&h.app, &cookie).await.is_empty());
}
#[sqlx::test(migrations = "./migrations")]
async fn requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.clone()
.oneshot(common::get("/api/v1/me/recommendations"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}