//! Integration tests for the bucketed trend-series queries + endpoints: //! `repo::crawl_metrics::series`, `repo::page_analysis::analysis_series`, and //! `GET /v1/admin/{crawler,analysis}/metrics/series`. mod common; use axum::http::StatusCode; use axum::Router; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use common::{body_json, get_with_cookie, harness, register_user}; use mangalord::repo::crawl_metrics::{self, Bucket}; use mangalord::repo::page_analysis; async fn seed_admin(pool: &PgPool, app: &Router) -> String { let (username, cookie) = register_user(app).await; let u = mangalord::repo::user::find_by_username(pool, &username) .await .unwrap() .unwrap(); mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true) .await .unwrap(); cookie } async fn insert_metric(pool: &PgPool, outcome: &str, duration_ms: i64, finished_at: &str) { sqlx::query( "INSERT INTO crawl_metrics (op, outcome, duration_ms, finished_at) \ VALUES ('manga_detail', $1, $2, $3::timestamptz)", ) .bind(outcome) .bind(duration_ms) .bind(finished_at) .execute(pool) .await .unwrap(); } #[sqlx::test(migrations = "./migrations")] async fn crawl_series_buckets_by_hour_and_day(pool: PgPool) { // Two rows in hour 09, one in hour 10 (same day). insert_metric(&pool, "ok", 100, "2026-06-18T09:10:00Z").await; insert_metric(&pool, "failed", 300, "2026-06-18T09:50:00Z").await; insert_metric(&pool, "ok", 200, "2026-06-18T10:05:00Z").await; let hourly = crawl_metrics::series(&pool, Bucket::Hour, None).await.unwrap(); assert_eq!(hourly.len(), 2, "two hour buckets"); // Ordered ascending; first bucket = hour 09 with 2 rows (1 ok / 1 failed). assert_eq!(hourly[0].n, 2); assert_eq!(hourly[0].ok, 1); assert_eq!(hourly[0].failed, 1); assert_eq!(hourly[0].avg_ms, Some(200.0)); // (100+300)/2 assert_eq!(hourly[1].n, 1); let daily = crawl_metrics::series(&pool, Bucket::Day, None).await.unwrap(); assert_eq!(daily.len(), 1, "all three collapse into one day bucket"); assert_eq!(daily[0].n, 3); assert_eq!(daily[0].failed, 1); } #[sqlx::test(migrations = "./migrations")] async fn crawl_series_respects_since_window(pool: PgPool) { insert_metric(&pool, "ok", 100, "2020-01-01T00:00:00Z").await; // ancient insert_metric(&pool, "ok", 100, "2026-06-18T10:00:00Z").await; // recent let since = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&chrono::Utc); let got = crawl_metrics::series(&pool, Bucket::Day, Some(since)) .await .unwrap(); assert_eq!(got.len(), 1, "only the in-window row"); } async fn seed_page(pool: &PgPool) -> Uuid { let manga = Uuid::new_v4(); let chapter = Uuid::new_v4(); let page = Uuid::new_v4(); sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, 'M')") .bind(manga) .execute(pool) .await .unwrap(); sqlx::query("INSERT INTO chapters (id, manga_id, number) VALUES ($1, $2, 1)") .bind(chapter) .bind(manga) .execute(pool) .await .unwrap(); sqlx::query( "INSERT INTO pages (id, chapter_id, page_number, storage_key, content_type) \ VALUES ($1, $2, 1, 'k', 'image/png')", ) .bind(page) .bind(chapter) .execute(pool) .await .unwrap(); page } async fn insert_analysis(pool: &PgPool, page_id: Uuid, status: &str, dur: i64, analyzed_at: &str) { sqlx::query( "INSERT INTO page_analysis (page_id, status, duration_ms, analyzed_at) \ VALUES ($1, $2, $3, $4::timestamptz)", ) .bind(page_id) .bind(status) .bind(dur) .bind(analyzed_at) .execute(pool) .await .unwrap(); } #[sqlx::test(migrations = "./migrations")] async fn analysis_series_splits_ok_failed_by_bucket(pool: PgPool) { let p1 = seed_page(&pool).await; let p2 = seed_page(&pool).await; let p3 = seed_page(&pool).await; insert_analysis(&pool, p1, "done", 500, "2026-06-18T09:00:00Z").await; insert_analysis(&pool, p2, "failed", 999, "2026-06-18T09:30:00Z").await; insert_analysis(&pool, p3, "done", 700, "2026-06-18T11:00:00Z").await; let hourly = page_analysis::analysis_series(&pool, Bucket::Hour, None) .await .unwrap(); assert_eq!(hourly.len(), 2); assert_eq!(hourly[0].n, 2); assert_eq!(hourly[0].ok, 1); assert_eq!(hourly[0].failed, 1); // Mean duration is done-only → 500 (the failed 999 is excluded). assert_eq!(hourly[0].avg_ms, Some(500.0)); } #[sqlx::test(migrations = "./migrations")] async fn crawler_series_endpoint_shape_and_gating(pool: PgPool) { let h = harness(pool.clone()); // Non-admin → 403. let (_u, plain) = register_user(&h.app).await; let resp = h .app .clone() .oneshot(get_with_cookie("/api/v1/admin/crawler/metrics/series", &plain)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); let cookie = seed_admin(&pool, &h.app).await; insert_metric(&pool, "ok", 100, "2026-06-18T09:10:00Z").await; let resp = h .app .clone() .oneshot(get_with_cookie( "/api/v1/admin/crawler/metrics/series?days=1", &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert!(body["buckets"].is_array()); // Bad bucket → 400. let resp = h .app .clone() .oneshot(get_with_cookie( "/api/v1/admin/crawler/metrics/series?bucket=year", &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[sqlx::test(migrations = "./migrations")] async fn analysis_series_endpoint_ok(pool: PgPool) { let h = harness(pool.clone()); let cookie = seed_admin(&pool, &h.app).await; let resp = h .app .oneshot(get_with_cookie( "/api/v1/admin/analysis/metrics/series?days=7", &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert!(body["buckets"].is_array()); }