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 { 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); }