Files
Mangalord/backend/tests/api_history.rs
MechaCat02 19c1276490 feat: read & upload history (0.19.0)
Per-user reading progress and uploader attribution.

Schema (migration 0011): `read_progress` table (one row per (user,
manga); chapter_id nullable on chapter delete) and nullable
`uploaded_by` columns on mangas + chapters with partial indexes
scoped to non-null rows.

Endpoints (all `/me/*`, auth-scoped):
- PUT `/v1/me/read-progress` upserts. FK violations + cross-manga
  chapter ids both surface as 4xx (404 / 422) so the API can't be
  used to write logically invalid rows.
- GET `/v1/me/read-progress` paged newest-first list.
- GET `/v1/me/read-progress/:manga_id` enriched with chapter_number
  for the manga page's Continue CTA.
- DELETE `/v1/me/read-progress/:manga_id` idempotent.
- GET `/v1/me/uploads` interleaved manga + chapter uploads as a
  tagged union; limit-only pagination.

Existing manga + chapter upload handlers stamp `uploaded_by`.

Frontend:
- Reader emits progress on mount + page change (debounce) and via
  IntersectionObserver in continuous mode. High-water mark is seeded
  from the persisted server value so re-opening a chapter doesn't
  regress to page 1. Tab close survives via `sendBeacon` (fallback
  `keepalive` fetch); SPA navigation flushes via regular fetch.
- Manga detail page shows "Continue reading Chapter N — page M"
  above the chapters list, working even for mangas with >50
  chapters.
- New `/profile/history` tab with reading history (clear-per-row,
  inline error on failure) and uploads (mangas + chapters mixed
  chronologically with type-aware rendering).

171 backend tests (incl. 16 history tests covering ownership, FK
race, cross-link guard, chapter SET NULL behaviour) and 97 frontend
tests + svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:19:52 +02:00

406 lines
14 KiB
Rust

mod common;
use axum::http::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
use common::MultipartBuilder;
async fn seed_chapter(app: &axum::Router, cookie: &str, manga_id: Uuid, number: i32) -> String {
let resp = app
.clone()
.oneshot(common::post_multipart_with_cookie(
&format!("/api/v1/mangas/{manga_id}/chapters"),
MultipartBuilder::new()
.add_json("metadata", json!({ "number": number }))
.add_file("page", "1.png", "image/png", &common::fake_png_bytes()),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = common::body_json(resp).await;
body["id"].as_str().unwrap().to_string()
}
async fn upsert_progress(
app: &axum::Router,
cookie: &str,
body: Value,
) -> Value {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
body,
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "upsert failed: {:?}", resp.status());
common::body_json(resp).await
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_creates_then_overwrites(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 chapter_id = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let first = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id, "page": 5 }),
)
.await;
assert_eq!(first["manga_id"], manga_id.to_string());
assert_eq!(first["page"], 5);
// A second upsert overwrites the page even when it moves backwards
// — re-reading scenarios just take the latest write.
let second = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id, "page": 1 }),
)
.await;
assert_eq!(second["page"], 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_with_unknown_manga_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({ "manga_id": Uuid::new_v4().to_string(), "page": 1 }),
&cookie,
))
.await
.unwrap();
// The FK violation in repo::upsert is mapped to NotFound.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_with_page_zero_is_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::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({ "manga_id": manga_id.to_string(), "page": 0 }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_orders_most_recent_first(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let m1 = common::seed_manga_via_api(&h.app, &cookie, "First").await;
let m2 = common::seed_manga_via_api(&h.app, &cookie, "Second").await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": m1.to_string(), "page": 1 }),
)
.await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": m2.to_string(), "page": 1 }),
)
.await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let titles: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|r| r["manga_title"].as_str().unwrap())
.collect();
// Second was upserted last → it surfaces first.
assert_eq!(titles, vec!["Second", "First"]);
assert_eq!(body["page"]["total"], 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_is_per_user_only(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &a, "Berserk").await;
let _ = upsert_progress(
&h.app,
&a,
json!({ "manga_id": manga_id.to_string(), "page": 7 }),
)
.await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &b))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn get_single_manga_returns_404_when_unread(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::get_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_single_manga_returns_progress_after_upsert(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 chapter_id = seed_chapter(&h.app, &cookie, manga_id, 7).await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({
"manga_id": manga_id.to_string(),
"chapter_id": chapter_id,
"page": 12
}),
)
.await;
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["page"], 12);
// chapter_number is resolved in the same round-trip so the
// Continue CTA can render without listing chapters.
assert_eq!(body["chapter_number"], 7);
assert_eq!(body["chapter_id"], chapter_id);
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_rejects_chapter_from_a_different_manga(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_a = common::seed_manga_via_api(&h.app, &cookie, "A").await;
let manga_b = common::seed_manga_via_api(&h.app, &cookie, "B").await;
let chapter_of_b = seed_chapter(&h.app, &cookie, manga_b, 1).await;
// Pair manga A with a chapter from manga B — must be rejected.
let resp = h
.app
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({
"manga_id": manga_a.to_string(),
"chapter_id": chapter_of_b,
"page": 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");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_progress_on_never_read_manga_is_204(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, "Untouched").await;
let resp = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
// DELETE is idempotent — clearing nothing is still success.
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_progress_is_idempotent(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 _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
)
.await;
for _ in 0..2 {
let resp = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
}
#[sqlx::test(migrations = "./migrations")]
async fn deleted_chapter_leaves_progress_row_with_null_chapter(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 chapter_id_str = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let chapter_id = Uuid::parse_str(&chapter_id_str).unwrap();
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id_str, "page": 3 }),
)
.await;
// Delete the chapter directly — the FK ON DELETE SET NULL keeps
// the progress row but clears chapter_id.
sqlx::query("DELETE FROM chapters WHERE id = $1")
.bind(chapter_id)
.execute(&pool)
.await
.unwrap();
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let item = &body["items"][0];
assert!(item["chapter_id"].is_null(), "chapter_id should be null after cascade");
assert!(item["chapter_number"].is_null());
}
#[sqlx::test(migrations = "./migrations")]
async fn uploads_lists_manga_and_chapter_uploads_interleaved(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Two manga uploads with covers, then a chapter on one of them.
let m1 = common::seed_manga_via_api(&h.app, &cookie, "Alpha").await;
let _m2 = common::seed_manga_via_api(&h.app, &cookie, "Beta").await;
let _ = seed_chapter(&h.app, &cookie, m1, 1).await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/uploads", &cookie))
.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(), 3);
// Most recent first; the chapter upload happened after both mangas.
assert_eq!(items[0]["kind"], "chapter");
assert_eq!(items[1]["kind"], "manga");
assert_eq!(items[2]["kind"], "manga");
assert_eq!(body["page"]["total"], 3);
}
#[sqlx::test(migrations = "./migrations")]
async fn uploads_is_per_user_only(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let _ = common::seed_manga_via_api(&h.app, &a, "A's manga").await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/uploads", &b))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
assert_eq!(body["page"]["total"], 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn manga_create_stamps_uploaded_by_with_current_user(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, "Stamped").await;
let (uploaded_by,): (Option<Uuid>,) =
sqlx::query_as("SELECT uploaded_by FROM mangas WHERE id = $1")
.bind(manga_id)
.fetch_one(&pool)
.await
.unwrap();
assert!(uploaded_by.is_some(), "manga.uploaded_by should be set");
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_create_stamps_uploaded_by_with_current_user(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 chapter_id_str = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let (uploaded_by,): (Option<Uuid>,) =
sqlx::query_as("SELECT uploaded_by FROM chapters WHERE id = $1")
.bind(Uuid::parse_str(&chapter_id_str).unwrap())
.fetch_one(&pool)
.await
.unwrap();
assert!(uploaded_by.is_some(), "chapter.uploaded_by should be set");
}
#[sqlx::test(migrations = "./migrations")]
async fn read_progress_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
for path in [
"/api/v1/me/read-progress",
"/api/v1/me/uploads",
] {
let resp = h
.app
.clone()
.oneshot(common::get(path))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{path} should require auth");
}
}