mod common; use axum::http::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use common::{ body_json, delete_with_cookie, fake_jpeg_bytes, fake_png_bytes, get, harness, post_multipart_with_cookie, put_multipart, put_multipart_with_cookie, register_user, MultipartBuilder, }; async fn create_manga_with_cover( app: &axum::Router, cookie: &str, title: &str, cover: Option<(&str, &[u8])>, ) -> Value { let mut form = MultipartBuilder::new().add_json("metadata", json!({ "title": title })); if let Some((ct, bytes)) = cover { form = form.add_file("cover", "cover.bin", ct, bytes); } let resp = app .clone() .oneshot(post_multipart_with_cookie("/api/v1/mangas", form, cookie)) .await .unwrap(); assert_eq!( resp.status(), StatusCode::CREATED, "seed create_manga failed: {:?}", resp.status() ); body_json(resp).await } fn id_of(body: &Value) -> Uuid { Uuid::parse_str(body["id"].as_str().unwrap()).unwrap() } fn cover_form(bytes: &[u8]) -> MultipartBuilder { MultipartBuilder::new().add_file("cover", "cover.bin", "application/octet-stream", bytes) } #[sqlx::test(migrations = "./migrations")] async fn put_cover_sets_path_when_none_existed(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover(&h.app, &cookie, "Cover Me", None).await; let id = id_of(&manga); assert!(manga["cover_image_path"].is_null()); let bytes = fake_png_bytes(); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), cover_form(&bytes), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; let expected_key = format!("mangas/{id}/cover.png"); assert_eq!(body["cover_image_path"], expected_key); assert_eq!(body["title"], "Cover Me"); let file_resp = h .app .clone() .oneshot(get(&format!("/api/v1/files/{expected_key}"))) .await .unwrap(); assert_eq!(file_resp.status(), StatusCode::OK); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_replaces_existing_same_extension(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let original = fake_png_bytes(); let manga = create_manga_with_cover( &h.app, &cookie, "Replace Me", Some(("image/png", &original)), ) .await; let id = id_of(&manga); let original_key = format!("mangas/{id}/cover.png"); assert_eq!(manga["cover_image_path"], original_key); let mut replacement = fake_png_bytes(); replacement.extend_from_slice(b"-replacement-marker"); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), cover_form(&replacement), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["cover_image_path"], original_key); let file_resp = h .app .clone() .oneshot(get(&format!("/api/v1/files/{original_key}"))) .await .unwrap(); assert_eq!(file_resp.status(), StatusCode::OK); let body_bytes = http_body_util::BodyExt::collect(file_resp.into_body()) .await .unwrap() .to_bytes(); assert_eq!(body_bytes.as_ref(), replacement.as_slice()); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_replaces_existing_different_extension_and_deletes_old_blob(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let png = fake_png_bytes(); let manga = create_manga_with_cover( &h.app, &cookie, "Switch Ext", Some(("image/png", &png)), ) .await; let id = id_of(&manga); let old_key = format!("mangas/{id}/cover.png"); assert_eq!(manga["cover_image_path"], old_key); let jpeg = fake_jpeg_bytes(); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), cover_form(&jpeg), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; let new_key = format!("mangas/{id}/cover.jpg"); assert_eq!(body["cover_image_path"], new_key); let new_file = h .app .clone() .oneshot(get(&format!("/api/v1/files/{new_key}"))) .await .unwrap(); assert_eq!(new_file.status(), StatusCode::OK); let old_file = h .app .clone() .oneshot(get(&format!("/api/v1/files/{old_key}"))) .await .unwrap(); assert_eq!(old_file.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_rejects_unauthenticated(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover(&h.app, &cookie, "Public Read", None).await; let id = id_of(&manga); let resp = h .app .clone() .oneshot(put_multipart( &format!("/api/v1/mangas/{id}/cover"), cover_form(&fake_png_bytes()), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_404_on_unknown_id(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let id = Uuid::new_v4(); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), cover_form(&fake_png_bytes()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_rejects_non_image_with_unsupported_media_type(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover(&h.app, &cookie, "Not Image", None).await; let id = id_of(&manga); let pdf = b"%PDF-1.4\n%\xc4\xe5".to_vec(); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), cover_form(&pdf), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); let body = body_json(resp).await; assert_eq!(body["error"]["code"], "unsupported_media_type"); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_rejects_oversized(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover(&h.app, &cookie, "Too Big", None).await; let id = id_of(&manga); // Harness max_file_bytes is 256 KiB; 300 KiB trips the cap. let mut bytes = fake_png_bytes(); bytes.resize(300 * 1024, 0); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), cover_form(&bytes), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_rejects_missing_cover_part(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover(&h.app, &cookie, "Empty Form", None).await; let id = id_of(&manga); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), MultipartBuilder::new(), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = body_json(resp).await; assert_eq!(body["error"]["code"], "validation_failed"); } #[sqlx::test(migrations = "./migrations")] async fn put_cover_preserves_other_metadata(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover( &h.app, &cookie, "Keep My Fields", None, ) .await; let id = id_of(&manga); let resp = h .app .clone() .oneshot(put_multipart_with_cookie( &format!("/api/v1/mangas/{id}/cover"), cover_form(&fake_png_bytes()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["title"], "Keep My Fields"); assert_eq!(body["status"], "ongoing"); assert_eq!(body["authors"], json!([])); assert_eq!(body["genres"], json!([])); assert_eq!(body["tags"], json!([])); } #[sqlx::test(migrations = "./migrations")] async fn delete_cover_clears_path_and_removes_blob(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let png = fake_png_bytes(); let manga = create_manga_with_cover( &h.app, &cookie, "Bye Cover", Some(("image/png", &png)), ) .await; let id = id_of(&manga); let key = format!("mangas/{id}/cover.png"); let resp = h .app .clone() .oneshot(delete_with_cookie( &format!("/api/v1/mangas/{id}/cover"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert!(body["cover_image_path"].is_null()); assert_eq!(body["title"], "Bye Cover"); let file_resp = h .app .clone() .oneshot(get(&format!("/api/v1/files/{key}"))) .await .unwrap(); assert_eq!(file_resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn delete_cover_is_idempotent_when_no_cover_present(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover(&h.app, &cookie, "Never Had One", None).await; let id = id_of(&manga); for _ in 0..2 { let resp = h .app .clone() .oneshot(delete_with_cookie( &format!("/api/v1/mangas/{id}/cover"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert!(body["cover_image_path"].is_null()); } } #[sqlx::test(migrations = "./migrations")] async fn delete_cover_rejects_unauthenticated(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let manga = create_manga_with_cover(&h.app, &cookie, "Locked", None).await; let id = id_of(&manga); let resp = h .app .clone() .oneshot( axum::http::Request::builder() .method("DELETE") .uri(format!("/api/v1/mangas/{id}/cover")) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn delete_cover_404_on_unknown_id(pool: PgPool) { let h = harness(pool); let (_, cookie) = register_user(&h.app).await; let id = Uuid::new_v4(); let resp = h .app .clone() .oneshot(delete_with_cookie( &format!("/api/v1/mangas/{id}/cover"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); }