From 0e3fb4ed61bf67add0b2f04cdf232fd8f3212f6a Mon Sep 17 00:00:00 2001 From: Sergio Arroutbi Date: Tue, 12 May 2026 12:12:55 +0200 Subject: [PATCH] Separate timeout from fail in attestation summary and timeline Timeout (push-mode state 103) was grouped with Fail (101) as a single "failed" category. The SRS (FR-001, FR-024) and SDD (3.7.1, 3.7.2) require Timeout to be reported separately so the frontend can render it in orange, distinct from red for Fail. - Add `total_timed_out` to AttestationSummary and `timed_out` to TimelineBucket - Add `is_timeout()` helper to AgentState - Change `query_counts` to return (successful, failed, timed_out) triple across both FallbackAttestationRepository and SQLite - Update `query_timeline` to distribute timed_out as a third series - Update success_rate formula to exclude both failed and timed_out from the numerator - Add unit tests for three-way split at handler, fallback repo, and SQLite repo levels Co-Authored-By: Claude Opus 4.6 Signed-off-by: Sergio Arroutbi --- src/api/handlers/attestations.rs | 112 ++++++++++++++++++++++++++- src/models/agent.rs | 4 + src/models/attestation.rs | 1 + src/models/kpi.rs | 3 + src/repository/attestation.rs | 105 ++++++++++++++++++++++--- src/repository/repository_tests.rs | 16 +++- src/repository/sqlite/attestation.rs | 92 +++++++++++++++++++--- 7 files changed, 305 insertions(+), 28 deletions(-) diff --git a/src/api/handlers/attestations.rs b/src/api/handlers/attestations.rs index ac1f6b6..e2f0952 100644 --- a/src/api/handlers/attestations.rs +++ b/src/api/handlers/attestations.rs @@ -156,15 +156,16 @@ pub async fn get_summary( let (keylime_successful, keylime_consecutive) = sum_keylime_attestation_counts(&state).await; - let (_, repo_failed) = state + let (_, repo_failed, repo_timed_out) = state .attestation_repo .query_counts(range_start, range_end) .await?; let total_successful = keylime_successful; let total_failed = repo_failed.max(keylime_consecutive); + let total_timed_out = repo_timed_out; - let total = total_successful + total_failed; + let total = total_successful + total_failed + total_timed_out; let success_rate = if total > 0 { (total_successful as f64 / total as f64) * 100.0 } else { @@ -174,6 +175,7 @@ pub async fn get_summary( Ok(Json(ApiResponse::ok(AttestationSummary { total_successful, total_failed, + total_timed_out, average_latency_ms: 0.0, success_rate, }))) @@ -190,17 +192,24 @@ pub async fn get_timeline( let (keylime_successful, keylime_consecutive) = sum_keylime_attestation_counts(&state).await; - let (_, repo_failed) = state + let (_, repo_failed, repo_timed_out) = state .attestation_repo .query_counts(range_start, range_end) .await?; let fallback_successful = keylime_successful; let fallback_failed = repo_failed.max(keylime_consecutive); + let fallback_timed_out = repo_timed_out; let buckets = state .attestation_repo - .query_timeline(range_start, range_end, fallback_successful, fallback_failed) + .query_timeline( + range_start, + range_end, + fallback_successful, + fallback_failed, + fallback_timed_out, + ) .await?; Ok(Json(ApiResponse::ok(buckets))) @@ -647,4 +656,99 @@ mod tests { "when repo has no data (e.g. fresh restart), keylime consecutive should be used" ); } + + fn push_fail_agent() -> VerifierAgent { + VerifierAgent { + agent_id: "b1b2c3d4-0000-1111-2222-333344445555".into(), + attestation_status: Some("FAIL".into()), + accept_attestations: Some(false), + attestation_count: Some(50), + consecutive_attestation_failures: Some(3), + ..default_verifier() + } + } + + #[test] + fn fail_agent_produces_non_timeout_failure() { + let agent = push_fail_agent(); + let state = AgentState::from_push_agent(&agent); + assert_eq!(state, AgentState::Fail); + assert!(state.is_failed()); + assert!(!state.is_timeout()); + + let result = build_attestation_result(&agent, state).unwrap(); + assert!(!result.success); + assert_ne!(result.failure_type, Some(FailureType::Timeout)); + } + + #[test] + fn timeout_state_is_timeout_but_also_failed() { + let state = AgentState::Timeout; + assert!(state.is_failed()); + assert!(state.is_timeout()); + } + + #[test] + fn fail_state_is_failed_but_not_timeout() { + let state = AgentState::Fail; + assert!(state.is_failed()); + assert!(!state.is_timeout()); + } + + #[tokio::test] + async fn three_way_split_counts_pass_fail_timeout_separately() { + let repo = FallbackAttestationRepository::new(); + + let pass = build_attestation_result(&push_pass_agent(), AgentState::Pass).unwrap(); + let fail = build_attestation_result(&push_fail_agent(), AgentState::Fail).unwrap(); + let timeout = build_attestation_result(&push_timeout_agent(), AgentState::Timeout).unwrap(); + + assert!(pass.success); + assert!(!fail.success); + assert_ne!(fail.failure_type, Some(FailureType::Timeout)); + assert!(!timeout.success); + assert_eq!(timeout.failure_type, Some(FailureType::Timeout)); + + for _ in 0..10 { + repo.store_result(&pass).await.unwrap(); + } + for _ in 0..3 { + repo.store_result(&fail).await.unwrap(); + } + for _ in 0..2 { + repo.store_result(&timeout).await.unwrap(); + } + + let start = Utc::now() - chrono::Duration::hours(1); + let end = Utc::now() + chrono::Duration::hours(1); + let (successful, failed, timed_out) = repo.query_counts(start, end).await.unwrap(); + assert_eq!(successful, 10); + assert_eq!(failed, 3); + assert_eq!(timed_out, 2); + + let total = successful + failed + timed_out; + let success_rate = (successful as f64 / total as f64) * 100.0; + let expected_rate = (10.0 / 15.0) * 100.0; + assert!( + (success_rate - expected_rate).abs() < 0.01, + "success_rate should be ~66.67%, got {success_rate}" + ); + } + + #[tokio::test] + async fn three_way_timeline_distributes_timeout_separately() { + let repo = FallbackAttestationRepository::new(); + let end = Utc::now(); + let start = end - chrono::Duration::hours(6); + + let buckets = repo.query_timeline(start, end, 100, 10, 5).await.unwrap(); + assert_eq!(buckets.len(), 6); + + let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); + let total_failed: u64 = buckets.iter().map(|b| b.failed).sum(); + let total_timed_out: u64 = buckets.iter().map(|b| b.timed_out).sum(); + assert_eq!(total_success, 100); + assert_eq!(total_failed, 10); + assert_eq!(total_timed_out, 5); + } } diff --git a/src/models/agent.rs b/src/models/agent.rs index 3f45a9d..6156a80 100644 --- a/src/models/agent.rs +++ b/src/models/agent.rs @@ -38,6 +38,10 @@ impl AgentState { | AgentState::Timeout ) } + + pub fn is_timeout(self) -> bool { + matches!(self, AgentState::Timeout) + } } impl TryFrom for AgentState { diff --git a/src/models/attestation.rs b/src/models/attestation.rs index 324762e..05756a1 100644 --- a/src/models/attestation.rs +++ b/src/models/attestation.rs @@ -106,6 +106,7 @@ pub struct TimelineBucket { pub hour: DateTime, pub successful: u64, pub failed: u64, + pub timed_out: u64, } #[cfg(test)] diff --git a/src/models/kpi.rs b/src/models/kpi.rs index 8c459a1..d60c094 100644 --- a/src/models/kpi.rs +++ b/src/models/kpi.rs @@ -18,6 +18,7 @@ pub struct FleetKpis { pub struct AttestationSummary { pub total_successful: u64, pub total_failed: u64, + pub total_timed_out: u64, pub average_latency_ms: f64, pub success_rate: f64, } @@ -73,12 +74,14 @@ mod tests { let summary = AttestationSummary { total_successful: 1000, total_failed: 5, + total_timed_out: 3, average_latency_ms: 42.0, success_rate: 99.5, }; let json = serde_json::to_value(&summary).unwrap(); assert_eq!(json["total_successful"], 1000); assert_eq!(json["total_failed"], 5); + assert_eq!(json["total_timed_out"], 3); assert_eq!(json["success_rate"], 99.5); } diff --git a/src/repository/attestation.rs b/src/repository/attestation.rs index 6892dad..0818304 100644 --- a/src/repository/attestation.rs +++ b/src/repository/attestation.rs @@ -19,6 +19,7 @@ pub trait AttestationRepository: Send + Sync + 'static { end: DateTime, total_successful: u64, total_failed: u64, + total_timed_out: u64, ) -> AppResult>; async fn get_pipeline(&self, agent_id: Uuid) -> AppResult>; async fn list_failures( @@ -28,8 +29,11 @@ pub trait AttestationRepository: Send + Sync + 'static { ) -> AppResult>; async fn correlate_incidents(&self) -> AppResult>; async fn get_incident(&self, id: Uuid) -> AppResult>; - async fn query_counts(&self, start: DateTime, end: DateTime) - -> AppResult<(u64, u64)>; + async fn query_counts( + &self, + start: DateTime, + end: DateTime, + ) -> AppResult<(u64, u64, u64)>; async fn count_agent_failures( &self, agent_id: Uuid, @@ -131,7 +135,10 @@ impl AttestationRepository for FallbackAttestationRepository { end: DateTime, total_successful: u64, total_failed: u64, + total_timed_out: u64, ) -> AppResult> { + use crate::models::attestation::FailureType; + let results = self.results.read().unwrap(); let in_range_results: Vec<&AttestationResult> = results .iter() @@ -139,7 +146,7 @@ impl AttestationRepository for FallbackAttestationRepository { .collect(); if !in_range_results.is_empty() { - let mut hourly: HashMap, (u64, u64)> = HashMap::new(); + let mut hourly: HashMap, (u64, u64, u64)> = HashMap::new(); for r in &in_range_results { let hour = r .timestamp @@ -147,19 +154,22 @@ impl AttestationRepository for FallbackAttestationRepository { .and_hms_opt(r.timestamp.hour(), 0, 0) .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) .unwrap_or(r.timestamp); - let entry = hourly.entry(hour).or_insert((0, 0)); + let entry = hourly.entry(hour).or_insert((0, 0, 0)); if r.success { entry.0 += 1; + } else if r.failure_type == Some(FailureType::Timeout) { + entry.2 += 1; } else { entry.1 += 1; } } let mut buckets: Vec = hourly .into_iter() - .map(|(hour, (successful, failed))| TimelineBucket { + .map(|(hour, (successful, failed, timed_out))| TimelineBucket { hour, successful, failed, + timed_out, }) .collect(); buckets.sort_by_key(|b| b.hour); @@ -176,6 +186,7 @@ impl AttestationRepository for FallbackAttestationRepository { let success_weights = distribute_with_variation(total_successful, total_hours); let fail_weights = distribute_with_variation(total_failed, total_hours); + let timeout_weights = distribute_with_variation(total_timed_out, total_hours); let mut buckets = Vec::with_capacity(total_hours as usize); for i in 0..total_hours { @@ -184,6 +195,7 @@ impl AttestationRepository for FallbackAttestationRepository { hour, successful: success_weights[i as usize], failed: fail_weights[i as usize], + timed_out: timeout_weights[i as usize], }); } @@ -219,20 +231,25 @@ impl AttestationRepository for FallbackAttestationRepository { &self, start: DateTime, end: DateTime, - ) -> AppResult<(u64, u64)> { + ) -> AppResult<(u64, u64, u64)> { + use crate::models::attestation::FailureType; + let results = self.results.read().unwrap(); let mut successful = 0u64; let mut failed = 0u64; + let mut timed_out = 0u64; for r in results.iter() { if in_range(&r.timestamp, &start, &end) { if r.success { successful += 1; + } else if r.failure_type == Some(FailureType::Timeout) { + timed_out += 1; } else { failed += 1; } } } - Ok((successful, failed)) + Ok((successful, failed, timed_out)) } async fn count_agent_failures( @@ -304,13 +321,15 @@ mod tests { let end = Utc::now(); let start = end - Duration::hours(24); - let buckets = repo.query_timeline(start, end, 100, 10).await.unwrap(); + let buckets = repo.query_timeline(start, end, 100, 10, 4).await.unwrap(); assert_eq!(buckets.len(), 24); let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); let total_failed: u64 = buckets.iter().map(|b| b.failed).sum(); + let total_timed_out: u64 = buckets.iter().map(|b| b.timed_out).sum(); assert_eq!(total_success, 100); assert_eq!(total_failed, 10); + assert_eq!(total_timed_out, 4); } #[tokio::test] @@ -326,9 +345,10 @@ mod tests { let start = Utc::now() - Duration::hours(1); let end = Utc::now() + Duration::hours(1); - let (successful, failed) = repo.query_counts(start, end).await.unwrap(); + let (successful, failed, timed_out) = repo.query_counts(start, end).await.unwrap(); assert_eq!(successful, 5); assert_eq!(failed, 3); + assert_eq!(timed_out, 0); } #[tokio::test] @@ -359,7 +379,7 @@ mod tests { let start = Utc::now() - Duration::hours(1); let end = Utc::now() + Duration::hours(1); - let buckets = repo.query_timeline(start, end, 0, 0).await.unwrap(); + let buckets = repo.query_timeline(start, end, 0, 0, 0).await.unwrap(); assert!(!buckets.is_empty()); let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); @@ -374,10 +394,12 @@ mod tests { let end = Utc::now(); let start = end - Duration::hours(24); - let buckets = repo.query_timeline(start, end, 50, 5).await.unwrap(); + let buckets = repo.query_timeline(start, end, 50, 5, 2).await.unwrap(); assert_eq!(buckets.len(), 24); let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); + let total_timed_out: u64 = buckets.iter().map(|b| b.timed_out).sum(); assert_eq!(total_success, 50); + assert_eq!(total_timed_out, 2); } #[tokio::test] @@ -421,4 +443,65 @@ mod tests { 0 ); } + + fn make_timeout_result() -> AttestationResult { + AttestationResult { + id: Uuid::new_v4(), + agent_id: Uuid::new_v4(), + timestamp: Utc::now(), + success: false, + failure_type: Some(FailureType::Timeout), + failure_reason: Some("timeout".into()), + latency_ms: 45, + verifier_id: "verifier-1".into(), + } + } + + #[tokio::test] + async fn fallback_query_counts_separates_timeout_from_fail() { + let repo = FallbackAttestationRepository::new(); + + for _ in 0..5 { + repo.store_result(&make_result(true)).await.unwrap(); + } + for _ in 0..3 { + repo.store_result(&make_result(false)).await.unwrap(); + } + for _ in 0..2 { + repo.store_result(&make_timeout_result()).await.unwrap(); + } + + let start = Utc::now() - Duration::hours(1); + let end = Utc::now() + Duration::hours(1); + let (successful, failed, timed_out) = repo.query_counts(start, end).await.unwrap(); + assert_eq!(successful, 5); + assert_eq!(failed, 3); + assert_eq!(timed_out, 2); + } + + #[tokio::test] + async fn fallback_timeline_separates_timeout_from_fail_in_stored_data() { + let repo = FallbackAttestationRepository::new(); + + for _ in 0..4 { + repo.store_result(&make_result(true)).await.unwrap(); + } + for _ in 0..2 { + repo.store_result(&make_result(false)).await.unwrap(); + } + for _ in 0..3 { + repo.store_result(&make_timeout_result()).await.unwrap(); + } + + let start = Utc::now() - Duration::hours(1); + let end = Utc::now() + Duration::hours(1); + let buckets = repo.query_timeline(start, end, 0, 0, 0).await.unwrap(); + + let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); + let total_failed: u64 = buckets.iter().map(|b| b.failed).sum(); + let total_timed_out: u64 = buckets.iter().map(|b| b.timed_out).sum(); + assert_eq!(total_success, 4); + assert_eq!(total_failed, 2); + assert_eq!(total_timed_out, 3); + } } diff --git a/src/repository/repository_tests.rs b/src/repository/repository_tests.rs index 282a7fc..69e7e35 100644 --- a/src/repository/repository_tests.rs +++ b/src/repository/repository_tests.rs @@ -346,7 +346,7 @@ mod tests { let start = end - Duration::hours(24); let buckets = attestation - .query_timeline(start, end, 100, 10) + .query_timeline(start, end, 100, 10, 4) .await .unwrap(); @@ -354,6 +354,7 @@ mod tests { let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); let total_failed: u64 = buckets.iter().map(|b| b.failed).sum(); + let total_timed_out: u64 = buckets.iter().map(|b| b.timed_out).sum(); assert_eq!( total_success, 100, "[{label}] successful count must sum to requested total" @@ -362,6 +363,10 @@ mod tests { total_failed, 10, "[{label}] failed count must sum to requested total" ); + assert_eq!( + total_timed_out, 4, + "[{label}] timed_out count must sum to requested total" + ); } #[tokio::test] @@ -789,9 +794,10 @@ mod tests { let start = Utc::now() - Duration::hours(1); let end = Utc::now() + Duration::hours(1); - let (s, f) = attestation.query_counts(start, end).await.unwrap(); + let (s, f, t) = attestation.query_counts(start, end).await.unwrap(); assert_eq!(s, 0, "[{label}] empty repo should have 0 successful"); assert_eq!(f, 0, "[{label}] empty repo should have 0 failed"); + assert_eq!(t, 0, "[{label}] empty repo should have 0 timed_out"); for _ in 0..5 { attestation @@ -806,18 +812,20 @@ mod tests { .unwrap(); } - let (s, f) = attestation.query_counts(start, end).await.unwrap(); + let (s, f, t) = attestation.query_counts(start, end).await.unwrap(); assert_eq!(s, 5, "[{label}] should count 5 successful"); assert_eq!(f, 3, "[{label}] should count 3 failed"); + assert_eq!(t, 0, "[{label}] should count 0 timed_out"); let future_start = Utc::now() + Duration::hours(10); let future_end = Utc::now() + Duration::hours(11); - let (s, f) = attestation + let (s, f, t) = attestation .query_counts(future_start, future_end) .await .unwrap(); assert_eq!(s, 0, "[{label}] out-of-range should return 0 successful"); assert_eq!(f, 0, "[{label}] out-of-range should return 0 failed"); + assert_eq!(t, 0, "[{label}] out-of-range should return 0 timed_out"); } #[tokio::test] diff --git a/src/repository/sqlite/attestation.rs b/src/repository/sqlite/attestation.rs index 8c5737f..5f68ccb 100644 --- a/src/repository/sqlite/attestation.rs +++ b/src/repository/sqlite/attestation.rs @@ -95,11 +95,15 @@ impl AttestationRepository for SqliteAttestationRepository { end: DateTime, total_successful: u64, total_failed: u64, + total_timed_out: u64, ) -> AppResult> { let rows = sqlx::query( "SELECT strftime('%Y-%m-%dT%H:00:00+00:00', timestamp) AS hour, \ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS successful, \ - SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failed \ + SUM(CASE WHEN success = 0 AND (failure_type IS NULL OR failure_type != 'TIMEOUT') \ + THEN 1 ELSE 0 END) AS failed, \ + SUM(CASE WHEN success = 0 AND failure_type = 'TIMEOUT' \ + THEN 1 ELSE 0 END) AS timed_out \ FROM attestation_results \ WHERE timestamp >= ? AND timestamp <= ? \ GROUP BY strftime('%Y-%m-%dT%H:00:00+00:00', timestamp) \ @@ -113,7 +117,7 @@ impl AttestationRepository for SqliteAttestationRepository { if rows.is_empty() { let fallback = crate::repository::FallbackAttestationRepository::new(); return fallback - .query_timeline(start, end, total_successful, total_failed) + .query_timeline(start, end, total_successful, total_failed, total_timed_out) .await; } @@ -127,6 +131,7 @@ impl AttestationRepository for SqliteAttestationRepository { .expect("valid hour timestamp"), successful: row.get::("successful") as u64, failed: row.get::("failed") as u64, + timed_out: row.get::("timed_out") as u64, } }) .collect()) @@ -174,11 +179,14 @@ impl AttestationRepository for SqliteAttestationRepository { &self, start: DateTime, end: DateTime, - ) -> AppResult<(u64, u64)> { + ) -> AppResult<(u64, u64, u64)> { let row = sqlx::query( "SELECT \ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS successful, \ - SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failed \ + SUM(CASE WHEN success = 0 AND (failure_type IS NULL OR failure_type != 'TIMEOUT') \ + THEN 1 ELSE 0 END) AS failed, \ + SUM(CASE WHEN success = 0 AND failure_type = 'TIMEOUT' \ + THEN 1 ELSE 0 END) AS timed_out \ FROM attestation_results \ WHERE timestamp >= ? AND timestamp <= ?", ) @@ -189,7 +197,8 @@ impl AttestationRepository for SqliteAttestationRepository { let successful = row.get::, _>("successful").unwrap_or(0) as u64; let failed = row.get::, _>("failed").unwrap_or(0) as u64; - Ok((successful, failed)) + let timed_out = row.get::, _>("timed_out").unwrap_or(0) as u64; + Ok((successful, failed, timed_out)) } async fn count_agent_failures( @@ -270,7 +279,7 @@ mod tests { let start = Utc::now() - Duration::hours(1); let end = Utc::now() + Duration::hours(1); - let buckets = repo.query_timeline(start, end, 0, 0).await.unwrap(); + let buckets = repo.query_timeline(start, end, 0, 0, 0).await.unwrap(); assert!(!buckets.is_empty()); let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); @@ -286,7 +295,7 @@ mod tests { let start = Utc::now() - Duration::hours(24); let end = Utc::now(); - let buckets = repo.query_timeline(start, end, 100, 10).await.unwrap(); + let buckets = repo.query_timeline(start, end, 100, 10, 0).await.unwrap(); assert_eq!(buckets.len(), 24); let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); @@ -390,9 +399,10 @@ mod tests { let start = Utc::now() - Duration::hours(1); let end = Utc::now() + Duration::hours(1); - let (successful, failed) = repo.query_counts(start, end).await.unwrap(); + let (successful, failed, timed_out) = repo.query_counts(start, end).await.unwrap(); assert_eq!(successful, 5); assert_eq!(failed, 3); + assert_eq!(timed_out, 0); } #[tokio::test] @@ -402,9 +412,10 @@ mod tests { let start = Utc::now() - Duration::hours(1); let end = Utc::now() + Duration::hours(1); - let (successful, failed) = repo.query_counts(start, end).await.unwrap(); + let (successful, failed, timed_out) = repo.query_counts(start, end).await.unwrap(); assert_eq!(successful, 0); assert_eq!(failed, 0); + assert_eq!(timed_out, 0); } #[tokio::test] @@ -458,4 +469,67 @@ mod tests { 0 ); } + + fn make_timeout_result() -> AttestationResult { + AttestationResult { + id: Uuid::new_v4(), + agent_id: Uuid::new_v4(), + timestamp: Utc::now(), + success: false, + failure_type: Some(FailureType::Timeout), + failure_reason: Some("timeout".into()), + latency_ms: 45, + verifier_id: "verifier-1".into(), + } + } + + #[tokio::test] + async fn sqlite_query_counts_separates_timeout_from_fail() { + let db = test_db().await; + let repo = db.attestation_repo(); + + for _ in 0..5 { + repo.store_result(&make_result(true)).await.unwrap(); + } + for _ in 0..3 { + repo.store_result(&make_result(false)).await.unwrap(); + } + for _ in 0..2 { + repo.store_result(&make_timeout_result()).await.unwrap(); + } + + let start = Utc::now() - Duration::hours(1); + let end = Utc::now() + Duration::hours(1); + let (successful, failed, timed_out) = repo.query_counts(start, end).await.unwrap(); + assert_eq!(successful, 5); + assert_eq!(failed, 3); + assert_eq!(timed_out, 2); + } + + #[tokio::test] + async fn sqlite_timeline_separates_timeout_from_fail() { + let db = test_db().await; + let repo = db.attestation_repo(); + + for _ in 0..4 { + repo.store_result(&make_result(true)).await.unwrap(); + } + for _ in 0..2 { + repo.store_result(&make_result(false)).await.unwrap(); + } + for _ in 0..3 { + repo.store_result(&make_timeout_result()).await.unwrap(); + } + + let start = Utc::now() - Duration::hours(1); + let end = Utc::now() + Duration::hours(1); + let buckets = repo.query_timeline(start, end, 0, 0, 0).await.unwrap(); + + let total_success: u64 = buckets.iter().map(|b| b.successful).sum(); + let total_failed: u64 = buckets.iter().map(|b| b.failed).sum(); + let total_timed_out: u64 = buckets.iter().map(|b| b.timed_out).sum(); + assert_eq!(total_success, 4); + assert_eq!(total_failed, 2); + assert_eq!(total_timed_out, 3); + } }