The chapter upload handler read every `page` part fully into a Vec before
writing any, so peak memory was the whole chapter (bounded only by the
200 MiB body limit and amplified by concurrent uploads). It also accepted
an unbounded number of pages.
Stream each page part to a `staging/{upload_id}/…` key as it arrives — at
most one page's bytes are held at a time — then, once the chapter row (and
its id) exists, promote each staged blob to its final key via a new
`Storage::rename` (LocalStorage: fs rename; default impl: stream+delete for
future backends). Finalization is all-or-nothing: on any failure the DB
rolls back and both staged and already-finalized blobs are cleaned up.
Add MAX_PAGES_PER_CHAPTER (UploadConfig, default 2000, 0 = disabled),
rejecting an over-cap upload with 413 before any DB write. Also document
the crawler-side CRAWLER_MAX_IMAGES_PER_CHAPTER (added earlier) in
.env.example + docker-compose so the env-coverage test passes.
Tests: LocalStorage rename unit tests; a 413 over-cap upload test; existing
rollback + happy-path upload tests still green (the fault-injecting storage
counts put/put_stream, so mid-upload failure still rolls back).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
549 lines
20 KiB
Rust
549 lines
20 KiB
Rust
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);
|
|
let chapter_id = common::body_json(resp).await["id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
// Fetch the page back via the streaming files endpoint.
|
|
let pages = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get(&format!(
|
|
"/api/v1/mangas/{manga_id}/chapters/{chapter_id}/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);
|
|
// The 201 body reflects the just-uploaded page bytes, not the stale 0
|
|
// the chapter row carried before its pages were inserted.
|
|
let created_total =
|
|
(common::fake_png_bytes().len() * 2 + common::fake_jpeg_bytes().len()) as i64;
|
|
assert_eq!(body["size_bytes"].as_i64().unwrap(), created_total);
|
|
|
|
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);
|
|
}
|
|
|
|
// The chapter list reports per-chapter storage = sum of the page
|
|
// bytes, captured at upload time (all pages measured → a number, not
|
|
// null).
|
|
let expected = (common::fake_png_bytes().len() * 2 + common::fake_jpeg_bytes().len()) as i64;
|
|
let list_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}/chapters")))
|
|
.await
|
|
.unwrap();
|
|
let list = common::body_json(list_resp).await;
|
|
let ch = &list["items"][0];
|
|
assert_eq!(ch["id"].as_str().unwrap(), chapter_id.to_string());
|
|
assert_eq!(ch["size_bytes"].as_i64().unwrap(), expected);
|
|
|
|
// The manga detail rolls those page bytes up into chapter_storage_bytes
|
|
// (cover excluded), measured at upload time so it's a number, not null.
|
|
let detail_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}")))
|
|
.await
|
|
.unwrap();
|
|
let detail = common::body_json(detail_resp).await;
|
|
assert_eq!(detail["chapter_storage_bytes"].as_i64().unwrap(), expected);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_chapter_rejects_non_positive_number_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;
|
|
|
|
for bad_number in [0i32, -1, -100] {
|
|
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": bad_number }))
|
|
.add_file("page", "1.png", "image/png", &common::fake_png_bytes()),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"number={bad_number} should be rejected"
|
|
);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "validation_failed");
|
|
assert!(body["error"]["details"]["number"].is_string());
|
|
}
|
|
}
|
|
|
|
#[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_over_page_cap_with_413(pool: PgPool) {
|
|
// Cap of 2 pages: a 3-page upload is refused once the third `page` part
|
|
// arrives, before any chapter row is written.
|
|
let h = common::harness_with_page_cap(pool.clone(), 2);
|
|
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 }))
|
|
.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::PAYLOAD_TOO_LARGE);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "payload_too_large");
|
|
|
|
// Nothing persisted — the cap trips before the chapter transaction.
|
|
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, "over-cap upload must not create a chapter");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
/// Multiple chapters can share the same number — different
|
|
/// scanlations, re-uploads, translator notes. As of migration 0013,
|
|
/// (manga_id, number) is not unique and each upload gets its own
|
|
/// chapter id.
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_chapter_allows_duplicate_numbers_as_separate_chapters(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 first_id = common::body_json(first).await["id"].as_str().unwrap().to_string();
|
|
|
|
let second = h.app.clone().oneshot(make()).await.unwrap();
|
|
assert_eq!(second.status(), StatusCode::CREATED);
|
|
let second_id = common::body_json(second).await["id"].as_str().unwrap().to_string();
|
|
|
|
assert_ne!(first_id, second_id, "each upload gets a distinct chapter id");
|
|
|
|
// List endpoint surfaces both rows.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}/chapters")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
let items = body["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 2, "both Ch.1 uploads listed separately");
|
|
for item in items {
|
|
assert_eq!(item["number"], 1);
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|