mod common; use axum::http::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; async fn create_collection( app: &axum::Router, cookie: &str, name: &str, ) -> Value { let resp = app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/collections", json!({ "name": name }), cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED, "create_collection failed"); common::body_json(resp).await } fn id_of(v: &Value) -> String { v["id"].as_str().unwrap().to_string() } #[sqlx::test(migrations = "./migrations")] async fn create_then_list_returns_only_own(pool: PgPool) { let h = common::harness(pool); let (_, cookie_a) = common::register_user(&h.app).await; let (_, cookie_b) = common::register_user(&h.app).await; let _favs = create_collection(&h.app, &cookie_a, "Favorites").await; let _read = create_collection(&h.app, &cookie_a, "Reading List").await; // User B sees an empty list. let resp = h .app .clone() .oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_b)) .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); // User A sees both. let resp = h .app .oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_a)) .await .unwrap(); let body = common::body_json(resp).await; let names: Vec<&str> = body["items"] .as_array() .unwrap() .iter() .map(|c| c["name"].as_str().unwrap()) .collect(); // Newest-updated first; both rows have the same updated_at on // create so we just sanity-check membership. assert_eq!(names.len(), 2); assert!(names.contains(&"Favorites")); assert!(names.contains(&"Reading List")); // Empty collections render with manga_count 0 and an empty // sample_covers array, not `null`. for item in body["items"].as_array().unwrap() { assert_eq!(item["manga_count"], 0); assert_eq!(item["sample_covers"], json!([])); } } #[sqlx::test(migrations = "./migrations")] async fn duplicate_name_for_same_user_is_409(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let _ = create_collection(&h.app, &cookie, "Favorites").await; let resp = h .app .oneshot(common::post_json_with_cookie( "/api/v1/collections", json!({ "name": "favorites" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CONFLICT); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "conflict"); } #[sqlx::test(migrations = "./migrations")] async fn two_users_can_share_a_collection_name(pool: PgPool) { let h = common::harness(pool); let (_, a) = common::register_user(&h.app).await; let (_, b) = common::register_user(&h.app).await; let _ = create_collection(&h.app, &a, "Favorites").await; // No conflict — uniqueness is per-(user_id, lower(name)). let _ = create_collection(&h.app, &b, "Favorites").await; } #[sqlx::test(migrations = "./migrations")] async fn create_requires_authentication(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::post_json( "/api/v1/collections", json!({ "name": "Anon" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn create_rejects_blank_name_with_422(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::post_json_with_cookie( "/api/v1/collections", json!({ "name": " " }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[sqlx::test(migrations = "./migrations")] async fn get_one_returns_404_for_non_owner_no_existence_leak(pool: PgPool) { let h = common::harness(pool); let (_, a) = common::register_user(&h.app).await; let (_, b) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &a, "Favorites").await; let id = id_of(&coll); // Owner-mismatch is collapsed to 404 so the API doesn't disclose // collection existence to non-owners. Otherwise an attacker could // distinguish "exists, not yours" from "doesn't exist" by status. let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/collections/{id}"), &b, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn add_manga_is_idempotent_and_picks_201_then_200(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; let coll = create_collection(&h.app, &cookie, "Favorites").await; let coll_id = id_of(&coll); let req = || { common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas"), json!({ "manga_id": manga_id.to_string() }), &cookie, ) }; let first = h.app.clone().oneshot(req()).await.unwrap(); assert_eq!(first.status(), StatusCode::CREATED); let second = h.app.oneshot(req()).await.unwrap(); assert_eq!(second.status(), StatusCode::OK); } #[sqlx::test(migrations = "./migrations")] async fn add_manga_returns_404_when_manga_missing(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &cookie, "Favorites").await; let coll_id = id_of(&coll); let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas"), json!({ "manga_id": Uuid::new_v4().to_string() }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn add_manga_to_someone_elses_collection_is_404(pool: PgPool) { let h = common::harness(pool); let (_, a) = common::register_user(&h.app).await; let (_, b) = common::register_user(&h.app).await; let coll_a = create_collection(&h.app, &a, "Mine").await; let coll_a_id = id_of(&coll_a); let manga_id = common::seed_manga_via_api(&h.app, &b, "Anything").await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_a_id}/mangas"), json!({ "manga_id": manga_id.to_string() }), &b, )) .await .unwrap(); // 404 not 403 — same non-existence-leak rationale as `get_one`. assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn patch_on_other_users_collection_is_404(pool: PgPool) { let h = common::harness(pool); let (_, a) = common::register_user(&h.app).await; let (_, b) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &a, "Mine").await; let id = id_of(&coll); let resp = h .app .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({ "name": "Hijacked" }), &b, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn patch_description_null_clears_existing_value(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &cookie, "C").await; let id = id_of(&coll); // Seed a description first via PATCH. let _ = h .app .clone() .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({ "description": "starting desc" }), &cookie, )) .await .unwrap(); // Now PATCH with description=null and expect the column cleared. let resp = h .app .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({ "description": null }), &cookie, )) .await .unwrap(); let body = common::body_json(resp).await; assert!(body["description"].is_null(), "expected description cleared"); } #[sqlx::test(migrations = "./migrations")] async fn patch_description_empty_string_sets_empty_not_null(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &cookie, "C").await; let id = id_of(&coll); let resp = h .app .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({ "description": "" }), &cookie, )) .await .unwrap(); let body = common::body_json(resp).await; // Empty string is a valid distinct value; only `null` clears. assert_eq!(body["description"], ""); } #[sqlx::test(migrations = "./migrations")] async fn patch_description_omitted_leaves_value_intact(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &cookie, "C").await; let id = id_of(&coll); let _ = h .app .clone() .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({ "description": "Keep me" }), &cookie, )) .await .unwrap(); // PATCH that doesn't mention description must not touch it. let resp = h .app .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({ "name": "Renamed" }), &cookie, )) .await .unwrap(); let body = common::body_json(resp).await; assert_eq!(body["name"], "Renamed"); assert_eq!(body["description"], "Keep me"); } #[sqlx::test(migrations = "./migrations")] async fn patch_with_empty_body_leaves_row_unchanged(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &cookie, "Stable").await; let id = id_of(&coll); let resp = h .app .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["name"], "Stable"); } #[sqlx::test(migrations = "./migrations")] async fn my_collections_for_unknown_manga_returns_empty_list(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/mangas/{}/my-collections", Uuid::new_v4()), &cookie, )) .await .unwrap(); // Non-existent manga is treated the same as a manga the user // hasn't collected — empty list. The handler comment documents // this; the test pins it. assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["collection_ids"], json!([])); } #[sqlx::test(migrations = "./migrations")] async fn list_mangas_returns_collection_contents(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let m1 = common::seed_manga_via_api(&h.app, &cookie, "First").await; let m2 = common::seed_manga_via_api(&h.app, &cookie, "Second").await; let _untagged = common::seed_manga_via_api(&h.app, &cookie, "NotInIt").await; let coll = create_collection(&h.app, &cookie, "Mix").await; let coll_id = id_of(&coll); for m in [m1, m2] { let r = h .app .clone() .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas"), json!({ "manga_id": m.to_string() }), &cookie, )) .await .unwrap(); assert_eq!(r.status(), StatusCode::CREATED); } let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas"), &cookie, )) .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(); // Newest-added first. assert_eq!(titles, vec!["Second", "First"]); assert_eq!(body["page"]["total"], 2); } #[sqlx::test(migrations = "./migrations")] async fn remove_manga_is_idempotent(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await; let coll = create_collection(&h.app, &cookie, "C").await; let coll_id = id_of(&coll); let _ = h .app .clone() .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas"), json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); let first = h .app .clone() .oneshot(common::delete_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"), &cookie, )) .await .unwrap(); assert_eq!(first.status(), StatusCode::NO_CONTENT); // Removing again is still a 204 — DELETE is idempotent. let second = h .app .oneshot(common::delete_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"), &cookie, )) .await .unwrap(); assert_eq!(second.status(), StatusCode::NO_CONTENT); } #[sqlx::test(migrations = "./migrations")] async fn my_collections_for_manga_lists_only_owned_containing(pool: PgPool) { let h = common::harness(pool); let (_, a) = common::register_user(&h.app).await; let (_, b) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &a, "X").await; let a_coll = create_collection(&h.app, &a, "A's").await; let b_coll = create_collection(&h.app, &b, "B's").await; let a_coll_id = id_of(&a_coll); let b_coll_id = id_of(&b_coll); for (coll, cookie) in [(&a_coll_id, &a), (&b_coll_id, &b)] { let _ = h .app .clone() .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll}/mangas"), json!({ "manga_id": manga_id.to_string() }), cookie, )) .await .unwrap(); } let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/mangas/{manga_id}/my-collections"), &a, )) .await .unwrap(); let body = common::body_json(resp).await; let ids: Vec<&str> = body["collection_ids"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap()) .collect(); assert_eq!(ids, vec![a_coll_id.as_str()]); } #[sqlx::test(migrations = "./migrations")] async fn patch_collection_updates_name_and_description(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let coll = create_collection(&h.app, &cookie, "Old name").await; let id = id_of(&coll); let resp = h .app .oneshot(common::patch_json_with_cookie( &format!("/api/v1/collections/{id}"), json!({ "name": "New name", "description": "Some notes" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["name"], "New name"); assert_eq!(body["description"], "Some notes"); } #[sqlx::test(migrations = "./migrations")] async fn delete_collection_cascades_attachments(pool: PgPool) { let h = common::harness(pool.clone()); let (_, cookie) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await; let coll = create_collection(&h.app, &cookie, "C").await; let coll_id = id_of(&coll); let _ = h .app .clone() .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas"), json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); let resp = h .app .oneshot(common::delete_with_cookie( &format!("/api/v1/collections/{coll_id}"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let (count,): (i64,) = sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1") .bind(Uuid::parse_str(&coll_id).unwrap()) .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 0, "collection_mangas should cascade-delete with the collection"); } #[sqlx::test(migrations = "./migrations")] async fn list_summary_carries_sample_covers_when_mangas_attached(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; // Seed a manga with a cover via the upload endpoint so the // cover_image_path column gets populated. let make_metadata = |title: &str| { common::MultipartBuilder::new() .add_json("metadata", json!({ "title": title })) .add_file("cover", "cover.png", "image/png", &common::fake_png_bytes()) }; let resp = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", make_metadata("With cover"), &cookie, )) .await .unwrap(); let body = common::body_json(resp).await; let manga_id = body["id"].as_str().unwrap().to_string(); let coll = create_collection(&h.app, &cookie, "Visual").await; let coll_id = id_of(&coll); let r = h .app .clone() .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/mangas"), json!({ "manga_id": manga_id }), &cookie, )) .await .unwrap(); assert_eq!(r.status(), StatusCode::CREATED); let resp = h .app .oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie)) .await .unwrap(); let body = common::body_json(resp).await; let item = &body["items"][0]; assert_eq!(item["manga_count"], 1); let covers = item["sample_covers"].as_array().unwrap(); assert_eq!(covers.len(), 1); assert!(covers[0] .as_str() .unwrap() .starts_with(&format!("mangas/{manga_id}/cover"))); }