//! A2: `ExecutionLogRepository::summarize_for_app` — the read model behind the //! observability dashboard. Seeds a known set of `execution_logs` rows for one //! app and asserts the rollup: totals, error rate, latency mean + percentiles, //! the by-status / by-source breakdowns, and the hourly series. Scoped to a //! unique `app_id`, so it is unaffected by any other rows in a shared DB. //! Skips cleanly when `DATABASE_URL` is unset. use picloud_manager_core::repo::{ExecutionLogRepository, PostgresExecutionLogRepository}; use picloud_shared::AppId; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use uuid::Uuid; async fn pool_or_skip() -> Option { let Ok(url) = std::env::var("DATABASE_URL") else { picloud_test_support::abort_if_db_required("metrics_summary"); eprintln!("metrics_summary: DATABASE_URL unset — skipping"); return None; }; let pool = PgPoolOptions::new() .max_connections(2) .connect(&url) .await .expect("connect to DATABASE_URL"); sqlx::migrate!("./migrations") .run(&pool) .await .expect("apply migrations"); Some(pool) } async fn make_app(pool: &PgPool) -> Uuid { // `apps.group_id` is NOT NULL, so anchor the app under a fresh root group. let gslug = format!("metrics-g-{}", Uuid::new_v4()); let group: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id") .bind(&gslug) .fetch_one(pool) .await .expect("group insert"); let slug = format!("metrics-{}", Uuid::new_v4()); let row: (Uuid,) = sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") .bind(&slug) .bind(group.0) .fetch_one(pool) .await .expect("app insert"); row.0 } async fn seed(pool: &PgPool, app: Uuid, duration_ms: i32, status: &str, source: &str) { sqlx::query( "INSERT INTO execution_logs (app_id, request_id, duration_ms, status, source) \ VALUES ($1, gen_random_uuid(), $2, $3, $4)", ) .bind(app) .bind(duration_ms) .bind(status) .bind(source) .execute(pool) .await .expect("seed execution_logs row"); } fn count_of(counts: &[picloud_manager_core::repo::MetricsCount], key: &str) -> i64 { counts.iter().find(|c| c.key == key).map_or(0, |c| c.count) } #[tokio::test] async fn summarize_rolls_up_counts_error_rate_and_percentiles() { let Some(pool) = pool_or_skip().await else { return; }; let app = make_app(&pool).await; // 3 successes (http), 1 error (kv), 1 timeout (cron). Durations chosen so // the percentiles are exact: sorted [10, 20, 30, 50, 100]. seed(&pool, app, 10, "success", "http").await; seed(&pool, app, 20, "success", "http").await; seed(&pool, app, 30, "success", "http").await; seed(&pool, app, 100, "error", "kv").await; seed(&pool, app, 50, "timeout", "cron").await; let repo = PostgresExecutionLogRepository::new(pool.clone()); let s = repo .summarize_for_app(AppId::from(app), 24) .await .expect("summarize"); assert_eq!(s.total, 5, "total executions"); // Every non-success terminal state counts as an error: error + timeout = 2. assert_eq!(s.errors, 2, "error count"); assert!( (s.error_rate - 0.4).abs() < 1e-9, "error rate: {}", s.error_rate ); // mean of [10,20,30,100,50] = 42. assert!( (s.avg_duration_ms - 42.0).abs() < 1e-6, "avg: {}", s.avg_duration_ms ); // percentile_cont(0.5) of the 5 sorted values = 30. assert!( (s.p50_duration_ms - 30.0).abs() < 1e-6, "p50: {}", s.p50_duration_ms ); // percentile_cont(0.95): 0.95*(n-1)=3.8 → interpolate 50..100 → 90. assert!( (s.p95_duration_ms - 90.0).abs() < 1e-6, "p95: {}", s.p95_duration_ms ); assert_eq!(count_of(&s.by_status, "success"), 3); assert_eq!(count_of(&s.by_status, "error"), 1); assert_eq!(count_of(&s.by_status, "timeout"), 1); assert_eq!(count_of(&s.by_source, "http"), 3); assert_eq!(count_of(&s.by_source, "kv"), 1); assert_eq!(count_of(&s.by_source, "cron"), 1); // All rows land in the current hour bucket; the series sums back to the total. assert!(!s.series.is_empty(), "series has at least one bucket"); assert_eq!(s.series.iter().map(|b| b.total).sum::(), 5); assert_eq!(s.series.iter().map(|b| b.errors).sum::(), 2); } #[tokio::test] async fn summarize_of_an_empty_app_is_all_zero() { let Some(pool) = pool_or_skip().await else { return; }; let app = make_app(&pool).await; let repo = PostgresExecutionLogRepository::new(pool.clone()); let s = repo .summarize_for_app(AppId::from(app), 24) .await .expect("summarize"); assert_eq!(s.total, 0); assert_eq!(s.errors, 0); assert!( (s.error_rate - 0.0).abs() < 1e-9, "no executions → zero rate" ); assert!(s.series.is_empty()); }