Files
Mangalord/backend/tests/api_mangas_cover.rs
MechaCat02 fa0a7da311 feat: edit existing manga metadata (0.31.0)
Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so
covers can be replaced or cleared after creation, and wires a dedicated
/manga/[id]/edit SvelteKit route that combines the existing PATCH with
the new cover endpoints. Cover PUT cleans up the old blob when the
extension changes, swallowing StorageError::NotFound so a manually-gone
file doesn't surface as a 404 to the client. Edit link on the manga
detail page is gated on session.user, matching the auth posture of the
underlying handlers.

Also pins the local-dev port story via loadEnv() in vite.config.ts so
VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL
stable across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:26:23 +02:00

413 lines
12 KiB
Rust

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);
}