Files
Mangalord/backend/tests/admin_metrics_series.rs
MechaCat02 c6a6d1690d feat(admin): time-series trend charts on the metrics tabs (0.87.0)
Turn the dormant timing data in crawl_metrics / page_analysis into "is it
healthy over time" views. New bucketed series queries (GROUP BY date_trunc,
hour|day via a closed Bucket enum so the unit can't be attacker-controlled)
behind GET /v1/admin/{crawler,analysis}/metrics/series, with a shared
SeriesParams/resolve_bucket helper (bad bucket → 400) and migration 0030
indexing page_analysis(analyzed_at) for the analysis scan.

Frontend: a dependency-free SVG TrendChart (line+area, null buckets render as
gaps, empty-state, role=img) embedded above the per-op tables in Crawler and
Analysis → Metrics, driven by each panel's existing window selector with
AbortController-cancelled fetches. A buildSeries() util fills the continuous
bucket axis (throughput 0 for empty buckets, success/duration null) — unit
tested alongside the chart and the series API client.

Closes the Phase-1 observability set (audit log · health checks · trends).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:48:56 +02:00

195 lines
6.2 KiB
Rust

//! 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());
}