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 }) } /// Collect the `title` field of every item in a list response, in order. fn title_list(body: &serde_json::Value) -> Vec<&str> { body["items"] .as_array() .unwrap() .iter() .map(|m| m["title"].as_str().unwrap()) .collect() } /// Create a manga via the upload API, asserting it succeeded. async fn seed(app: &axum::Router, cookie: &str, title: &str) { let resp = app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", json!({ "title": title })), cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED, "seed({title}) failed"); } /// Pin a manga's `created_at` to an explicit RFC3339 instant so time-based /// ordering assertions don't depend on insert timing. async fn set_created_at(pool: &PgPool, title: &str, when: &str) { let ts: chrono::DateTime = when.parse().unwrap(); sqlx::query("UPDATE mangas SET created_at = $1 WHERE title = $2") .bind(ts) .bind(title) .execute(pool) .await .unwrap(); } /// Pin a manga's `updated_at` to an explicit RFC3339 instant. async fn set_updated_at(pool: &PgPool, title: &str, when: &str) { let ts: chrono::DateTime = when.parse().unwrap(); sqlx::query("UPDATE mangas SET updated_at = $1 WHERE title = $2") .bind(ts) .bind(title) .execute(pool) .await .unwrap(); } #[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 list_total_is_computed_only_on_the_first_page(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for title in ["One Piece", "Berserk", "Vinland Saga"] { seed(&h.app, &cookie, title).await; } // Page 1 (offset 0): total is the full population. let body0 = common::body_json( h.app .clone() .oneshot(common::get("/api/v1/mangas?limit=2&offset=0")) .await .unwrap(), ) .await; assert_eq!(body0["page"]["total"], 3); // Page 2 (offset 2): total is omitted (null) — the correlated count is // not recomputed on every page. Items still paginate correctly. let body1 = common::body_json( h.app .oneshot(common::get("/api/v1/mangas?limit=2&offset=2")) .await .unwrap(), ) .await; assert!( body1["page"]["total"].is_null(), "total should be null past the first page, got {}", body1["page"]["total"] ); assert_eq!(body1["items"].as_array().unwrap().len(), 1); } #[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(); } // Title sort now requires an explicit direction; the wire default is // `desc`, so A→Z needs `order=asc`. let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=title&order=asc")) .await .unwrap(); let body = common::body_json(resp).await; let titles = title_list(&body); assert_eq!(titles, vec!["Berserk", "One Piece", "Vinland Saga"]); } #[sqlx::test(migrations = "./migrations")] async fn list_sort_title_desc_orders_reverse_alphabetically(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for title in ["Vinland Saga", "Berserk", "One Piece"] { seed(&h.app, &cookie, title).await; } let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=title&order=desc")) .await .unwrap(); let body = common::body_json(resp).await; let titles = title_list(&body); assert_eq!(titles, vec!["Vinland Saga", "One Piece", "Berserk"]); } #[sqlx::test(migrations = "./migrations")] async fn list_default_sort_is_updated_desc(pool: PgPool) { let h = common::harness(pool.clone()); let (_, cookie) = common::register_user(&h.app).await; for title in ["Alpha", "Bravo", "Charlie"] { seed(&h.app, &cookie, title).await; } // Pin explicit, distinct update times so the default ordering is // deterministic regardless of insert timing. Bravo is the most recently // updated and must appear first under the default (updated DESC). set_updated_at(&pool, "Alpha", "2020-01-01T00:00:00Z").await; set_updated_at(&pool, "Bravo", "2023-01-01T00:00:00Z").await; set_updated_at(&pool, "Charlie", "2021-01-01T00:00:00Z").await; // No sort/order params — exercises the default. let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap(); let body = common::body_json(resp).await; let titles = title_list(&body); assert_eq!(titles, vec!["Bravo", "Charlie", "Alpha"]); } #[sqlx::test(migrations = "./migrations")] async fn list_sort_created_orders_by_creation_time(pool: PgPool) { let h = common::harness(pool.clone()); let (_, cookie) = common::register_user(&h.app).await; for title in ["Alpha", "Bravo", "Charlie"] { seed(&h.app, &cookie, title).await; } set_created_at(&pool, "Alpha", "2020-01-01T00:00:00Z").await; set_created_at(&pool, "Bravo", "2023-01-01T00:00:00Z").await; set_created_at(&pool, "Charlie", "2021-01-01T00:00:00Z").await; // Newest-created first. let resp = h .app .clone() .oneshot(common::get("/api/v1/mangas?sort=created&order=desc")) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(title_list(&body), vec!["Bravo", "Charlie", "Alpha"]); // Ascending is the exact reverse. let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=created&order=asc")) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(title_list(&body), vec!["Alpha", "Charlie", "Bravo"]); } #[sqlx::test(migrations = "./migrations")] async fn list_sort_author_orders_by_author_name_nulls_last(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; // Author order is deliberately scrambled relative to title order so the // assertion can't pass by accidentally sorting on title. for (title, authors) in [ ("Akira", json!(["Beta Author"])), ("Boku", json!(["Alpha Author"])), ("Chi", json!(["Gamma Author"])), ("NoAuthor", json!([])), ] { let _ = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new() .add_json("metadata", json!({ "title": title, "authors": authors })), &cookie, )) .await .unwrap(); } // Ascending by author name; the authorless manga sorts last (NULLS LAST). let resp = h .app .clone() .oneshot(common::get("/api/v1/mangas?sort=author&order=asc")) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(title_list(&body), vec!["Boku", "Akira", "Chi", "NoAuthor"]); // Descending flips the authored mangas but keeps the authorless one last. let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=author&order=desc")) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(title_list(&body), vec!["Chi", "Akira", "Boku", "NoAuthor"]); } #[sqlx::test(migrations = "./migrations")] async fn list_sort_recent_alias_maps_to_created_desc(pool: PgPool) { // `recent` was the pre-0.88 sort value (newest created first). It is kept // as an alias for `created` so existing bots/scripts don't break. let h = common::harness(pool.clone()); let (_, cookie) = common::register_user(&h.app).await; for title in ["Alpha", "Bravo", "Charlie"] { seed(&h.app, &cookie, title).await; } set_created_at(&pool, "Alpha", "2020-01-01T00:00:00Z").await; set_created_at(&pool, "Bravo", "2023-01-01T00:00:00Z").await; set_created_at(&pool, "Charlie", "2021-01-01T00:00:00Z").await; let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=recent")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; // Same ordering as `sort=created&order=desc`: newest-created first. assert_eq!(title_list(&body), vec!["Bravo", "Charlie", "Alpha"]); } #[sqlx::test(migrations = "./migrations")] async fn list_sort_field_defaults_direction_per_field(pool: PgPool) { // When `order` is omitted, the API applies the field's natural direction: // text fields ascend (A→Z), matching the frontend's `defaultOrderFor`, so // a bare `?sort=title` link reads the same in the UI and over the wire. let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for title in ["Vinland Saga", "Berserk", "One Piece"] { seed(&h.app, &cookie, title).await; } let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=title")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(title_list(&body), vec!["Berserk", "One Piece", "Vinland Saga"]); } #[sqlx::test(migrations = "./migrations")] async fn list_sort_author_default_is_ascending_nulls_last(pool: PgPool) { // The other text field: a bare `?sort=author` must default to ascending and // keep authorless mangas last (NULLS LAST), exercising the omitted-order // path through the author subquery — not just `title`. let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for (title, authors) in [ ("Akira", json!(["Beta Author"])), ("Boku", json!(["Alpha Author"])), ("NoAuthor", json!([])), ] { let _ = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new() .add_json("metadata", json!({ "title": title, "authors": authors })), &cookie, )) .await .unwrap(); } let resp = h .app .oneshot(common::get("/api/v1/mangas?sort=author")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; // Alpha Author (Boku) < Beta Author (Akira); authorless sorts last. assert_eq!(title_list(&body), vec!["Boku", "Akira", "NoAuthor"]); } #[sqlx::test(migrations = "./migrations")] async fn list_rejects_invalid_sort_and_order_with_envelope(pool: PgPool) { let h = common::harness(pool); // Unknown sort field → structured 422, not axum's plain-text 400. let resp = h .app .clone() .oneshot(common::get("/api/v1/mangas?sort=bogus")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["details"]["sort"], "invalid"); // Unknown order → same treatment. let resp = h .app .oneshot(common::get("/api/v1/mangas?order=sideways")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["details"]["order"], "invalid"); } /// Pin a manga's primary key to an explicit UUID. Safe only for freshly /// seeded mangas with no chapters/authors/tags referencing them. async fn set_id(pool: &PgPool, title: &str, id: &str) { let uuid: uuid::Uuid = id.parse().unwrap(); sqlx::query("UPDATE mangas SET id = $1 WHERE title = $2") .bind(uuid) .bind(title) .execute(pool) .await .unwrap(); } #[sqlx::test(migrations = "./migrations")] async fn list_tie_break_orders_equal_keys_by_ascending_id(pool: PgPool) { // The trailing `, id` in ORDER BY is what keeps pagination stable when the // primary sort key ties. Pin three mangas to the SAME created_at and assign // ids whose ascending order (Bravo < Charlie < Alpha) deliberately differs // from insertion/physical order, so the expected ordering can ONLY come // from the id tie-break. // // We sort by `created` on purpose: `mangas_created_at_idx` is `(created_at // DESC)` with no id, so it does NOT encode the secondary order — unlike the // default `updated` path, whose `mangas_updated_at_idx (updated_at DESC, // id)` would mask a missing clause. Drop `, id` from the query and this // test fails. let h = common::harness(pool.clone()); let (_, cookie) = common::register_user(&h.app).await; for title in ["Alpha", "Bravo", "Charlie"] { seed(&h.app, &cookie, title).await; set_created_at(&pool, title, "2022-01-01T00:00:00Z").await; } set_id(&pool, "Alpha", "33333333-3333-4333-8333-333333333333").await; set_id(&pool, "Bravo", "11111111-1111-4111-8111-111111111111").await; set_id(&pool, "Charlie", "22222222-2222-4222-8222-222222222222").await; let page = |limit: i64, offset: i64| { let app = h.app.clone(); async move { let resp = app .oneshot(common::get(&format!( "/api/v1/mangas?sort=created&limit={limit}&offset={offset}" ))) .await .unwrap(); common::body_json(resp).await } }; // Single page: tied rows come back in ascending-id order. let full = page(50, 0).await; assert_eq!(title_list(&full), vec!["Bravo", "Charlie", "Alpha"]); // Paginating one row at a time reproduces that exact sequence — no overlap, // no gap — which only holds because the id tie-break makes the order total. let paged: Vec = [page(1, 0).await, page(1, 1).await, page(1, 2).await] .iter() .map(|b| b["items"][0]["title"].as_str().unwrap().to_string()) .collect(); assert_eq!(paged, vec!["Bravo", "Charlie", "Alpha"]); } #[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"); }