mod common; use axum::http::StatusCode; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use common::MultipartBuilder; #[sqlx::test(migrations = "./migrations")] async fn create_manga_with_cover_stores_image(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .clone() .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new() .add_json("metadata", json!({ "title": "Berserk" })) .add_file("cover", "cover.png", "image/png", &common::fake_png_bytes()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let body = common::body_json(resp).await; let manga_id = Uuid::parse_str(body["id"].as_str().unwrap()).unwrap(); let cover_path = body["cover_image_path"] .as_str() .expect("cover_image_path set after upload"); assert_eq!(cover_path, &format!("mangas/{manga_id}/cover.png")); // The blob is reachable via the files endpoint and round-trips byte-for-byte. let file_resp = h .app .oneshot(common::get(&format!("/api/v1/files/{cover_path}"))) .await .unwrap(); assert_eq!(file_resp.status(), StatusCode::OK); let ct = file_resp .headers() .get(axum::http::header::CONTENT_TYPE) .unwrap(); assert_eq!(ct, "image/png"); } #[sqlx::test(migrations = "./migrations")] async fn create_manga_without_cover_leaves_path_null(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", json!({ "title": "Solo Manga" })), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let body = common::body_json(resp).await; assert!(body["cover_image_path"].is_null()); } #[sqlx::test(migrations = "./migrations")] async fn create_manga_rejects_non_image_cover_with_415(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let pdf = b"%PDF-1.4\n%\xc4\xe5".to_vec(); let resp = h .app .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new() .add_json("metadata", json!({ "title": "Bad Cover" })) .add_file("cover", "cover.png", "image/png", &pdf), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "unsupported_media_type"); } #[sqlx::test(migrations = "./migrations")] async fn create_manga_rejects_oversized_cover_with_413(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; // Test harness max_file_bytes is 256 KiB. Build a "PNG" that's 300 KiB. let mut big = common::fake_png_bytes(); big.resize(300 * 1024, 0); let resp = h .app .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new() .add_json("metadata", json!({ "title": "Heavy Cover" })) .add_file("cover", "cover.png", "image/png", &big), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "payload_too_large"); } #[sqlx::test(migrations = "./migrations")] async fn files_endpoint_streams_in_multiple_frames(pool: PgPool) { use axum::http::header; use http_body_util::BodyExt; let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Big Manga").await; // The test harness caps a single file at 256 KiB; build a ~200 KiB PNG // so it fits but is large enough that the 64 KiB chunker emits >1 frame. let mut big = common::fake_png_bytes(); big.resize(200 * 1024, 7); let resp = h .app .clone() .oneshot(common::post_multipart_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters"), common::MultipartBuilder::new() .add_json("metadata", json!({ "number": 1 })) .add_file("page", "1.png", "image/png", &big), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // Fetch the page back via the streaming files endpoint. let pages = h .app .clone() .oneshot(common::get(&format!( "/api/v1/mangas/{manga_id}/chapters/1/pages" ))) .await .unwrap(); let body = common::body_json(pages).await; let key = body["pages"][0]["storage_key"].as_str().unwrap().to_string(); let resp = h .app .oneshot(common::get(&format!("/api/v1/files/{key}"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get(header::CONTENT_LENGTH).unwrap(), big.len().to_string().as_str() ); // Browsers must trust the declared Content-Type rather than sniff // the body — the upload-time magic-byte check is authoritative. assert_eq!( resp.headers().get("x-content-type-options").unwrap(), "nosniff" ); let mut body = resp.into_body(); let mut frames = 0usize; let mut total = 0usize; while let Some(frame) = body.frame().await { let frame = frame.unwrap(); if let Some(data) = frame.data_ref() { frames += 1; total += data.len(); } } assert_eq!(total, big.len()); assert!( frames > 1, "expected the file to stream in more than one frame (got {frames})" ); } #[sqlx::test(migrations = "./migrations")] async fn create_chapter_with_pages_stores_each(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 resp = h .app .clone() .oneshot(common::post_multipart_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters"), MultipartBuilder::new() .add_json("metadata", json!({ "number": 1, "title": "The Brand" })) .add_file("page", "1.png", "image/png", &common::fake_png_bytes()) .add_file("page", "2.jpg", "image/jpeg", &common::fake_jpeg_bytes()) .add_file("page", "3.png", "image/png", &common::fake_png_bytes()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let body = common::body_json(resp).await; assert_eq!(body["number"], 1); assert_eq!(body["title"], "The Brand"); assert_eq!(body["page_count"], 3); let chapter_id = Uuid::parse_str(body["id"].as_str().unwrap()).unwrap(); // Each page is reachable in arrival order, with the correct extension // derived from the sniffed MIME (not the client filename). for (idx, expected_ct) in [ (1, "image/png"), (2, "image/jpeg"), (3, "image/png"), ] { let ext = match expected_ct { "image/png" => "png", "image/jpeg" => "jpg", _ => unreachable!(), }; let key = format!("mangas/{manga_id}/chapters/{chapter_id}/pages/{idx:04}.{ext}"); let file_resp = h .app .clone() .oneshot(common::get(&format!("/api/v1/files/{key}"))) .await .unwrap(); assert_eq!(file_resp.status(), StatusCode::OK, "missing page {idx}"); let ct = file_resp .headers() .get(axum::http::header::CONTENT_TYPE) .unwrap(); assert_eq!(ct, expected_ct); } } #[sqlx::test(migrations = "./migrations")] async fn create_chapter_rejects_when_no_pages_with_422(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 resp = h .app .oneshot(common::post_multipart_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters"), MultipartBuilder::new().add_json("metadata", json!({ "number": 1 })), &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"]["page"].is_string()); } #[sqlx::test(migrations = "./migrations")] async fn create_chapter_rejects_renamed_non_image_page(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; // Client claims it's an image; bytes are a PDF. let pdf = b"%PDF-1.4\n%\xc4\xe5".to_vec(); let resp = h .app .oneshot(common::post_multipart_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters"), MultipartBuilder::new() .add_json("metadata", json!({ "number": 1 })) .add_file("page", "page1.png", "image/png", &pdf), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "unsupported_media_type"); } #[sqlx::test(migrations = "./migrations")] async fn create_chapter_returns_409_on_duplicate_number(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 make = || { common::post_multipart_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters"), MultipartBuilder::new() .add_json("metadata", json!({ "number": 1 })) .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), &cookie, ) }; let first = h.app.clone().oneshot(make()).await.unwrap(); assert_eq!(first.status(), StatusCode::CREATED); let second = h.app.oneshot(make()).await.unwrap(); assert_eq!(second.status(), StatusCode::CONFLICT); let body = common::body_json(second).await; assert_eq!(body["error"]["code"], "conflict"); } #[sqlx::test(migrations = "./migrations")] async fn create_chapter_requires_authentication(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, "Berserk").await; let resp = h .app .oneshot(common::post_multipart( &format!("/api/v1/mangas/{manga_id}/chapters"), MultipartBuilder::new() .add_json("metadata", json!({ "number": 1 })) .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn manga_upload_rolls_back_when_cover_storage_fails(pool: PgPool) { // First `put` call errors. The manga create handler is the only // thing that hits storage here, so the cover put on the first // request triggers the injected failure and the transaction must // roll back. let h = common::harness_with_failing_storage(pool.clone(), 0); 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", json!({ "title": "Berserk" })) .add_file("cover", "cover.png", "image/png", &common::fake_png_bytes()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "internal_error"); // No manga row with that title — the INSERT inside the tx was // rolled back when the cover put failed. let (count,): (i64,) = sqlx::query_as("SELECT count(*) FROM mangas WHERE title = $1") .bind("Berserk") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 0, "rolled-back manga must not persist"); } #[sqlx::test(migrations = "./migrations")] async fn chapter_upload_rolls_back_when_storage_fails_mid_loop(pool: PgPool) { // Configure storage so the second `put` call (0-indexed: index 1) // errors. seed_manga_via_api uploads no cover, so the very first // `put` happens inside the chapter handler — page 1 succeeds, page // 2 fails, the transaction rolls back. let h = common::harness_with_failing_storage(pool.clone(), 1); let (_, cookie) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; let resp = h .app .oneshot(common::post_multipart_with_cookie( &format!("/api/v1/mangas/{manga_id}/chapters"), MultipartBuilder::new() .add_json("metadata", json!({ "number": 1 })) .add_file("page", "1.png", "image/png", &common::fake_png_bytes()) .add_file("page", "2.png", "image/png", &common::fake_png_bytes()) .add_file("page", "3.png", "image/png", &common::fake_png_bytes()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "internal_error"); // No chapter rows for this manga. let (chapter_count,): (i64,) = sqlx::query_as("SELECT count(*) FROM chapters WHERE manga_id = $1") .bind(manga_id) .fetch_one(&pool) .await .unwrap(); assert_eq!(chapter_count, 0, "rolled-back chapter must not persist"); // No page rows at all (we never seeded any other chapter). let (page_count,): (i64,) = sqlx::query_as("SELECT count(*) FROM pages").fetch_one(&pool).await.unwrap(); assert_eq!(page_count, 0, "rolled-back pages must not persist"); } #[sqlx::test(migrations = "./migrations")] async fn create_chapter_under_unknown_manga_is_404(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let unknown = Uuid::nil(); let resp = h .app .oneshot(common::post_multipart_with_cookie( &format!("/api/v1/mangas/{unknown}/chapters"), MultipartBuilder::new() .add_json("metadata", json!({ "number": 1 })) .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); }