diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f56c520..c7ddfe2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c31dec1..d345edb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.30.0" +version = "0.31.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index 10b6939..f0e21af 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -1,6 +1,6 @@ use axum::extract::{Multipart, Path, Query, State}; use axum::http::StatusCode; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; use serde::Deserialize; use serde_json::json; @@ -14,12 +14,14 @@ use crate::domain::patch::Patch; use crate::domain::tag::TagRef; use crate::error::{AppError, AppResult}; use crate::repo; +use crate::storage::StorageError; use crate::upload::{parse_image, UploadedImage}; pub fn routes() -> Router { Router::new() .route("/mangas", get(list).post(create)) .route("/mangas/:id", get(get_one).patch(update)) + .route("/mangas/:id/cover", put(put_cover).delete(delete_cover)) .route("/mangas/:id/tags", post(attach_tag)) .route("/mangas/:id/tags/:tag_id", delete(detach_tag)) } @@ -259,6 +261,82 @@ async fn update( Ok(Json(repo::manga::get_detail(&state.db, id).await?)) } +/// `PUT /api/v1/mangas/:id/cover` is multipart/form-data with a single +/// required `cover` part containing image bytes. MIME is sniffed by +/// magic bytes (jpeg/png/webp/gif/avif); filename and Content-Type from +/// the client are ignored. Replaces any existing cover, deleting the +/// previous blob if its extension differs. Returns the refreshed +/// `MangaDetail`. +async fn put_cover( + State(state): State, + CurrentUser(_user): CurrentUser, + Path(id): Path, + mut multipart: Multipart, +) -> AppResult> { + // TODO(auth): until uploaders are tracked (Phase 5), any signed-in + // user can edit any manga's cover. Restrict to uploader + admin + // once that column lands. + if !repo::manga::exists(&state.db, id).await? { + return Err(AppError::NotFound); + } + + let mut cover: Option = None; + while let Some(field) = next_field(&mut multipart).await? { + if field.name() == Some("cover") { + let bytes = read_field_bytes(field).await?.to_vec(); + cover = Some(parse_image(bytes, state.upload.max_file_bytes, "cover")?); + } + } + let img = cover.ok_or_else(|| AppError::ValidationFailed { + message: "cover part is required".into(), + details: json!({ "cover": "required" }), + })?; + + // Read the old key BEFORE writing so we can clean up an orphan if + // the extension changed (e.g., .png → .jpg). Same-extension is a + // `put` overwrite — no delete needed. + let old_key = repo::manga::get(&state.db, id).await?.cover_image_path; + let new_key = format!("mangas/{}/cover.{}", id, img.ext); + state.storage.put(&new_key, &img.bytes).await?; + + if let Some(prev) = old_key.as_deref() { + if prev != new_key { + // Swallow NotFound — AppError maps it to a client 404, + // which would be wrong here. The DB row can outlive a + // manually-deleted blob. + match state.storage.delete(prev).await { + Ok(()) | Err(StorageError::NotFound) => {} + Err(e) => return Err(e.into()), + } + } + } + + repo::manga::set_cover_image_path(&state.db, id, &new_key).await?; + Ok(Json(repo::manga::get_detail(&state.db, id).await?)) +} + +/// `DELETE /api/v1/mangas/:id/cover` clears `cover_image_path` and +/// removes the blob. Idempotent: removing a non-existent cover succeeds +/// with the unchanged detail. +async fn delete_cover( + State(state): State, + CurrentUser(_user): CurrentUser, + Path(id): Path, +) -> AppResult> { + // TODO(auth): same caveat as put_cover. + if !repo::manga::exists(&state.db, id).await? { + return Err(AppError::NotFound); + } + if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path { + match state.storage.delete(&key).await { + Ok(()) | Err(StorageError::NotFound) => {} + Err(e) => return Err(e.into()), + } + repo::manga::clear_cover_image_path(&state.db, id).await?; + } + Ok(Json(repo::manga::get_detail(&state.db, id).await?)) +} + #[derive(Debug, Deserialize)] pub struct AttachTagBody { pub name: String, diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index 07b1379..f285940 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -262,6 +262,17 @@ pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>( Ok(()) } +pub async fn clear_cover_image_path<'e, E: PgExecutor<'e>>( + executor: E, + id: Uuid, +) -> AppResult<()> { + sqlx::query("UPDATE mangas SET cover_image_path = NULL, updated_at = now() WHERE id = $1") + .bind(id) + .execute(executor) + .await?; + Ok(()) +} + pub async fn exists(pool: &PgPool, id: Uuid) -> AppResult { let (exists,): (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM mangas WHERE id = $1)") diff --git a/backend/tests/api_mangas_cover.rs b/backend/tests/api_mangas_cover.rs new file mode 100644 index 0000000..396eacd --- /dev/null +++ b/backend/tests/api_mangas_cover.rs @@ -0,0 +1,412 @@ +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); +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 4f086e6..f49507c 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -336,6 +336,37 @@ pub fn post_multipart_with_cookie( .unwrap() } +pub fn put_multipart_with_cookie( + uri: &str, + builder: MultipartBuilder, + cookie: &str, +) -> Request { + let (boundary, body) = builder.finalize(); + Request::builder() + .method("PUT") + .uri(uri) + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .header(header::COOKIE, cookie) + .body(Body::from(body)) + .unwrap() +} + +pub fn put_multipart(uri: &str, builder: MultipartBuilder) -> Request { + let (boundary, body) = builder.finalize(); + Request::builder() + .method("PUT") + .uri(uri) + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap() +} + /// Realistic PNG file header bytes — enough for `infer` to identify. pub fn fake_png_bytes() -> Vec { vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] diff --git a/frontend/e2e/manga-edit.spec.ts b/frontend/e2e/manga-edit.spec.ts new file mode 100644 index 0000000..ff5f95c --- /dev/null +++ b/frontend/e2e/manga-edit.spec.ts @@ -0,0 +1,147 @@ +import { test, expect, type Page } from '@playwright/test'; + +const userFixture = { + id: 'u1', + username: 'alice', + created_at: '2026-01-01T00:00:00Z' +}; + +const baseManga = { + id: 'm1', + title: 'Berserk', + status: 'ongoing', + alt_titles: ['Old Alt'], + description: 'Original description', + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [{ id: 'a1', name: 'Kentaro Miura' }], + genres: [], + tags: [] +}; + +async function stubAuthenticatedAndGenres(page: Page) { + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }) + ); + await page.route('**/api/v1/genres', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { id: 'g-action', name: 'Action' }, + { id: 'g-fantasy', name: 'Fantasy' } + ]) + }) + ); +} + +test('anonymous user sees sign-in prompt on /manga/[id]/edit', async ({ page }) => { + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }) + ); + await page.route('**/api/v1/genres', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + ); + await page.route('**/api/v1/mangas/m1', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(baseManga) + }) + ); + + await page.goto('/manga/m1/edit'); + await expect(page.getByTestId('edit-signin')).toBeVisible(); +}); + +test('/manga/[id]/edit PATCHes the changed metadata and lands on the manga page', async ({ + page +}) => { + await stubAuthenticatedAndGenres(page); + + let patchBody: Record | null = null; + let mangaAfter = { ...baseManga }; + await page.route('**/api/v1/mangas/m1', async (route) => { + const method = route.request().method(); + if (method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaAfter) + }); + } else if (method === 'PATCH') { + patchBody = JSON.parse(route.request().postData() ?? '{}'); + mangaAfter = { + ...mangaAfter, + title: (patchBody.title as string) ?? mangaAfter.title, + description: + 'description' in (patchBody as Record) + ? ((patchBody.description as string | null) ?? null) + : mangaAfter.description + }; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaAfter) + }); + } else { + await route.fallback(); + } + }); + await page.route('**/api/v1/mangas/m1/chapters*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.route('**/api/v1/me/bookmarks*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.route('**/api/v1/me/read-progress/m1', (route) => + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'not_found', message: 'no progress' } + }) + }) + ); + + await page.goto('/manga/m1'); + // Edit link is gated on session.user — it should be visible to the + // stubbed authenticated user. + await page.getByTestId('edit-manga-link').click(); + await expect(page).toHaveURL(/\/manga\/m1\/edit$/); + + const titleInput = page.getByTestId('manga-title'); + await expect(titleInput).toHaveValue('Berserk'); + await titleInput.fill('Berserk (Deluxe)'); + await page.getByTestId('manga-edit-submit').click(); + + await expect(page).toHaveURL(/\/manga\/m1$/); + await expect(page.getByTestId('manga-title')).toHaveText('Berserk (Deluxe)'); + expect(patchBody).not.toBeNull(); + expect((patchBody as Record).title).toBe('Berserk (Deluxe)'); +}); diff --git a/frontend/package.json b/frontend/package.json index c28cded..4789d99 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.30.0", + "version": "0.31.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/mangas.test.ts b/frontend/src/lib/api/mangas.test.ts index 71b2ebd..e8ca4a0 100644 --- a/frontend/src/lib/api/mangas.test.ts +++ b/frontend/src/lib/api/mangas.test.ts @@ -4,6 +4,8 @@ import { createManga, getManga, updateManga, + updateMangaCover, + deleteMangaCover, attachTag, detachTag } from './mangas'; @@ -184,6 +186,49 @@ describe('mangas api client', () => { }); }); + it('updateMangaCover PUTs multipart with the cover blob', async () => { + fetchSpy.mockResolvedValueOnce( + ok(detailFixture({ cover_image_path: 'mangas/b1/cover.png' })) + ); + const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }); + const updated = await updateMangaCover('b1', cover); + expect(updated.cover_image_path).toBe('mangas/b1/cover.png'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('PUT'); + expect(init.body).toBeInstanceOf(FormData); + const form = init.body as FormData; + expect(form.get('cover')).toBeInstanceOf(Blob); + // Boundary is filled in by the browser when body is FormData. + expect(init.headers).toBeUndefined(); + }); + + it('updateMangaCover throws ApiError on payload_too_large', async () => { + fetchSpy.mockResolvedValue( + envelope(413, 'payload_too_large', 'cover exceeds size cap') + ); + const cover = new Blob([new Uint8Array(1)]); + await expect(updateMangaCover('b1', cover)).rejects.toMatchObject({ + name: 'ApiError', + status: 413, + code: 'payload_too_large' + }); + }); + + it('deleteMangaCover DELETEs and returns the refreshed detail with null path', async () => { + fetchSpy.mockResolvedValueOnce( + ok(detailFixture({ cover_image_path: null })) + ); + const updated = await deleteMangaCover('b1'); + expect(updated.cover_image_path).toBeNull(); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + expect(init.body).toBeUndefined(); + }); + it('attachTag POSTs the name and returns the TagRef', async () => { fetchSpy.mockResolvedValueOnce( ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201) diff --git a/frontend/src/lib/api/mangas.ts b/frontend/src/lib/api/mangas.ts index f42760f..0351ef6 100644 --- a/frontend/src/lib/api/mangas.ts +++ b/frontend/src/lib/api/mangas.ts @@ -109,6 +109,31 @@ export async function updateManga( }); } +/** + * PUT /api/v1/mangas/:id/cover (multipart). Replaces the cover image and + * returns the refreshed detail. As with createManga the browser fills in + * the multipart boundary automatically, so we must NOT set Content-Type. + */ +export async function updateMangaCover( + id: string, + cover: Blob +): Promise { + const form = new FormData(); + form.append('cover', cover); + return request( + `/v1/mangas/${encodeURIComponent(id)}/cover`, + { method: 'PUT', body: form } + ); +} + +/** DELETE /api/v1/mangas/:id/cover. Returns the refreshed detail. */ +export async function deleteMangaCover(id: string): Promise { + return request( + `/v1/mangas/${encodeURIComponent(id)}/cover`, + { method: 'DELETE' } + ); +} + export async function attachTag( mangaId: string, name: string diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index 59d61e0..75db8e9 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -14,6 +14,7 @@ import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte'; import Plus from '@lucide/svelte/icons/plus'; import FolderPlus from '@lucide/svelte/icons/folder-plus'; + import Pencil from '@lucide/svelte/icons/pencil'; import UploadCloud from '@lucide/svelte/icons/upload-cloud'; let { data } = $props(); @@ -327,6 +328,14 @@