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>
183 lines
6.9 KiB
Rust
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);
|
|
}
|