//! Integration tests for the admin force-resync endpoints. //! //! Real resync work requires Chromium, so these tests swap in a stub //! [`ResyncService`] to assert the handler-level contract: routing, //! admin gate, 503 when the daemon is disabled, 404 / 422 mapping for //! missing-resource / no-source cases, and the audit-log side effect. mod common; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use axum::http::StatusCode; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use mangalord::crawler::resync::{ ChapterResyncOutcome, MangaResyncOutcome, ResyncError, ResyncService, }; use mangalord::repo; use mangalord::repo::crawler::UpsertStatus; /// Stub that records call counts and returns a canned outcome. struct StubResync { manga_calls: AtomicUsize, chapter_calls: AtomicUsize, /// When true, returns NoMangaSource / NoChapterSource. no_source: bool, } impl StubResync { fn new() -> Arc { Arc::new(Self { manga_calls: AtomicUsize::new(0), chapter_calls: AtomicUsize::new(0), no_source: false, }) } fn no_source() -> Arc { Arc::new(Self { manga_calls: AtomicUsize::new(0), chapter_calls: AtomicUsize::new(0), no_source: true, }) } } #[async_trait] impl ResyncService for StubResync { async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result { self.manga_calls.fetch_add(1, Ordering::SeqCst); if self.no_source { return Err(ResyncError::NoMangaSource.into()); } Ok(MangaResyncOutcome { manga_id, metadata_status: UpsertStatus::Updated, cover_fetched: true, }) } async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result { self.chapter_calls.fetch_add(1, Ordering::SeqCst); if self.no_source { return Err(ResyncError::NoChapterSource.into()); } Ok(ChapterResyncOutcome::Fetched { chapter_id, pages: 7, }) } } async fn promote_admin(pool: &PgPool, username: &str) { let u = repo::user::find_by_username(pool, username) .await .unwrap() .unwrap(); repo::user::set_is_admin_unchecked(pool, u.id, true) .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_chapter(pool: &PgPool, manga_id: Uuid, number: i32, pages: 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(pages) .fetch_one(pool) .await .unwrap(); id } // ----- manga resync --------------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn manga_resync_calls_service_and_returns_refreshed_detail(pool: PgPool) { let stub = StubResync::new(); let h = common::harness_with_resync(pool.clone(), stub.clone()); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let manga_id = insert_manga(&pool, "Hello").await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/mangas/{manga_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; // Stub returned Updated + cover_fetched=true. assert_eq!(body["metadata_status"], "updated"); assert_eq!(body["cover_fetched"], true); // Response includes the refreshed manga detail. assert_eq!(body["manga"]["id"], manga_id.to_string()); assert_eq!(body["manga"]["title"], "Hello"); assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 1); // Audit row written. let (audit_count,): (i64,) = sqlx::query_as("SELECT count(*) FROM admin_audit WHERE action = 'manga_resync' AND target_id = $1") .bind(manga_id) .fetch_one(&pool) .await .unwrap(); assert_eq!(audit_count, 1); } #[sqlx::test(migrations = "./migrations")] async fn manga_resync_returns_404_for_unknown_id(pool: PgPool) { let stub = StubResync::new(); let h = common::harness_with_resync(pool.clone(), stub.clone()); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/mangas/{}/resync", Uuid::new_v4()), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); // Service must not have been called when the manga doesn't exist. assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 0); } #[sqlx::test(migrations = "./migrations")] async fn manga_resync_maps_no_source_to_422(pool: PgPool) { let stub = StubResync::no_source(); let h = common::harness_with_resync(pool.clone(), stub); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let manga_id = insert_manga(&pool, "Manual upload, no crawler source").await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/mangas/{manga_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["details"]["manga"], "no_source"); } #[sqlx::test(migrations = "./migrations")] async fn manga_resync_returns_503_when_daemon_disabled(pool: PgPool) { let h = common::harness(pool.clone()); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let manga_id = insert_manga(&pool, "Z").await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/mangas/{manga_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "service_unavailable"); } #[sqlx::test(migrations = "./migrations")] async fn manga_resync_requires_admin(pool: PgPool) { let stub = StubResync::new(); let h = common::harness_with_resync(pool.clone(), stub); // Non-admin user. let (_u, cookie) = common::register_user(&h.app).await; let manga_id = insert_manga(&pool, "M").await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/mangas/{manga_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } // ----- chapter resync ------------------------------------------------------- #[sqlx::test(migrations = "./migrations")] async fn chapter_resync_calls_service_and_returns_refreshed_chapter(pool: PgPool) { let stub = StubResync::new(); let h = common::harness_with_resync(pool.clone(), stub.clone()); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let manga_id = insert_manga(&pool, "M").await; let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/chapters/{chapter_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["outcome"], "fetched"); assert_eq!(body["pages"], 7); assert_eq!(body["chapter"]["id"], chapter_id.to_string()); assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 1); let (audit_count,): (i64,) = sqlx::query_as( "SELECT count(*) FROM admin_audit WHERE action = 'chapter_resync' AND target_id = $1", ) .bind(chapter_id) .fetch_one(&pool) .await .unwrap(); assert_eq!(audit_count, 1); } #[sqlx::test(migrations = "./migrations")] async fn chapter_resync_returns_404_for_unknown_id(pool: PgPool) { let stub = StubResync::new(); let h = common::harness_with_resync(pool.clone(), stub.clone()); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/chapters/{}/resync", Uuid::new_v4()), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 0); } #[sqlx::test(migrations = "./migrations")] async fn chapter_resync_maps_no_source_to_422(pool: PgPool) { let stub = StubResync::no_source(); let h = common::harness_with_resync(pool.clone(), stub); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let manga_id = insert_manga(&pool, "M").await; let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/chapters/{chapter_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["details"]["chapter"], "no_source"); } #[sqlx::test(migrations = "./migrations")] async fn chapter_resync_returns_503_when_daemon_disabled(pool: PgPool) { let h = common::harness(pool.clone()); let (username, cookie) = common::register_user(&h.app).await; promote_admin(&pool, &username).await; let manga_id = insert_manga(&pool, "M").await; let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/chapters/{chapter_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); } #[sqlx::test(migrations = "./migrations")] async fn chapter_resync_requires_admin(pool: PgPool) { let stub = StubResync::new(); let h = common::harness_with_resync(pool.clone(), stub); let (_u, cookie) = common::register_user(&h.app).await; let manga_id = insert_manga(&pool, "M").await; let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await; let resp = h .app .oneshot(common::post_json_with_cookie( &format!("/api/v1/admin/chapters/{chapter_id}/resync"), json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); }