//! PR 3 (feat/admin-mangas-api) integration tests. //! //! Per-variant fixture tests for the derived sync-state SQL plus //! happy-path E2E for the two admin endpoints. Auth-gate regression //! (403/401) is covered by PR 1's `RequireAdmin` test matrix; the only //! gate test here is one spot check per endpoint. mod common; use axum::http::StatusCode; use axum::Router; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use mangalord::repo; const SOURCE_ID: &str = "test-source"; async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String) { let (username, cookie) = common::register_user(app).await; let u = repo::user::find_by_username(pool, &username) .await .unwrap() .unwrap(); repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap(); (username, cookie) } async fn seed_source(pool: &PgPool) { repo::crawler::ensure_source(pool, SOURCE_ID, "Test", "https://example.test") .await .unwrap(); } async fn insert_manga(pool: &PgPool, title: &str) -> Uuid { let (id,): (Uuid,) = sqlx::query_as( "INSERT INTO mangas (title, status, alt_titles) VALUES ($1, 'ongoing', ARRAY[]::text[]) RETURNING id", ) .bind(title) .fetch_one(pool) .await .unwrap(); id } async fn insert_manga_source( pool: &PgPool, manga_id: Uuid, source_manga_key: &str, dropped: bool, ) { let dropped_at = if dropped { "now()" } else { "NULL" }; let sql = format!( "INSERT INTO manga_sources (source_id, source_manga_key, manga_id, source_url, dropped_at) \ VALUES ($1, $2, $3, 'https://example.test/m', {dropped_at})" ); sqlx::query(&sql) .bind(SOURCE_ID) .bind(source_manga_key) .bind(manga_id) .execute(pool) .await .unwrap(); } async fn insert_chapter(pool: &PgPool, manga_id: Uuid, number: i32, page_count: i32) -> Uuid { let (id,): (Uuid,) = sqlx::query_as( "INSERT INTO chapters (manga_id, number, title, page_count) VALUES ($1, $2, NULL, $3) RETURNING id", ) .bind(manga_id) .bind(number) .bind(page_count) .fetch_one(pool) .await .unwrap(); id } async fn insert_chapter_source( pool: &PgPool, chapter_id: Uuid, source_chapter_key: &str, dropped: bool, ) { let dropped_at = if dropped { "now()" } else { "NULL" }; let sql = format!( "INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url, dropped_at) \ VALUES ($1, $2, $3, 'https://example.test/c', {dropped_at})" ); sqlx::query(&sql) .bind(SOURCE_ID) .bind(source_chapter_key) .bind(chapter_id) .execute(pool) .await .unwrap(); } async fn insert_job(pool: &PgPool, payload: serde_json::Value, state: &str) { sqlx::query("INSERT INTO crawler_jobs (payload, state) VALUES ($1, $2)") .bind(payload) .bind(state) .execute(pool) .await .unwrap(); } /// Per-variant tests don't care about pagination — fetch the whole /// chapter set (up to the hard cap) and discard the total. async fn fetch_chapter_rows( pool: &PgPool, manga_id: Uuid, ) -> Vec { let (rows, _) = repo::admin_view::list_chapters_with_sync_state( pool, &repo::admin_view::ListAdminChaptersQuery { manga_id, limit: 500, offset: 0, }, ) .await .unwrap(); rows } // ---- manga sync state ------------------------------------------------------ #[sqlx::test(migrations = "./migrations")] async fn manga_state_synced_for_fresh_source(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "Synced Manga").await; insert_manga_source(&pool, m, "smk-1", false).await; let (rows, total) = repo::admin_view::list_mangas_with_sync_state( &pool, &repo::admin_view::ListAdminMangasQuery { limit: 50, ..Default::default() }, ) .await .unwrap(); assert_eq!(total, 1); assert_eq!(rows[0].id, m); assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Synced); } #[sqlx::test(migrations = "./migrations")] async fn manga_state_synced_for_user_upload_without_sources(pool: PgPool) { let m = insert_manga(&pool, "User Upload").await; let (rows, _) = repo::admin_view::list_mangas_with_sync_state( &pool, &repo::admin_view::ListAdminMangasQuery { limit: 50, ..Default::default() }, ) .await .unwrap(); assert_eq!(rows[0].id, m); assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Synced); } #[sqlx::test(migrations = "./migrations")] async fn manga_state_dropped_when_all_sources_dropped(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "Dropped Manga").await; insert_manga_source(&pool, m, "smk-1", true).await; let (rows, _) = repo::admin_view::list_mangas_with_sync_state( &pool, &repo::admin_view::ListAdminMangasQuery { limit: 50, ..Default::default() }, ) .await .unwrap(); assert_eq!(rows[0].id, m); assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Dropped); } #[sqlx::test(migrations = "./migrations")] async fn manga_state_in_progress_via_sync_chapter_list_job(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "Syncing Manga").await; insert_manga_source(&pool, m, "smk-1", false).await; // sync_chapter_list payload carries manga_id directly. insert_job( &pool, json!({ "kind": "sync_chapter_list", "source_id": SOURCE_ID, "manga_id": m.to_string(), "source_manga_key": "smk-1", }), "pending", ) .await; let (rows, _) = repo::admin_view::list_mangas_with_sync_state( &pool, &repo::admin_view::ListAdminMangasQuery { limit: 50, ..Default::default() }, ) .await .unwrap(); assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::InProgress); } #[sqlx::test(migrations = "./migrations")] async fn manga_state_in_progress_via_sync_manga_job(pool: PgPool) { // The trickier branch: sync_manga payload is keyed by // source_manga_key, NOT manga_id — must join through manga_sources. seed_source(&pool).await; let m = insert_manga(&pool, "Metadata-Refreshing Manga").await; insert_manga_source(&pool, m, "smk-key-42", false).await; insert_job( &pool, json!({ "kind": "sync_manga", "source_id": SOURCE_ID, "source_manga_key": "smk-key-42", }), "running", ) .await; let (rows, _) = repo::admin_view::list_mangas_with_sync_state( &pool, &repo::admin_view::ListAdminMangasQuery { limit: 50, ..Default::default() }, ) .await .unwrap(); assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::InProgress); } #[sqlx::test(migrations = "./migrations")] async fn manga_list_filters_by_sync_state(pool: PgPool) { seed_source(&pool).await; let m_synced = insert_manga(&pool, "AAA Synced").await; insert_manga_source(&pool, m_synced, "smk-a", false).await; let m_dropped = insert_manga(&pool, "BBB Dropped").await; insert_manga_source(&pool, m_dropped, "smk-b", true).await; let (rows, total) = repo::admin_view::list_mangas_with_sync_state( &pool, &repo::admin_view::ListAdminMangasQuery { sync_state: Some(mangalord::domain::MangaSyncState::Dropped), limit: 50, ..Default::default() }, ) .await .unwrap(); assert_eq!(total, 1); assert_eq!(rows.len(), 1); assert_eq!(rows[0].id, m_dropped); } // ---- chapter sync state ---------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn chapter_state_synced_when_pages_present(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "M").await; insert_manga_source(&pool, m, "smk", false).await; let c = insert_chapter(&pool, m, 1, 12).await; insert_chapter_source(&pool, c, "ckey-1", false).await; let rows = fetch_chapter_rows(&pool, m).await; assert_eq!(rows.len(), 1); assert_eq!(rows[0].id, c); assert_eq!(rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced); } #[sqlx::test(migrations = "./migrations")] async fn chapter_state_not_downloaded_when_page_count_zero(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "M").await; let c = insert_chapter(&pool, m, 1, 0).await; insert_chapter_source(&pool, c, "ckey-1", false).await; let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::NotDownloaded ); } #[sqlx::test(migrations = "./migrations")] async fn chapter_state_downloading_when_job_in_flight(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "M").await; let c = insert_chapter(&pool, m, 1, 0).await; insert_chapter_source(&pool, c, "ckey-1", false).await; insert_job( &pool, json!({ "kind": "sync_chapter_content", "source_id": SOURCE_ID, "chapter_id": c.to_string(), "source_chapter_key": "ckey-1", }), "running", ) .await; let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Downloading ); } #[sqlx::test(migrations = "./migrations")] async fn chapter_state_dropped_when_all_sources_dropped(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "M").await; let c = insert_chapter(&pool, m, 1, 0).await; insert_chapter_source(&pool, c, "ckey-1", true).await; let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Dropped ); } #[sqlx::test(migrations = "./migrations")] async fn chapter_state_failed_when_most_recent_job_dead(pool: PgPool) { seed_source(&pool).await; let m = insert_manga(&pool, "M").await; let c = insert_chapter(&pool, m, 1, 0).await; insert_chapter_source(&pool, c, "ckey-1", false).await; insert_job( &pool, json!({ "kind": "sync_chapter_content", "source_id": SOURCE_ID, "chapter_id": c.to_string(), "source_chapter_key": "ckey-1", }), "dead", ) .await; let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Failed ); } // ---- HTTP-level happy-path + gate ------------------------------------------ #[sqlx::test(migrations = "./migrations")] async fn http_list_mangas_returns_paged_with_state(pool: PgPool) { let h = common::harness(pool.clone()); let (_admin, cookie) = seed_admin(&pool, &h.app).await; seed_source(&pool).await; let m = insert_manga(&pool, "Hello").await; insert_manga_source(&pool, m, "smk", false).await; let resp = h .app .oneshot(common::get_with_cookie( "/api/v1/admin/mangas?limit=50", &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(), 1); assert_eq!(items[0]["id"], m.to_string()); assert_eq!(items[0]["sync_state"], "synced"); assert_eq!(items[0]["chapter_count"], 0); assert_eq!(body["page"]["total"], 1); } #[sqlx::test(migrations = "./migrations")] async fn http_list_mangas_rejects_unknown_sync_state(pool: PgPool) { let h = common::harness(pool.clone()); let (_admin, cookie) = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(common::get_with_cookie( "/api/v1/admin/mangas?sync_state=bogus", &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[sqlx::test(migrations = "./migrations")] async fn http_list_chapters_returns_per_chapter_state(pool: PgPool) { let h = common::harness(pool.clone()); let (_admin, cookie) = seed_admin(&pool, &h.app).await; seed_source(&pool).await; let m = insert_manga(&pool, "M").await; let c1 = insert_chapter(&pool, m, 1, 12).await; let c2 = insert_chapter(&pool, m, 2, 0).await; insert_chapter_source(&pool, c1, "ck1", false).await; insert_chapter_source(&pool, c2, "ck2", false).await; let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/admin/mangas/{m}/chapters"), &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(), 2); assert_eq!(items[0]["id"], c1.to_string()); assert_eq!(items[0]["sync_state"], "synced"); assert_eq!(items[1]["id"], c2.to_string()); assert_eq!(items[1]["sync_state"], "not_downloaded"); assert_eq!(body["page"]["total"], 2); } #[sqlx::test(migrations = "./migrations")] async fn http_list_chapters_caps_limit_at_500(pool: PgPool) { // The handler clamps limit to [1, 500] so a long-runner with // thousands of chapters can't be turned into a request-stall by an // admin (or by a curious admin tab) just clicking expand. let h = common::harness(pool.clone()); let (_admin, cookie) = seed_admin(&pool, &h.app).await; seed_source(&pool).await; let m = insert_manga(&pool, "M").await; for n in 1..=3 { let _c = insert_chapter(&pool, m, n, 0).await; } let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/admin/mangas/{m}/chapters?limit=999"), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["page"]["limit"], 500, "limit must clamp to 500"); assert_eq!(body["items"].as_array().unwrap().len(), 3); } #[sqlx::test(migrations = "./migrations")] async fn http_list_chapters_paginates(pool: PgPool) { let h = common::harness(pool.clone()); let (_admin, cookie) = seed_admin(&pool, &h.app).await; seed_source(&pool).await; let m = insert_manga(&pool, "M").await; for n in 1..=5 { let _c = insert_chapter(&pool, m, n, 0).await; } let resp = h .app .clone() .oneshot(common::get_with_cookie( &format!("/api/v1/admin/mangas/{m}/chapters?limit=2&offset=2"), &cookie, )) .await .unwrap(); let body = common::body_json(resp).await; let items = body["items"].as_array().unwrap(); assert_eq!(items.len(), 2); // Ordered by chapter number ascending; offset=2 skips chapters 1 & 2. assert_eq!(items[0]["number"], 3); assert_eq!(items[1]["number"], 4); assert_eq!(body["page"]["total"], 5); } #[sqlx::test(migrations = "./migrations")] async fn http_list_chapters_returns_404_for_unknown_manga(pool: PgPool) { // Regression: used to return 200 [] for a non-existent manga, // which silently rendered "No chapters." for a typo'd / deleted id. let h = common::harness(pool.clone()); let (_admin, cookie) = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(common::get_with_cookie( &format!("/api/v1/admin/mangas/{}/chapters", Uuid::new_v4()), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "./migrations")] async fn chapter_state_synced_when_pages_present_even_with_dead_job(pool: PgPool) { // Regression: the old CASE prioritised the dead-job branch above // the page_count check, so a chapter with pages on disk AND a // historical dead job (e.g. from a re-download attempt that // crashed) flipped to Failed — contradicting Synced's "downloaded // at some point" contract. seed_source(&pool).await; let m = insert_manga(&pool, "M").await; let c = insert_chapter(&pool, m, 1, 12).await; // pages present insert_chapter_source(&pool, c, "ckey-1", false).await; insert_job( &pool, json!({ "kind": "sync_chapter_content", "source_id": SOURCE_ID, "chapter_id": c.to_string(), "source_chapter_key": "ckey-1", }), "dead", ) .await; let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced, "pages on disk override historical dead-job noise" ); } #[sqlx::test(migrations = "./migrations")] async fn http_list_mangas_requires_admin(pool: PgPool) { let h = common::harness(pool); let (_u, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::get_with_cookie("/api/v1/admin/mangas", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); }