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>
406 lines
14 KiB
Rust
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");
|
|
}
|
|
}
|