//! Route accessibility checks — verify expected pages are reachable. use std::time::{Duration, Instant}; use tracing::instrument; /// Result of checking a single route. #[derive(Debug, Clone)] pub struct RouteCheckResult { pub target: String, pub path: String, pub status_code: u16, pub ok: bool, pub checked_at: String, pub response_time_ms: i64, pub error: Option, } /// Extract the base URL (scheme + host + port) from a health endpoint URL. /// /// ```text /// "https://makenot.work/api/health" → "https://makenot.work" /// "http://100.106.221.39:3400/api/health" → "http://100.106.221.39:3400" /// ``` pub fn base_url_from_health_url(health_url: &str) -> Option { let rest = health_url.strip_prefix("https://") .map(|r| ("https", r)) .or_else(|| health_url.strip_prefix("http://").map(|r| ("http", r)))?; let (scheme, after_scheme) = rest; // Host (+ optional port) ends at the first '/' or end of string let authority = after_scheme.split('/').next()?; if authority.is_empty() { return None; } Some(format!("{scheme}://{authority}")) } /// Check all expected routes for a target, returning one result per path. /// /// Runs sequentially within a target to avoid hammering the server. #[instrument(skip_all, fields(target, route_count = paths.len()))] pub async fn check_routes( client: &reqwest::Client, target: &str, base_url: &str, paths: &[String], timeout: Duration, ) -> Vec { let mut results = Vec::with_capacity(paths.len()); for path in paths { let url = format!("{base_url}{path}"); let checked_at = chrono::Utc::now().to_rfc3339(); let start = Instant::now(); let result = match client .get(&url) .timeout(timeout) .send() .await { Ok(response) => { let status_code = response.status().as_u16(); let ok = response.status().is_success(); RouteCheckResult { target: target.to_string(), path: path.clone(), status_code, ok, checked_at, response_time_ms: start.elapsed().as_millis() as i64, error: if ok { None } else { Some(format!("HTTP {status_code}")) }, } } Err(e) => { let error_msg = if e.is_timeout() { "timeout".to_string() } else { e.to_string() }; RouteCheckResult { target: target.to_string(), path: path.clone(), status_code: 0, ok: false, checked_at, response_time_ms: start.elapsed().as_millis() as i64, error: Some(error_msg), } } }; results.push(result); } results } #[cfg(test)] mod tests { use super::*; #[test] fn base_url_from_https() { assert_eq!( base_url_from_health_url("https://makenot.work/api/health"), Some("https://makenot.work".to_string()), ); } #[test] fn base_url_from_http_with_port() { assert_eq!( base_url_from_health_url("http://100.106.221.39:3400/api/health"), Some("http://100.106.221.39:3400".to_string()), ); } #[test] fn base_url_from_invalid_url() { assert_eq!(base_url_from_health_url("not-a-url"), None); } #[test] fn base_url_from_localhost() { assert_eq!( base_url_from_health_url("http://127.0.0.1:9100/api/health"), Some("http://127.0.0.1:9100".to_string()), ); } #[tokio::test] async fn check_routes_handles_unreachable() { let client = reqwest::Client::builder() .timeout(Duration::from_millis(100)) .build() .unwrap(); let results = check_routes( &client, "test", "http://127.0.0.1:19999", &["/".to_string()], Duration::from_millis(100), ) .await; assert_eq!(results.len(), 1); assert!(!results[0].ok); assert_eq!(results[0].target, "test"); assert_eq!(results[0].path, "/"); assert!(results[0].error.is_some()); } #[tokio::test] async fn check_routes_empty_paths_returns_empty() { let client = reqwest::Client::builder().build().unwrap(); let results = check_routes( &client, "test", "http://127.0.0.1:19999", &[], Duration::from_millis(100), ) .await; assert!(results.is_empty()); } }