mod common; use axum::http::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use common::MultipartBuilder; async fn create_manga( app: &axum::Router, cookie: &str, metadata: Value, ) -> Value { let resp = app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new().add_json("metadata", metadata), cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); common::body_json(resp).await } fn first_author_id(manga: &Value) -> String { manga["authors"][0]["id"].as_str().unwrap().to_string() } #[sqlx::test(migrations = "./migrations")] async fn get_returns_name_and_manga_count(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let m1 = create_manga( &h.app, &cookie, json!({ "title": "Berserk", "authors": ["Kentaro Miura"] }), ) .await; // A second manga by the same author bumps the count to 2. let _ = create_manga( &h.app, &cookie, json!({ "title": "Berserk Prelude", "authors": ["Kentaro Miura"] }), ) .await; let author_id = first_author_id(&m1); let resp = h .app .oneshot(common::get(&format!("/api/v1/authors/{author_id}"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["id"], author_id); assert_eq!(body["name"], "Kentaro Miura"); assert_eq!(body["manga_count"], 2); } #[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(&format!("/api/v1/authors/{}", 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"); } #[sqlx::test(migrations = "./migrations")] async fn list_mangas_returns_only_works_by_that_author(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let m_miura = create_manga( &h.app, &cookie, json!({ "title": "Berserk", "authors": ["Kentaro Miura"] }), ) .await; let _ = create_manga( &h.app, &cookie, json!({ "title": "One Piece", "authors": ["Eiichiro Oda"] }), ) .await; let _ = create_manga( &h.app, &cookie, json!({ "title": "Berserk Prelude", "authors": ["Kentaro Miura"] }), ) .await; let author_id = first_author_id(&m_miura); let resp = h .app .oneshot(common::get(&format!("/api/v1/authors/{author_id}/mangas"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); 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(); // Sorted by created_at DESC — Berserk Prelude was created last. assert_eq!(titles, vec!["Berserk Prelude", "Berserk"]); assert_eq!(body["page"]["total"], 2); } #[sqlx::test(migrations = "./migrations")] async fn list_mangas_paginates(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let mut author_id = String::new(); for i in 0..5 { let body = create_manga( &h.app, &cookie, json!({ "title": format!("Vol {i}"), "authors": ["Solo"] }), ) .await; if author_id.is_empty() { author_id = first_author_id(&body); } } let resp = h .app .oneshot(common::get(&format!( "/api/v1/authors/{author_id}/mangas?limit=2&offset=1" ))) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(body["items"].as_array().unwrap().len(), 2); assert_eq!(body["page"]["limit"], 2); assert_eq!(body["page"]["offset"], 1); // Total reflects unfiltered count, not page size. assert_eq!(body["page"]["total"], 5); } #[sqlx::test(migrations = "./migrations")] async fn list_mangas_for_unknown_author_returns_empty(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::get(&format!( "/api/v1/authors/{}/mangas", Uuid::new_v4() ))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["items"], json!([])); assert_eq!(body["page"]["total"], 0); } #[sqlx::test(migrations = "./migrations")] async fn search_authors_matches_substring_case_insensitively(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for name in ["Kentaro Miura", "Eiichiro Oda", "Makoto Yukimura"] { let _ = create_manga( &h.app, &cookie, json!({ "title": format!("Book of {name}"), "authors": [name] }), ) .await; } // Substring is case-insensitive (ILIKE) — "miura" picks Kentaro // Miura but NOT Makoto Yukimura (whose surname is Yukimura, not // "Yumiura"). The trigram path is exercised in api_mangas.rs. let resp = h .app .clone() .oneshot(common::get("/api/v1/authors?search=miura")) .await .unwrap(); let body = common::body_json(resp).await; let names: Vec<&str> = body .as_array() .unwrap() .iter() .map(|a| a["name"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["Kentaro Miura"]); // "yuki" matches only Makoto Yukimura. let resp = h .app .oneshot(common::get("/api/v1/authors?search=yuki")) .await .unwrap(); let body = common::body_json(resp).await; let names: Vec<&str> = body .as_array() .unwrap() .iter() .map(|a| a["name"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["Makoto Yukimura"]); } #[sqlx::test(migrations = "./migrations")] async fn search_authors_default_limit_caps_at_10(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for i in 0..15 { let _ = create_manga( &h.app, &cookie, json!({ "title": format!("Book {i}"), "authors": [format!("Author {i}")] }), ) .await; } let resp = h .app .oneshot(common::get("/api/v1/authors")) .await .unwrap(); let body = common::body_json(resp).await; // No search term: returns the default page (10), not all 15. assert_eq!(body.as_array().unwrap().len(), 10); } #[sqlx::test(migrations = "./migrations")] async fn manga_count_drops_when_manga_deleted(pool: PgPool) { let h = common::harness(pool.clone()); let (_, cookie) = common::register_user(&h.app).await; let m1 = create_manga( &h.app, &cookie, json!({ "title": "Vol 1", "authors": ["Solo"] }), ) .await; let _ = create_manga( &h.app, &cookie, json!({ "title": "Vol 2", "authors": ["Solo"] }), ) .await; let author_id = first_author_id(&m1); let manga_id = m1["id"].as_str().unwrap().to_string(); // No DELETE endpoint yet — delete via SQL to simulate cleanup. sqlx::query("DELETE FROM mangas WHERE id = $1") .bind(Uuid::parse_str(&manga_id).unwrap()) .execute(&pool) .await .unwrap(); let resp = h .app .oneshot(common::get(&format!("/api/v1/authors/{author_id}"))) .await .unwrap(); let body = common::body_json(resp).await; // FK cascade on manga_authors keeps the count honest. assert_eq!(body["manga_count"], 1); }