diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a09e0a7..cbf4524 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -994,7 +994,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d2d29db..961a1a3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.3.0" +version = "0.4.0" edition = "2021" [lib] diff --git a/backend/src/api/chapters.rs b/backend/src/api/chapters.rs new file mode 100644 index 0000000..b1e05ed --- /dev/null +++ b/backend/src/api/chapters.rs @@ -0,0 +1,59 @@ +//! Chapter list + get. Reads are public — anyone can browse a manga's +//! table of contents and individual chapter metadata. Uploads land in +//! feat/uploads under POST /api/v1/mangas/{id}/chapters. + +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::api::pagination::PagedResponse; +use crate::app::AppState; +use crate::domain::Chapter; +use crate::error::AppResult; +use crate::repo; + +pub fn routes() -> Router { + Router::new() + .route("/mangas/:manga_id/chapters", get(list)) + .route("/mangas/:manga_id/chapters/:number", get(get_one)) +} + +#[derive(Debug, Deserialize)] +pub struct ListParams { + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + 50 +} + +async fn list( + State(state): State, + Path(manga_id): Path, + Query(params): Query, +) -> AppResult>> { + // Surface 404 when the parent manga doesn't exist so an empty result + // can't be mistaken for "no chapters yet" on a real manga. + repo::manga::get(&state.db, manga_id).await?; + + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let items = repo::chapter::list_for_manga(&state.db, manga_id, limit, offset).await?; + Ok(Json(PagedResponse::new(items, limit, offset))) +} + +async fn get_one( + State(state): State, + Path((manga_id, number)): Path<(Uuid, i32)>, +) -> AppResult> { + repo::manga::get(&state.db, manga_id).await?; + let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number) + .await? + .ok_or(crate::error::AppError::NotFound)?; + Ok(Json(chapter)) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 978a2e2..2801ae0 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod chapters; pub mod files; pub mod health; pub mod mangas; @@ -12,6 +13,7 @@ pub fn routes() -> Router { Router::new() .merge(health::routes()) .merge(mangas::routes()) + .merge(chapters::routes()) .merge(files::routes()) .merge(auth::routes()) } diff --git a/backend/src/repo/chapter.rs b/backend/src/repo/chapter.rs new file mode 100644 index 0000000..74ce97e --- /dev/null +++ b/backend/src/repo/chapter.rs @@ -0,0 +1,88 @@ +//! Chapter persistence. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::Chapter; +use crate::error::AppResult; + +pub async fn list_for_manga( + pool: &PgPool, + manga_id: Uuid, + limit: i64, + offset: i64, +) -> AppResult> { + let rows = sqlx::query_as::<_, Chapter>( + r#" + SELECT id, manga_id, number, title, page_count, created_at + FROM chapters + WHERE manga_id = $1 + ORDER BY number ASC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(manga_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn find_by_manga_and_number( + pool: &PgPool, + manga_id: Uuid, + number: i32, +) -> AppResult> { + let row = sqlx::query_as::<_, Chapter>( + r#" + SELECT id, manga_id, number, title, page_count, created_at + FROM chapters + WHERE manga_id = $1 AND number = $2 + "#, + ) + .bind(manga_id) + .bind(number) + .fetch_optional(pool) + .await?; + Ok(row) +} + +/// Inserts a chapter. Used by tests today and by the upload handler in +/// feat/uploads. Returns `AppError::Conflict` on the (manga_id, number) +/// unique violation so handlers can surface a clean 409. +pub async fn create( + pool: &PgPool, + manga_id: Uuid, + number: i32, + title: Option<&str>, +) -> AppResult { + let result = sqlx::query_as::<_, Chapter>( + r#" + INSERT INTO chapters (manga_id, number, title) + VALUES ($1, $2, $3) + RETURNING id, manga_id, number, title, page_count, created_at + "#, + ) + .bind(manga_id) + .bind(number) + .bind(title) + .fetch_one(pool) + .await; + + match result { + Ok(c) => Ok(c), + Err(e) if is_unique_violation(&e) => Err(crate::error::AppError::Conflict(format!( + "chapter {number} already exists for this manga" + ))), + Err(e) => Err(crate::error::AppError::Database(e)), + } +} + +fn is_unique_violation(err: &sqlx::Error) -> bool { + if let sqlx::Error::Database(db_err) = err { + db_err.code().as_deref() == Some("23505") + } else { + false + } +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index ee1d37f..0e45bd6 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -1,4 +1,5 @@ pub mod api_token; +pub mod chapter; pub mod manga; pub mod session; pub mod user; diff --git a/backend/tests/api_chapters.rs b/backend/tests/api_chapters.rs new file mode 100644 index 0000000..85a68db --- /dev/null +++ b/backend/tests/api_chapters.rs @@ -0,0 +1,144 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +/// Create a manga via the API (which requires auth) and return its id + +/// the session cookie of the user who owns it. +async fn seed_manga(h: &common::Harness, cookie: &str, title: &str) -> Uuid { + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/mangas", + json!({ "title": title }), + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + Uuid::parse_str(body["id"].as_str().unwrap()).unwrap() +} + +/// Insert a chapter directly via the repo (the upload handler that does +/// this from HTTP lands in feat/uploads). +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 = 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); +} diff --git a/frontend/package.json b/frontend/package.json index 8db5bd5..34b21fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/chapters.test.ts b/frontend/src/lib/api/chapters.test.ts new file mode 100644 index 0000000..a8b6bb2 --- /dev/null +++ b/frontend/src/lib/api/chapters.test.ts @@ -0,0 +1,89 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance +} from 'vitest'; +import { listChapters, getChapter } from './chapters'; + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} + +function envelope(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; + +const chapterFixture = { + id: 'c1', + manga_id: 'm1', + number: 1, + title: 'The Brand', + page_count: 0, + created_at: '2026-01-01T00:00:00Z' +}; + +describe('chapters api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listChapters hits /v1/mangas/{id}/chapters with no params', async () => { + fetchSpy.mockResolvedValueOnce(ok(emptyPage)); + await listChapters('m1'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/m1\/chapters$/); + }); + + it('listChapters encodes limit and offset', async () => { + fetchSpy.mockResolvedValueOnce(ok(emptyPage)); + await listChapters('m1', { limit: 10, offset: 20 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('limit=10'); + expect(url).toContain('offset=20'); + }); + + it('listChapters returns the paged envelope', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [chapterFixture], + page: { limit: 50, offset: 0, total: null } + }) + ); + const result = await listChapters('m1'); + expect(result.items[0]).toEqual(chapterFixture); + expect(result.page.total).toBeNull(); + }); + + it('getChapter hits /v1/mangas/{id}/chapters/{n}', async () => { + fetchSpy.mockResolvedValueOnce(ok(chapterFixture)); + const c = await getChapter('m1', 1); + expect(c).toEqual(chapterFixture); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/1$/); + }); + + it('getChapter surfaces 404 via ApiError.code', async () => { + fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'not found')); + await expect(getChapter('m1', 99)).rejects.toMatchObject({ + status: 404, + code: 'not_found' + }); + }); +}); diff --git a/frontend/src/lib/api/chapters.ts b/frontend/src/lib/api/chapters.ts new file mode 100644 index 0000000..d4b7213 --- /dev/null +++ b/frontend/src/lib/api/chapters.ts @@ -0,0 +1,39 @@ +import { request, type Page } from './client'; + +export type Chapter = { + id: string; + manga_id: string; + number: number; + title: string | null; + page_count: number; + created_at: string; +}; + +export type ChaptersPage = { + items: Chapter[]; + page: Page; +}; + +export type ListOptions = { + limit?: number; + offset?: number; +}; + +export async function listChapters( + mangaId: string, + opts: ListOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request( + `/v1/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}` + ); +} + +export async function getChapter(mangaId: string, number: number): Promise { + return request( + `/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}` + ); +}