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>
195 lines
6.2 KiB
Rust
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());
|
|
}
|