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,) = 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,) = 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"); } }