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>
413 lines
12 KiB
Rust
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);
|
|
}
|