Files
Mangalord/backend/tests/api_chapters.rs
MechaCat02 9af070608b feat: streaming files endpoint + reader pages + chapter pages metadata
Backend:
- Migration 0003_pages.sql adds a `pages` table (id, chapter_id,
  page_number, storage_key, content_type) with a unique (chapter_id,
  page_number). New table because chapter pages can have different MIME
  types per page; reconstructing keys from a single template would
  break the moment a chapter mixes png and jpg pages.
- `domain::Page` + `repo::page` (create + list_for_chapter).
- The chapter upload handler now inserts one page row per part as it
  writes the bytes to storage.
- GET /api/v1/mangas/{id}/chapters/{n}/pages returns `{pages: [...]}`
  with the storage_key clients need to construct image URLs. 404 if
  the manga or chapter doesn't exist; reads are public.

Storage trait grows `get_stream(&str) -> StreamingFile` returning a
`Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>` + size. The
local backend implements via `tokio::fs::File` + `tokio_util::io::
ReaderStream` with a 64 KiB chunk size. GET /api/v1/files/*key now
streams via `axum::body::Body::from_stream` instead of buffering — the
test asserts a 200 KiB file emits >1 frame end-to-end through the
router.

Frontend:
- lib/api/client.ts gains `fileUrl(key)` so components don't
  reconstruct the `/api/v1/files/...` path manually.
- lib/api/chapters.ts gains `ChapterPage` type + `getChapterPages` (the
  type is named ChapterPage to avoid colliding with `Page` from
  client.ts, which is the pagination envelope).
- /manga/[id]/+page.svelte: overview with cover, title, author,
  description, chapter list, and a disabled bookmark control (real
  bookmarking lands in feat/bookmarks). Responsive at 640 px.
- /manga/[id]/chapter/[n]/+page.svelte: paginated reader. Current page
  loads eagerly; next page is preloaded in a hidden img so navigation
  feels instant. Keyboard handler maps ArrowRight/j/Space → next,
  ArrowLeft/k → prev, Home/End → first/last; skips when the user is
  typing in an input. Focus ring on the prev/next buttons.
- SSR is disabled on both routes via `export const ssr = false` so the
  client-only fetch flow doesn't need to be replicated server-side; the
  routes are interactive features, not SEO surfaces.
- E2E (e2e/reader.spec.ts): overview shows the title/cover/chapter
  list; reader pages through three pages via ArrowRight, j, k, and
  ArrowLeft, and the preload img holds the page-2 src on initial load.

Lockstep version bump to 0.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:32:08 +02:00

166 lines
5.3 KiB
Rust

mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
#[allow(unused_imports)]
use serde_json as _;
async fn seed_manga(h: &common::Harness, cookie: &str, title: &str) -> Uuid {
common::seed_manga_via_api(&h.app, cookie, title).await
}
async fn seed_chapter(pool: &PgPool, manga_id: Uuid, number: i32, title: Option<&str>) {
mangalord::repo::chapter::create(pool, manga_id, number, title)
.await
.unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn list_chapters_is_empty_initially(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
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;
assert_eq!(body["items"], json!([]));
assert_eq!(body["page"]["limit"], 50);
assert_eq!(body["page"]["offset"], 0);
assert!(body["page"]["total"].is_null());
}
#[sqlx::test(migrations = "./migrations")]
async fn list_chapters_returned_in_number_order(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
seed_chapter(&pool, manga_id, 3, Some("The Black Swordsman")).await;
seed_chapter(&pool, manga_id, 1, Some("The Brand")).await;
seed_chapter(&pool, manga_id, 2, None).await;
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 numbers: Vec<i64> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|c| c["number"].as_i64().unwrap())
.collect();
assert_eq!(numbers, vec![1, 2, 3]);
assert_eq!(body["items"][1]["title"], serde_json::Value::Null);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_chapters_returns_404_for_unknown_manga(pool: PgPool) {
let h = common::harness(pool);
let unknown = Uuid::nil();
let resp = h
.app
.oneshot(common::get(&format!("/api/v1/mangas/{unknown}/chapters")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "not_found");
}
#[sqlx::test(migrations = "./migrations")]
async fn get_chapter_by_number(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
seed_chapter(&pool, manga_id, 1, Some("The Brand")).await;
let resp = h
.app
.oneshot(common::get(&format!(
"/api/v1/mangas/{manga_id}/chapters/1"
)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["number"], 1);
assert_eq!(body["title"], "The Brand");
assert_eq!(body["page_count"], 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_chapter_unknown_number_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
let resp = h
.app
.oneshot(common::get(&format!(
"/api/v1/mangas/{manga_id}/chapters/99"
)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "not_found");
}
#[sqlx::test(migrations = "./migrations")]
async fn get_chapter_unknown_manga_is_404(pool: PgPool) {
let h = common::harness(pool);
let unknown = Uuid::nil();
let resp = h
.app
.oneshot(common::get(&format!("/api/v1/mangas/{unknown}/chapters/1")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_pages_empty_for_chapter_without_upload(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
seed_chapter(&pool, manga_id, 1, None).await;
let resp = h
.app
.oneshot(common::get(&format!(
"/api/v1/mangas/{manga_id}/chapters/1/pages"
)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["pages"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn list_pages_returns_404_for_unknown_chapter(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
let resp = h
.app
.oneshot(common::get(&format!(
"/api/v1/mangas/{manga_id}/chapters/99/pages"
)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}