Add `new_chapters_count` to each `GET /me/read-progress` item: how many chapters sit past the reader's last-read chapter, computed by a correlated subquery on chapter number. It is 0 when the reader is caught up, and 0 when the last-read chapter is unknown (manga-level progress or a deleted chapter) so we never over-claim. (manga_id, number) is non-unique, so this counts positions past the reader rather than distinct files. Backs the personal "new since last read" indicator on the upcoming Continue-reading shelf. Tests cover the mid-series, caught-up, and no-read-chapter cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
484 lines
16 KiB
Rust
484 lines
16 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 list_reports_new_chapters_since_last_read(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;
|
|
// Three chapters exist; the reader is on chapter 1, so chapters 2 and
|
|
// 3 are "new since last read".
|
|
let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await;
|
|
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
|
|
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
|
|
let _ = upsert_progress(
|
|
&h.app,
|
|
&cookie,
|
|
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }),
|
|
)
|
|
.await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["items"][0]["new_chapters_count"], 2);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn list_new_chapters_count_is_zero_at_latest_chapter(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 _ = seed_chapter(&h.app, &cookie, manga_id, 1).await;
|
|
let ch2 = seed_chapter(&h.app, &cookie, manga_id, 2).await;
|
|
// Caught up to the last chapter → nothing newer.
|
|
let _ = upsert_progress(
|
|
&h.app,
|
|
&cookie,
|
|
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch2, "page": 1 }),
|
|
)
|
|
.await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["items"][0]["new_chapters_count"], 0);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn list_new_chapters_count_is_zero_without_a_read_chapter(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 _ = seed_chapter(&h.app, &cookie, manga_id, 1).await;
|
|
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
|
|
// Progress recorded without a chapter (manga-level) — we can't place
|
|
// the reader among the chapters, so we don't claim any are "new".
|
|
let _ = upsert_progress(
|
|
&h.app,
|
|
&cookie,
|
|
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
|
|
)
|
|
.await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
|
|
.await
|
|
.unwrap();
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["items"][0]["new_chapters_count"], 0);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|