mod common; use axum::http::StatusCode; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use common::MultipartBuilder; fn metadata(title: &str) -> serde_json::Value { json!({ "title": title }) } #[sqlx::test(migrations = "./migrations")] async fn list_is_empty_initially(pool: PgPool) { let h = common::harness(pool); let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["items"], json!([])); assert_eq!(body["page"]["limit"], 50); assert_eq!(body["page"]["offset"], 0); assert_eq!(body["page"]["total"], 0); } #[sqlx::test(migrations = "./migrations")] async fn list_returns_total_count_independent_of_pagination(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for title in ["One Piece", "Berserk", "Vinland Saga"] { let _ = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", json!({ "title": title })), &cookie, )) .await .unwrap(); } let resp = h .app .oneshot(common::get("/api/v1/mangas?limit=2")) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(body["items"].as_array().unwrap().len(), 2); // Total reflects the unfiltered population, not the page size. assert_eq!(body["page"]["total"], 3); } #[sqlx::test(migrations = "./migrations")] async fn search_via_trigram_tolerates_typos(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let _ = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", json!({ "title": "Naruto" })), &cookie, )) .await .unwrap(); // 'narto' is one letter off — the % operator on the GIN trgm index // should still match it. let resp = h .app .oneshot(common::get("/api/v1/mangas?search=narto")) .await .unwrap(); let body = common::body_json(resp).await; let titles: Vec<&str> = body["items"] .as_array() .unwrap() .iter() .map(|m| m["title"].as_str().unwrap()) .collect(); assert_eq!(titles, vec!["Naruto"]); assert_eq!(body["page"]["total"], 1); } #[sqlx::test(migrations = "./migrations")] async fn list_sort_title_orders_alphabetically(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for title in ["Vinland Saga", "Berserk", "One Piece"] { let _ = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", json!({ "title": title })), &cookie, )) .await .unwrap(); } let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=title")) .await .unwrap(); let body = common::body_json(resp).await; let titles: Vec<&str> = body["items"] .as_array() .unwrap() .iter() .map(|m| m["title"].as_str().unwrap()) .collect(); assert_eq!(titles, vec!["Berserk", "One Piece", "Vinland Saga"]); } #[sqlx::test(migrations = "./migrations")] async fn search_reflects_filtered_total(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for title in ["One Piece", "Berserk", "Vinland Saga"] { let _ = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", json!({ "title": title })), &cookie, )) .await .unwrap(); } let resp = h .app .oneshot(common::get("/api/v1/mangas?search=berserk")) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(body["items"].as_array().unwrap().len(), 1); assert_eq!(body["page"]["total"], 1); } #[sqlx::test(migrations = "./migrations")] async fn create_then_list_roundtrip(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let created = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json( "metadata", json!({ "title": "Berserk", "authors": ["Kentaro Miura"], "description": null, }), ), &cookie, )) .await .unwrap(); assert_eq!(created.status(), StatusCode::CREATED); let body = common::body_json(created).await; assert_eq!(body["title"], "Berserk"); let authors = body["authors"].as_array().unwrap(); assert_eq!(authors.len(), 1); assert_eq!(authors[0]["name"], "Kentaro Miura"); assert!(body["id"].as_str().is_some()); // Status defaults to ongoing; tags and genres start empty. assert_eq!(body["status"], "ongoing"); assert_eq!(body["genres"], json!([])); assert_eq!(body["tags"], json!([])); let listed = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap(); let listed_body = common::body_json(listed).await; let items = listed_body["items"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["title"], "Berserk"); // List endpoint returns the card shape: authors + genres included. let authors = items[0]["authors"].as_array().unwrap(); assert_eq!(authors[0]["name"], "Kentaro Miura"); } #[sqlx::test(migrations = "./migrations")] async fn search_filters_by_title_and_author(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for (title, author) in [ ("One Piece", "Eiichiro Oda"), ("Berserk", "Kentaro Miura"), ("Vinland Saga", "Makoto Yukimura"), ] { let _ = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new() .add_json("metadata", json!({ "title": title, "authors": [author] })), &cookie, )) .await .unwrap(); } // Searching by author name still works — the list query joins // authors so 'miura' resolves through the manga_authors table. let resp = h .app .clone() .oneshot(common::get("/api/v1/mangas?search=miura")) .await .unwrap(); let body = common::body_json(resp).await; let titles: Vec<&str> = body["items"] .as_array() .unwrap() .iter() .map(|m| m["title"].as_str().unwrap()) .collect(); assert_eq!(titles, vec!["Berserk"]); let resp = h .app .oneshot(common::get("/api/v1/mangas?search=saga")) .await .unwrap(); let body = common::body_json(resp).await; let titles: Vec<&str> = body["items"] .as_array() .unwrap() .iter() .map(|m| m["title"].as_str().unwrap()) .collect(); assert_eq!(titles, vec!["Vinland Saga"]); } #[sqlx::test(migrations = "./migrations")] async fn create_rejects_empty_title_with_validation_failed(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", metadata(" ")), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "validation_failed"); assert!(body["error"]["details"]["title"].is_string()); } #[sqlx::test(migrations = "./migrations")] async fn create_rejects_missing_metadata_part(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new(), // no metadata part &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "validation_failed"); assert_eq!(body["error"]["details"]["metadata"], "required"); } #[sqlx::test(migrations = "./migrations")] async fn create_requires_authentication(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::post_multipart( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", metadata("Berserk")), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "unauthenticated"); } #[sqlx::test(migrations = "./migrations")] async fn get_unknown_id_is_404_with_envelope(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::get( "/api/v1/mangas/00000000-0000-0000-0000-000000000000", )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "not_found"); let msg = body["error"]["message"].as_str().expect("message is string"); assert!(!msg.is_empty(), "message should be non-empty"); } // ---------------------------------------------------------------------------- // GET /v1/mangas/:id/similar — recommendation by tag overlap (Jaccard). // ---------------------------------------------------------------------------- /// Attach a tag to a manga via the public API. Asserts the call succeeds /// (201 created or 200 already-attached) so test setup failures surface loudly. async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: uuid::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() == StatusCode::CREATED || resp.status() == StatusCode::OK, "attach_tag({name}) failed: {}", resp.status() ); } fn ids(body: &serde_json::Value) -> Vec { body["items"] .as_array() .unwrap() .iter() .map(|m| m["id"].as_str().unwrap().to_string()) .collect() } #[sqlx::test(migrations = "./migrations")] async fn similar_ranks_by_jaccard(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let a = common::seed_manga_via_api(&h.app, &cookie, "Source").await; let b = common::seed_manga_via_api(&h.app, &cookie, "Perfect Match").await; let c = common::seed_manga_via_api(&h.app, &cookie, "Tight Subset").await; let d = common::seed_manga_via_api(&h.app, &cookie, "Broad Overlap").await; let e = common::seed_manga_via_api(&h.app, &cookie, "No Overlap").await; // Source A: t1..t4 for t in ["t1", "t2", "t3", "t4"] { attach_tag(&h.app, &cookie, a, t).await; } // B shares all 4 -> Jaccard 4/4 = 1.0 for t in ["t1", "t2", "t3", "t4"] { attach_tag(&h.app, &cookie, b, t).await; } // C has t1,t2 -> shares 2 -> Jaccard 2/4 = 0.5 for t in ["t1", "t2"] { attach_tag(&h.app, &cookie, c, t).await; } // D has t1..t8 -> shares 4 -> Jaccard 4/8 = 0.5 (tie with C; loses on shared-count tie-break) for t in ["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"] { attach_tag(&h.app, &cookie, d, t).await; } // E disjoint -> excluded for t in ["z1", "z2"] { attach_tag(&h.app, &cookie, e, t).await; } let resp = h .app .oneshot(common::get(&format!("/api/v1/mangas/{a}/similar"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; let got = ids(&body); let b = b.to_string(); let c = c.to_string(); let d = d.to_string(); // B (1.0) first; then C and D both 0.5 but C wins the shared-count tie-break (D shares 4 too, // wait — D shares 4, C shares 2). Tie-break is more-shared-first, so D precedes C. assert_eq!(got, vec![b, d, c], "ranked B(1.0), D(0.5,4 shared), C(0.5,2 shared)"); // Self and the disjoint manga never appear. assert!(!got.contains(&a.to_string())); assert!(!got.contains(&e.to_string())); // Cards carry the enriched shape. assert!(body["items"][0]["authors"].is_array()); assert!(body["items"][0]["genres"].is_array()); } #[sqlx::test(migrations = "./migrations")] async fn similar_caps_at_five_and_excludes_self(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await; attach_tag(&h.app, &cookie, source, "shared").await; for i in 0..7 { let m = common::seed_manga_via_api(&h.app, &cookie, &format!("Neighbor {i}")).await; attach_tag(&h.app, &cookie, m, "shared").await; } let resp = h .app .oneshot(common::get(&format!("/api/v1/mangas/{source}/similar"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; let got = ids(&body); assert_eq!(got.len(), 5, "capped at 5"); assert!(!got.contains(&source.to_string()), "self excluded"); } #[sqlx::test(migrations = "./migrations")] async fn similar_empty_when_no_tags(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let source = common::seed_manga_via_api(&h.app, &cookie, "Untagged").await; let resp = h .app .oneshot(common::get(&format!("/api/v1/mangas/{source}/similar"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["items"], json!([])); } #[sqlx::test(migrations = "./migrations")] async fn similar_empty_when_no_overlap(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await; let other = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await; attach_tag(&h.app, &cookie, source, "alpha").await; attach_tag(&h.app, &cookie, other, "beta").await; let resp = h .app .oneshot(common::get(&format!("/api/v1/mangas/{source}/similar"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["items"], json!([])); } #[sqlx::test(migrations = "./migrations")] async fn similar_404_for_unknown_manga(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::get(&format!( "/api/v1/mangas/{}/similar", uuid::Uuid::new_v4() ))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "not_found"); }