mod common; use axum::http::StatusCode; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; #[sqlx::test(migrations = "./migrations")] async fn create_then_list_returns_only_own(pool: PgPool) { let h = common::harness(pool); let (_, cookie_a) = common::register_user(&h.app).await; let (_, cookie_b) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await; // User A bookmarks the manga. let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie_a, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let body = common::body_json(resp).await; assert_eq!(body["manga_id"], manga_id.to_string()); // User B sees nothing. let resp = h .app .clone() .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_b)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["items"], json!([])); // User A sees their bookmark. let resp = h .app .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_a)) .await .unwrap(); let body = common::body_json(resp).await; let items = body["items"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["manga_id"], manga_id.to_string()); } #[sqlx::test(migrations = "./migrations")] async fn create_returns_409_on_duplicate_manga_level(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 make = || { common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie, ) }; let first = h.app.clone().oneshot(make()).await.unwrap(); assert_eq!(first.status(), StatusCode::CREATED); let second = h.app.oneshot(make()).await.unwrap(); assert_eq!(second.status(), StatusCode::CONFLICT); let body = common::body_json(second).await; assert_eq!(body["error"]["code"], "conflict"); } #[sqlx::test(migrations = "./migrations")] async fn create_404_on_unknown_manga(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let unknown = Uuid::nil(); let resp = h .app .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": unknown.to_string() }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn create_rejects_non_positive_page_with_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 .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string(), "page": 0 }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "validation_failed"); assert!(body["error"]["details"]["page"].is_string()); let resp = h .app .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string(), "page": -1 }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[sqlx::test(migrations = "./migrations")] async fn create_requires_authentication(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; // Unauthenticated request → 401. let resp = h .app .oneshot(common::post_json( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn user_a_cannot_delete_user_b_bookmark(pool: PgPool) { let h = common::harness(pool); let (_, cookie_a) = common::register_user(&h.app).await; let (_, cookie_b) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await; // User A creates a bookmark. let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie_a, )) .await .unwrap(); let body = common::body_json(resp).await; let id = body["id"].as_str().unwrap().to_string(); // User B tries to delete → 403. let resp = h .app .clone() .oneshot(common::delete_with_cookie( &format!("/api/v1/bookmarks/{id}"), &cookie_b, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "forbidden"); // User A succeeds. let resp = h .app .oneshot(common::delete_with_cookie( &format!("/api/v1/bookmarks/{id}"), &cookie_a, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } #[sqlx::test(migrations = "./migrations")] async fn concurrent_manga_bookmarks_serialised_by_unique_index(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 app_a = h.app.clone(); let cookie_a = cookie.clone(); let mid_a = manga_id.to_string(); let app_b = h.app.clone(); let cookie_b = cookie.clone(); let mid_b = manga_id.to_string(); let f1 = tokio::spawn(async move { app_a .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": mid_a }), &cookie_a, )) .await .unwrap() .status() }); let f2 = tokio::spawn(async move { app_b .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": mid_b }), &cookie_b, )) .await .unwrap() .status() }); let (s1, s2) = tokio::join!(f1, f2); let statuses = [s1.unwrap(), s2.unwrap()]; assert!( statuses.contains(&StatusCode::CREATED), "expected one winner with 201, got {statuses:?}" ); assert!( statuses.contains(&StatusCode::CONFLICT), "expected one loser with 409 (the partial unique index), got {statuses:?}" ); } #[sqlx::test(migrations = "./migrations")] async fn bookmark_create_accepts_bearer_token(pool: PgPool) { // Bot scripts use Authorization: Bearer; cover that path on a // *write* endpoint to make sure CurrentUser resolves identically // whether the credential is a cookie or a bearer. 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 .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", json!({ "name": "ci-bot" }), &cookie, )) .await .unwrap(); let bearer = common::body_json(resp).await["bearer"] .as_str() .unwrap() .to_string(); let resp = h .app .oneshot(common::post_json_with_bearer( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &bearer, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); } #[sqlx::test(migrations = "./migrations")] async fn delete_unknown_bookmark_is_404(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::delete_with_cookie( "/api/v1/bookmarks/00000000-0000-0000-0000-000000000000", &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn delete_already_deleted_bookmark_is_404(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 .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); let id = common::body_json(resp).await["id"].as_str().unwrap().to_string(); let resp = h .app .clone() .oneshot(common::delete_with_cookie( &format!("/api/v1/bookmarks/{id}"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Deleting again → 404, not 500. let resp = h .app .oneshot(common::delete_with_cookie( &format!("/api/v1/bookmarks/{id}"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn list_me_requires_authentication(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::get("/api/v1/me/bookmarks")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn list_me_enriches_chapter_bookmarks_with_chapter_number(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; // Seed a chapter directly so we know its number without uploading pages. mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand"), None) .await .unwrap(); // Look up its id so we can bookmark it. let chapter_id: Uuid = sqlx::query_scalar( "SELECT id FROM chapters WHERE manga_id = $1 AND number = $2", ) .bind(manga_id) .bind(7_i32) .fetch_one(&pool) .await .unwrap(); // Bookmark the chapter at page 4. let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id.to_string(), "page": 4 }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // Also bookmark the manga at the manga level — chapter_number must // come back null for it, populated for the chapter one. let _ = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); let resp = h .app .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie)) .await .unwrap(); let body = common::body_json(resp).await; let items = body["items"].as_array().unwrap(); assert_eq!(items.len(), 2); let chapter_bookmark = items .iter() .find(|b| !b["chapter_id"].is_null()) .expect("chapter-level bookmark present"); assert_eq!(chapter_bookmark["chapter_number"], 7); assert_eq!(chapter_bookmark["page"], 4); // Manga title is JOINed in too so the /bookmarks page can render // a real card instead of "Manga bookmark". assert_eq!(chapter_bookmark["manga_title"], "Berserk"); // No cover was uploaded for the seeded manga — null is correct. assert!(chapter_bookmark["manga_cover_image_path"].is_null()); let manga_bookmark = items .iter() .find(|b| b["chapter_id"].is_null()) .expect("manga-level bookmark present"); assert!( manga_bookmark["chapter_number"].is_null(), "manga-level bookmark must have a null chapter_number" ); assert_eq!(manga_bookmark["manga_title"], "Berserk"); } #[sqlx::test(migrations = "./migrations")] async fn list_me_returns_paged_envelope(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert!(body["items"].is_array()); assert_eq!(body["page"]["limit"], 50); assert_eq!(body["page"]["offset"], 0); // `total` is the unfiltered row count, returned so callers (e.g. // the profile overview's bookmark counter) can show a number // without paging through. assert_eq!(body["page"]["total"], 0); } // ------------------------------------------------------------------------- // Bookmark create -> SyncChapterContent job enqueue (background task) // ------------------------------------------------------------------------- async fn seed_chapter_with_source( pool: &PgPool, manga_id: Uuid, number: i32, source_id: &str, source_chapter_key: &str, source_url: &str, dropped: bool, ) -> Uuid { let chapter_id: Uuid = mangalord::repo::chapter::create(pool, manga_id, number, None, None) .await .unwrap() .id; sqlx::query("INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING") .bind(source_id) .bind(source_id) .bind("https://example.com") .execute(pool) .await .unwrap(); let dropped_at = if dropped { "now()" } else { "NULL" }; sqlx::query(&format!( "INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url, dropped_at) \ VALUES ($1, $2, $3, $4, {dropped_at})" )) .bind(source_id) .bind(source_chapter_key) .bind(chapter_id) .bind(source_url) .execute(pool) .await .unwrap(); chapter_id } /// Poll `crawler_jobs` for the expected pending count, up to ~1.5s, so the /// detached `tokio::spawn` from the bookmark create handler has time to /// land regardless of CI scheduling jitter. async fn wait_for_pending_count(pool: &PgPool, expected: i64) -> i64 { for _ in 0..30 { let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM crawler_jobs \ WHERE state = 'pending' \ AND payload->>'kind' = 'sync_chapter_content'", ) .fetch_one(pool) .await .unwrap(); if count >= expected { return count; } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM crawler_jobs \ WHERE state = 'pending' \ AND payload->>'kind' = 'sync_chapter_content'", ) .fetch_one(pool) .await .unwrap() } #[sqlx::test(migrations = "./migrations")] async fn create_enqueues_sync_chapter_content_jobs_for_pending_chapters(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; // Two zero-page chapters with non-dropped sources. let c1 = seed_chapter_with_source(&pool, manga_id, 1, "target", "ch1", "https://example.com/c1", false).await; let c2 = seed_chapter_with_source(&pool, manga_id, 2, "target", "ch2", "https://example.com/c2", false).await; let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let count = wait_for_pending_count(&pool, 2).await; assert_eq!(count, 2, "both pending chapters should be enqueued"); let chapter_ids: Vec = sqlx::query_scalar( "SELECT payload->>'chapter_id' FROM crawler_jobs \ WHERE payload->>'kind' = 'sync_chapter_content' \ ORDER BY payload->>'chapter_id'", ) .fetch_all(&pool) .await .unwrap(); let mut expected = vec![c1.to_string(), c2.to_string()]; expected.sort(); assert_eq!(chapter_ids, expected); } #[sqlx::test(migrations = "./migrations")] async fn re_bookmark_after_delete_does_not_re_enqueue_pending_jobs(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 _ = seed_chapter_with_source(&pool, manga_id, 1, "target", "ch1", "https://example.com/c1", false).await; // First bookmark — should enqueue 1. let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); let bookmark_id = common::body_json(resp).await["id"].as_str().unwrap().to_string(); assert_eq!(wait_for_pending_count(&pool, 1).await, 1); // Delete the bookmark, then re-bookmark — the existing pending job // is still there so the dedup index suppresses the second enqueue. let resp = h .app .clone() .oneshot(common::delete_with_cookie( &format!("/api/v1/bookmarks/{bookmark_id}"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // Give the background task time to attempt re-enqueue (it should be a no-op). tokio::time::sleep(std::time::Duration::from_millis(300)).await; let final_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM crawler_jobs \ WHERE state IN ('pending', 'running') \ AND payload->>'kind' = 'sync_chapter_content'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(final_count, 1, "dedup index keeps the queue at a single in-flight row"); } #[sqlx::test(migrations = "./migrations")] async fn create_skips_chapters_with_dropped_sources(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 _alive = seed_chapter_with_source(&pool, manga_id, 1, "target", "ch1", "https://example.com/c1", false).await; let _dropped = seed_chapter_with_source(&pool, manga_id, 2, "target", "ch2", "https://example.com/c2", true).await; let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/bookmarks", json!({ "manga_id": manga_id.to_string() }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); assert_eq!( wait_for_pending_count(&pool, 1).await, 1, "only the chapter with a non-dropped source row gets enqueued" ); }