//! Integration tests for the page-level collection endpoints: //! //! - `POST /v1/collections/:id/pages` //! - `DELETE /v1/collections/:id/pages/:page_id` //! - `GET /v1/collections/:id/pages` //! - `GET /v1/pages/:id/my-collections` //! //! These exercise migration 0023's `collection_pages` table end-to-end, //! plus the owner-only / 404-non-existence-leak / idempotency / cascade //! invariants that mirror `api_collections.rs`. 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_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); common::body_json(resp).await } /// Create a chapter with a single page so we have a real `pages.id` to /// attach to. Returns `(chapter_id, page_id)`. async fn seed_chapter_with_page( app: &axum::Router, cookie: &str, manga_id: Uuid, number: i32, ) -> (String, String) { let resp = app .clone() .oneshot(common::post_multipart_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters"), MultipartBuilder::new() .add_json("metadata", json!({ "number": number })) .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let chapter = common::body_json(resp).await; let chapter_id = chapter["id"].as_str().unwrap().to_string(); let resp = app .clone() .oneshot(common::get_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"), cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; let page_id = body["pages"][0]["id"].as_str().unwrap().to_string(); (chapter_id, page_id) } fn id_of(v: &Value) -> String { v["id"].as_str().unwrap().to_string() } #[sqlx::test(migrations = "./migrations")] async fn add_page_then_list_returns_breadcrumb(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 (chapter_id, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; let coll = create_collection(&h.app, &cookie, "Favorite panels").await; let coll_id = id_of(&coll); let resp = h .app .clone() .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/pages"), json!({ "page_id": page_id }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/collections/{coll_id}/pages"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; let items = body["items"].as_array().unwrap(); assert_eq!(items.len(), 1); let item = &items[0]; assert_eq!(item["page_id"], page_id); assert_eq!(item["chapter_id"], chapter_id); assert_eq!(item["manga_id"], manga_id.to_string()); assert_eq!(item["manga_title"], "Berserk"); assert_eq!(item["chapter_number"], 1); assert_eq!(item["page_number"], 1); assert!( item["storage_key"].as_str().unwrap().starts_with(&format!( "mangas/{manga_id}/chapters/{chapter_id}/pages/" )), "unexpected storage_key: {}", item["storage_key"] ); } #[sqlx::test(migrations = "./migrations")] async fn add_page_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, "M").await; let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; let coll = create_collection(&h.app, &cookie, "C").await; let coll_id = id_of(&coll); let req = || { common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/pages"), json!({ "page_id": page_id }), &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_page_returns_404_when_page_missing(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 coll_id = id_of(&coll); let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_id}/pages"), json!({ "page_id": Uuid::new_v4().to_string() }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn add_page_to_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 manga_id = common::seed_manga_via_api(&h.app, &b, "M").await; let (_, page_id) = seed_chapter_with_page(&h.app, &b, manga_id, 1).await; let coll_a = create_collection(&h.app, &a, "A's").await; let coll_a_id = id_of(&coll_a); let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/collections/{coll_a_id}/pages"), json!({ "page_id": page_id }), &b, )) .await .unwrap(); // Same non-leak semantic as the manga-level handler. assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn remove_page_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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).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}/pages"), json!({ "page_id": page_id }), &cookie, )) .await .unwrap(); let first = h .app .clone() .oneshot(common::delete_with_cookie( &format!("/api/v1/collections/{coll_id}/pages/{page_id}"), &cookie, )) .await .unwrap(); assert_eq!(first.status(), StatusCode::NO_CONTENT); let second = h .app .oneshot(common::delete_with_cookie( &format!("/api/v1/collections/{coll_id}/pages/{page_id}"), &cookie, )) .await .unwrap(); assert_eq!(second.status(), StatusCode::NO_CONTENT); } #[sqlx::test(migrations = "./migrations")] async fn my_collections_for_page_lists_only_owned(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, "M").await; let (_, page_id) = seed_chapter_with_page(&h.app, &a, manga_id, 1).await; let a_coll = create_collection(&h.app, &a, "A").await; let b_coll = create_collection(&h.app, &b, "B").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}/pages"), json!({ "page_id": page_id }), cookie, )) .await .unwrap(); } let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/pages/{page_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 my_collections_for_unknown_page_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/pages/{}/my-collections", Uuid::new_v4()), &cookie, )) .await .unwrap(); 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 add_page_requires_authentication(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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; let coll = create_collection(&h.app, &cookie, "C").await; let coll_id = id_of(&coll); let resp = h .app .oneshot(common::post_json( &format!("/api/v1/collections/{coll_id}/pages"), json!({ "page_id": page_id }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn list_pages_in_others_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 coll_id = id_of(&coll); let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/collections/{coll_id}/pages"), &b, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); }