//! Health check and metrics endpoints //! //! Provides health check endpoints for Kubernetes readiness/liveness probes and Prometheus metrics. //! //! # Endpoints //! //! - `/health/live` - Liveness probe: checks if the application is running (basic check) //! - `/health/ready` - Readiness probe: checks if dependencies (DB, Redis) are healthy //! - `/metrics` - Prometheus metrics endpoint use axum::{ extract::State, http::{HeaderMap, StatusCode}, response::{IntoResponse, Json}, routing::get, Router, }; use std::time::Duration; use tracing::warn; use crate::http::AppState; use synctv_api_common::observability::metrics; use synctv_api_common::webrtc_status; use synctv_core::service::{email_health, ws_ticket_backend_is_safe_for_mode, ws_ticket_health}; use synctv_proto::client::{HealthDetails, HealthResponse, MemoryHealth}; /// Timeout for individual health check probes (DB, Redis). /// Prevents a hung dependency from blocking the readiness endpoint indefinitely. const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5); /// Health check router. pub fn create_health_router() -> Router { Router::new() .route("/health/live", get(liveness_check)) .route("/health/ready", get(readiness_check)) } /// Dedicated metrics router. pub fn create_metrics_router() -> Router { metrics::initialize(); Router::new().route("/metrics", get(prometheus_metrics)) } /// Liveness probe - checks if the application process is running /// /// This is a basic check that always returns OK if the server is responding. /// Kubernetes uses this to determine if the pod needs to be restarted. /// /// Returns: /// - 200 OK: Application is alive #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/health/live", tag = "Health", responses( (status = 200, description = "Application is alive", body = HealthResponse) ) ) )] pub async fn liveness_check() -> impl IntoResponse { ( StatusCode::OK, Json(HealthResponse { status: "ok".to_string(), details: None, }), ) } /// Readiness probe - checks if the application is ready to serve traffic /// /// Health check validates critical dependencies: /// - Database connectivity (`PostgreSQL`) - Executes a test query via `user_service` /// - Redis connectivity - Sends PING command, gracefully handles "not configured" case /// - Memory pressure - Checks if system memory usage exceeds 90% /// /// Kubernetes uses this to determine if the pod should receive traffic. /// A failing health check will prevent traffic routing until dependencies recover. /// /// Returns: /// - 200 OK: All dependencies are healthy /// - 503 Service Unavailable: One or more dependencies are unhealthy #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/health/ready", tag = "Health", responses( (status = 200, description = "Dependencies are healthy", body = HealthResponse), (status = 503, description = "One or more dependencies are unhealthy", body = HealthResponse) ) ) )] pub async fn readiness_check(State(state): State) -> impl IntoResponse { let mut is_healthy = true; let mut error_messages = Vec::new(); let (database_health, redis_health) = tokio::join!(check_database_health(&state), check_redis_health(&state),); // Check database connectivity let db_status = match database_health { Ok(()) => "healthy".to_string(), Err(e) => { error_messages.push(format!("Database: {e}")); is_healthy = false; warn!(target: "synctv::health", "Database health check failed: {}", e); "unhealthy".to_string() } }; // Check Redis connectivity let redis_status = match redis_health { RedisHealthStatus::Healthy => "healthy".to_string(), RedisHealthStatus::NotConfigured => "not configured".to_string(), RedisHealthStatus::Unhealthy(e) => { error_messages.push(format!("Redis: {e}")); is_healthy = false; warn!(target: "synctv::health", "Redis health check failed: {}", e); "unhealthy".to_string() } }; // Check cluster health (only when distributed mode is active) let cluster_status = match check_cluster_health(&state) { Some(Ok(())) => Some("healthy".to_string()), Some(Err(e)) => { error_messages.push(format!("Cluster: {e}")); is_healthy = false; warn!(target: "synctv::health", "Cluster health check failed: {}", e); Some("unhealthy".to_string()) } None => None, // No cluster realtime service, single-node mode }; // Check WebSocket ticket service. // In distributed mode, memory-backed ticket storage causes cross-replica auth failures. let ws_ticket_status = { let svc = &state.ws_ticket_service; let is_cluster_mode = state.runtime_settings.cluster_runtime_enabled(); let health = ws_ticket_health(svc.as_ref()); if ws_ticket_backend_is_safe_for_mode(svc.as_ref(), is_cluster_mode) { Some(health) } else { error_messages.push( "WsTicketService: ticket storage is not cross-node capable in distributed mode \ (tickets created on one node cannot be validated on another)" .to_string(), ); is_healthy = false; warn!(target: "synctv::health", "WsTicketService storage is not cross-node capable in distributed mode; \ cross-replica ticket validation will fail" ); Some("unhealthy (single-node ticket storage in distributed mode)".to_string()) } }; // Check email service (if configured) - validates SMTP config is present let email_status = state.email_service.as_ref().map(|svc| email_health(svc)); // Check livestream infrastructure (if configured) let livestream_status = state .live_streaming_infrastructure .as_ref() .map(|_| "configured".to_string()); // Check memory pressure - high memory usage should mark node as unhealthy let memory_health = check_memory_health(); if let Some(ref mem) = memory_health { if mem.status == "unhealthy" { error_messages.push(format!( "Memory: usage at {:.1}% (threshold: {:.0}%)", mem.usage_percent, MEMORY_UNHEALTHY_THRESHOLD_PERCENT )); is_healthy = false; warn!(target: "synctv::health", "Memory pressure detected: {:.1}% usage", mem.usage_percent); } } let status_code = if is_healthy { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE }; let response = HealthResponse { status: if is_healthy { "healthy".to_string() } else { "unhealthy".to_string() }, details: Some(HealthDetails { database: db_status, redis: redis_status, cluster: cluster_status, ws_ticket: ws_ticket_status, email: email_status, livestream: livestream_status, memory: memory_health, message: if error_messages.is_empty() { None } else { Some(error_messages.join("; ")) }, webrtc: Some(webrtc_status::to_proto_status( &state.shared_api_runtime.webrtc_status, )), }), }; (status_code, Json(response)) } /// Check database connectivity with timeout pub(crate) async fn check_database_health(state: &AppState) -> Result<(), String> { match tokio::time::timeout(HEALTH_CHECK_TIMEOUT, state.user_service.health_check()).await { Ok(Ok(())) => Ok(()), Ok(Err(e)) => { warn!(target: "synctv::health", "Database health check failed: {}", e); Err(format!("Database connection failed: {e}")) } Err(_) => { warn!(target: "synctv::health", "Database health check timed out after {}s", HEALTH_CHECK_TIMEOUT.as_secs() ); Err(format!( "Database health check timed out after {}s", HEALTH_CHECK_TIMEOUT.as_secs() )) } } } /// Check cluster health by verifying the realtime cluster service is operational. /// /// Returns `Some(Ok(()))` if the realtime runtime is healthy. /// Returns `Some(Err(...))` if the realtime runtime reports issues. fn check_cluster_health(state: &AppState) -> Option> { if !state.runtime_settings.cluster_runtime_enabled() { return None; } let event_service = state.event_service.as_ref(); let metrics = event_service.metrics(); // Verify node has a valid ID (non-empty) if event_service.node_id().is_empty() { return Some(Err("Cluster node ID is empty".to_string())); } // If the distributed event path should be enabled but isn't, the node can't sync with the cluster if !metrics.distributed_enabled { return Some(Err( "Cluster distributed realtime transport is not connected".to_string(), )); } Some(Ok(())) } /// Check Redis connectivity by sending a PING command with timeout. /// /// Uses the shared raw Redis connection (`state.redis_conn`) rather than the /// rate limiter's internal connection. This ensures the health check reports /// the status of the application's Redis, not whether the rate limiter's /// connection pool happens to be healthy. When the rate limiter is degraded /// (e.g. using in-memory fallback due to a transient Redis error), the health /// check will still correctly detect that Redis is unavailable. /// /// Redis readiness reflects the health of the configured Redis connection. /// #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum RedisHealthStatus { Healthy, NotConfigured, Unhealthy(String), } /// Whether Redis must exist is a configuration-layer invariant enforced during /// startup validation. At runtime, a missing Redis handle is treated as /// "not configured" rather than re-enforcing realtime configuration rules. pub(crate) async fn check_redis_health(state: &AppState) -> RedisHealthStatus { let redis_conn = state.resolve_redis_conn().await; check_redis_health_from_conn(redis_conn).await } pub(crate) async fn check_redis_health_from_conn( redis_conn: Option, ) -> RedisHealthStatus { let Some(mut conn) = redis_conn else { return RedisHealthStatus::NotConfigured; }; // Send a direct PING to verify the raw connection is responsive. match tokio::time::timeout( HEALTH_CHECK_TIMEOUT, redis::cmd("PING").query_async::(&mut conn), ) .await { Ok(Ok(_)) => RedisHealthStatus::Healthy, Ok(Err(e)) => { warn!(target: "synctv::health", "Redis health check failed: {}", e); RedisHealthStatus::Unhealthy(format!("Redis ping failed: {e}")) } Err(_) => { warn!(target: "synctv::health", "Redis health check timed out after {}s", HEALTH_CHECK_TIMEOUT.as_secs() ); RedisHealthStatus::Unhealthy(format!( "Redis health check timed out after {}s", HEALTH_CHECK_TIMEOUT.as_secs() )) } } } /// Memory usage threshold percentage for marking the node as unhealthy. /// When memory usage exceeds this threshold, the node should not receive /// additional traffic to prevent OOM or performance degradation. const MEMORY_UNHEALTHY_THRESHOLD_PERCENT: f64 = 90.0; #[cfg(any(target_os = "linux", target_os = "macos", test))] fn memory_usage_percent(used_bytes: u64, total_bytes: u64) -> Option { if total_bytes == 0 { return None; } let scaled_percent = (u128::from(used_bytes) * 10_000 + u128::from(total_bytes / 2)) / u128::from(total_bytes); let scaled_percent = u32::try_from(scaled_percent).ok()?; Some(f64::from(scaled_percent) / 100.0) } /// Check system memory health. /// /// Returns memory usage information and health status. /// When memory usage exceeds 90%, the status is "unhealthy". /// Returns None if memory information cannot be obtained. pub(crate) fn check_memory_health() -> Option { // Use the `sysinfo` crate to get memory info // Note: We use a minimal approach here to avoid heavy dependencies #[cfg(target_os = "linux")] { check_memory_health_linux() } #[cfg(target_os = "macos")] { check_memory_health_macos() } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { None } } #[cfg(target_os = "linux")] fn check_memory_health_linux() -> Option { // Try cgroup-aware memory check first (for containers), fall back to /proc/meminfo check_cgroup_memory().or_else(check_proc_meminfo) } /// Read memory limits from cgroup v2 or v1 when running inside a container. /// /// Returns `None` if not running in a cgroup-limited environment (e.g., bare metal). #[cfg(target_os = "linux")] fn check_cgroup_memory() -> Option { use std::fs; // Try cgroup v2 first let (limit, current) = if let (Ok(limit_str), Ok(current_str)) = ( fs::read_to_string("/sys/fs/cgroup/memory.max"), fs::read_to_string("/sys/fs/cgroup/memory.current"), ) { let limit = limit_str.trim().parse::().ok()?; let current = current_str.trim().parse::().ok()?; (limit, current) } else if let (Ok(limit_str), Ok(usage_str)) = ( // Try cgroup v1 fallback fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes"), fs::read_to_string("/sys/fs/cgroup/memory/memory.usage_in_bytes"), ) { let limit = limit_str.trim().parse::().ok()?; let current = usage_str.trim().parse::().ok()?; (limit, current) } else { return None; }; // Sanity check: a limit of u64::MAX or 0 means "no limit" (not containerized) if limit == 0 || limit >= (1u64 << 62) { return None; } let usage_percent = memory_usage_percent(current, limit)?; let status = if usage_percent > MEMORY_UNHEALTHY_THRESHOLD_PERCENT { "unhealthy" } else { "healthy" }; Some(MemoryHealth { usage_percent: (usage_percent * 100.0).round() / 100.0, status: status.to_string(), }) } /// Read memory info from /proc/meminfo (host-level, not container-aware). #[cfg(target_os = "linux")] fn check_proc_meminfo() -> Option { use std::fs; let meminfo = fs::read_to_string("/proc/meminfo").ok()?; let mut total_kb: u64 = 0; let mut available_kb: u64 = 0; for line in meminfo.lines() { if line.starts_with("MemTotal:") { total_kb = line .split(':') .nth(1)? .split_whitespace() .next()? .parse() .ok()?; } else if line.starts_with("MemAvailable:") { available_kb = line .split(':') .nth(1)? .split_whitespace() .next()? .parse() .ok()?; } if total_kb > 0 && available_kb > 0 { break; } } if total_kb == 0 { return None; } let used_kb = total_kb.saturating_sub(available_kb); let usage_percent = memory_usage_percent(used_kb, total_kb)?; let status = if usage_percent > MEMORY_UNHEALTHY_THRESHOLD_PERCENT { "unhealthy" } else { "healthy" }; Some(MemoryHealth { usage_percent: (usage_percent * 100.0).round() / 100.0, status: status.to_string(), }) } #[cfg(target_os = "macos")] fn check_memory_health_macos() -> Option { // On macOS, use vm_stat to get memory info // For simplicity, we use sysctl which is more portable use std::process::Command; // Get total memory in bytes let total_output = Command::new("sysctl") .args(["-n", "hw.memsize"]) .output() .ok()?; let total_bytes: u64 = String::from_utf8_lossy(&total_output.stdout) .trim() .parse() .ok()?; // Get page size and free pages from vm_stat let vm_stat_output = Command::new("vm_stat").output().ok()?; let vm_stat = String::from_utf8_lossy(&vm_stat_output.stdout); let mut page_size: u64 = 4096; // Default page size let mut free_pages: u64 = 0; let mut inactive_pages: u64 = 0; for line in vm_stat.lines() { if line.contains("page size of") { // Extract page size from line like: "Mach Virtual Memory Statistics: (page size of 16384 bytes)" if let Some(start) = line.find("page size of ") { let rest = &line[start + 13..]; if let Some(end) = rest.find(" bytes") { if let Ok(ps) = rest[..end].parse::() { page_size = ps; } } } } else if line.starts_with("Pages free:") { // Extract number from "Pages free: 12345." let num_str: String = line .split(':') .nth(1)? .trim() .trim_end_matches('.') .chars() .filter(char::is_ascii_digit) .collect(); free_pages = num_str.parse().ok()?; } else if line.starts_with("Pages inactive:") { let num_str: String = line .split(':') .nth(1)? .trim() .trim_end_matches('.') .chars() .filter(char::is_ascii_digit) .collect(); inactive_pages = num_str.parse().ok()?; } } // On macOS, "free + inactive" is roughly equivalent to available memory let available_bytes = (free_pages + inactive_pages) * page_size; let used_bytes = total_bytes.saturating_sub(available_bytes); let usage_percent = memory_usage_percent(used_bytes, total_bytes)?.min(100.0); let status = if usage_percent > MEMORY_UNHEALTHY_THRESHOLD_PERCENT { "unhealthy" } else { "healthy" }; Some(MemoryHealth { usage_percent: (usage_percent * 100.0).round() / 100.0, status: status.to_string(), }) } /// Prometheus metrics endpoint /// /// Startup validation requires a valid `metrics.auth` configuration whenever /// metrics are enabled, so this endpoint always enforces authenticated access /// in production. pub async fn prometheus_metrics( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { #[cfg(feature = "k8s")] let auth_result = state .metrics_access_controller .authorize(&state.runtime_settings.metrics, &headers, "/metrics", "GET") .await; #[cfg(not(feature = "k8s"))] let auth_result = state.metrics_access_controller.authorize( &state.runtime_settings.metrics, &headers, "/metrics", "GET", ); match auth_result { Ok(()) => {} Err(synctv_api_common::metrics_auth::MetricsAccessError::Unauthorized) => { return ( StatusCode::UNAUTHORIZED, [( axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8", )], "Unauthorized".to_string(), ) .into_response(); } Err(synctv_api_common::metrics_auth::MetricsAccessError::Forbidden) => { return ( StatusCode::FORBIDDEN, [( axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8", )], "Forbidden".to_string(), ) .into_response(); } Err(synctv_api_common::metrics_auth::MetricsAccessError::Internal) => { return ( StatusCode::INTERNAL_SERVER_ERROR, [( axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8", )], "Internal Server Error".to_string(), ) .into_response(); } } match metrics::gather_metrics() { Ok(body) => ( [( axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8", )], body, ) .into_response(), Err(error) => { tracing::error!(%error, "Failed to gather Prometheus metrics"); ( StatusCode::INTERNAL_SERVER_ERROR, [( axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8", )], "Internal Server Error".to_string(), ) .into_response() } } } #[cfg(test)] mod tests { use super::*; use crate::http::tests::test_app_state; use async_trait::async_trait; use axum::extract::State; use axum::http::header::AUTHORIZATION; use axum::http::HeaderValue; use axum::response::IntoResponse; use http_body_util::BodyExt; use std::sync::Arc; use tower::ServiceExt; type TestResult = Result>; fn test_error(message: impl Into) -> Box { anyhow::anyhow!(message.into()).into() } async fn read_health_response( response: axum::response::Response, ) -> TestResult { let body = response.into_body().collect().await?.to_bytes(); Ok(serde_json::from_slice(&body)?) } fn require_details(health: HealthResponse) -> TestResult { health .details .ok_or_else(|| test_error("readiness should include details")) } fn request(uri: &str) -> TestResult> { axum::http::Request::builder() .uri(uri) .body(axum::body::Body::empty()) .map_err(|err| test_error(format!("request should build: {err}"))) } #[derive(Clone)] struct TestEmailConfigProvider(Option); impl synctv_core::service::EmailConfigProvider for TestEmailConfigProvider { fn current_config(&self) -> synctv_core::Result> { Ok(self.0.clone()) } } struct SharedTestTicketStore; #[async_trait] impl synctv_core::service::TicketStore for SharedTestTicketStore { async fn store( &self, _ticket: &str, _data: &synctv_core::service::WsTicketData, _ttl_secs: u64, ) -> synctv_core::Result<()> { Ok(()) } async fn load( &self, _ticket: &str, ) -> synctv_core::Result> { Ok(None) } async fn claim( &self, _ticket: &str, _expected_ticket: &synctv_core::service::WsTicketData, ) -> synctv_core::Result { Ok(false) } fn supports_cluster_runtime(&self) -> bool { true } } #[tokio::test] async fn test_liveness_check_response_body() -> TestResult { let response = liveness_check().await.into_response(); let health = read_health_response(response).await?; assert_eq!(health.status, "ok"); assert!(health.details.is_none()); Ok(()) } #[test] fn test_memory_usage_percent_handles_large_totals_without_u32_truncation() -> TestResult { let total_bytes = 16 * 1024 * 1024 * 1024_u64; let used_bytes = 8 * 1024 * 1024 * 1024_u64; let usage_percent = memory_usage_percent(used_bytes, total_bytes) .ok_or_else(|| test_error("non-zero total memory"))?; assert!((usage_percent - 50.0).abs() < f64::EPSILON); Ok(()) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn test_check_redis_health_status_reports_not_configured_without_connection() -> TestResult { let state = test_app_state(); let response = readiness_check(State(state)).await.into_response(); assert!( matches!( response.status(), StatusCode::OK | StatusCode::SERVICE_UNAVAILABLE ), "readiness should remain on the documented status surface" ); let health = read_health_response(response).await?; let details = require_details(health)?; assert_eq!( details.redis, "not configured", "readiness must distinguish missing Redis from a healthy configured Redis" ); Ok(()) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn test_readiness_includes_webrtc_status() -> TestResult { let state = test_app_state(); let response = readiness_check(State(state)).await.into_response(); let health = read_health_response(response).await?; let details = require_details(health)?; let webrtc = details .webrtc .ok_or_else(|| test_error("readiness should expose WebRTC status"))?; assert_eq!(webrtc.mode, "peer_to_peer"); assert_eq!(webrtc.builtin_stun_state, "disabled"); assert_eq!(webrtc.reason, "disabled_by_config"); Ok(()) } fn metrics_test_state(metrics_bearer_token: &str) -> crate::http::AppState { let mut state = test_app_state(); Arc::make_mut(&mut state.router_options).runtime_settings = Arc::new({ let mut config = (*state.runtime_settings).clone(); config.metrics.enabled = true; config.metrics.auth.mode = synctv_api_common::api_runtime::MetricsAuthMode::BearerToken; config.metrics.auth.bearer_token = metrics_bearer_token.to_string(); config }); state } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn test_prometheus_metrics_rejects_missing_auth_when_token_configured() { let response = prometheus_metrics(State(metrics_test_state("secret")), HeaderMap::new()) .await .into_response(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn test_prometheus_metrics_accepts_matching_bearer_token() { let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret")); let response = prometheus_metrics(State(metrics_test_state("secret")), headers) .await .into_response(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn test_prometheus_metrics_accepts_matching_basic_auth() { let mut state = test_app_state(); Arc::make_mut(&mut state.router_options).runtime_settings = Arc::new({ let mut config = (*state.runtime_settings).clone(); config.metrics.enabled = true; config.metrics.auth.mode = synctv_api_common::api_runtime::MetricsAuthMode::Basic; config.metrics.auth.basic_username = "metrics".to_string(); config.metrics.auth.basic_password = "secret".to_string(); config }); let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, HeaderValue::from_static("Basic bWV0cmljczpzZWNyZXQ="), ); let response = prometheus_metrics(State(state), headers) .await .into_response(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn test_metrics_router_exposes_metrics_endpoint() -> TestResult { let app = create_metrics_router().with_state(metrics_test_state("secret")); let mut request = request("/metrics")?; request .headers_mut() .insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret")); let response = app.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); Ok(()) } #[test] fn test_ws_ticket_memory_backend_is_only_unhealthy_when_distributed_enabled() { let memory_tickets = synctv_core::service::WsTicketService::local_only(None); assert!( ws_ticket_backend_is_safe_for_mode(&memory_tickets, false), "standalone mode should allow memory-backed ws tickets" ); assert!( !ws_ticket_backend_is_safe_for_mode(&memory_tickets, true), "distributed mode must reject memory-backed ws tickets" ); let shared_tickets = synctv_core::service::WsTicketService::from_store( Arc::new(SharedTestTicketStore), None, ); let mut standalone = synctv_api_common::ApiRuntimeSettings::default(); standalone.cluster_enabled = false; assert!( !standalone.cluster_runtime_enabled(), "standalone config should not be treated as distributed mode" ); let mut clustered = synctv_api_common::ApiRuntimeSettings::default(); clustered.cluster_enabled = true; clustered.cluster.secret = "shared-secret".to_string(); assert!( clustered.cluster_runtime_enabled(), "cluster-enabled config should be treated as distributed mode" ); assert!( ws_ticket_backend_is_safe_for_mode(&shared_tickets, true), "distributed mode should accept any store that advertises cluster capability" ); assert!( ws_ticket_health(&shared_tickets).contains("cross-node capable"), "health helper should expose capability rather than backend implementation" ); assert!( ws_ticket_health(&memory_tickets).contains("single-node"), "health helper should expose capability rather than backend implementation" ); } #[test] fn test_cluster_health_is_skipped_when_distributed_cluster_disabled() { let memory_tickets = synctv_core::service::WsTicketService::local_only(None); assert!( ws_ticket_backend_is_safe_for_mode(&memory_tickets, false), "helper should treat standalone mode as safe" ); assert!( !ws_ticket_backend_is_safe_for_mode(&memory_tickets, true), "helper should treat distributed distributed mode as unsafe for memory tickets" ); } #[test] fn test_email_health_reports_configuration_only() -> TestResult { let unconfigured = synctv_core::service::EmailService::new(Arc::new(TestEmailConfigProvider(None)))?; assert_eq!(email_health(&unconfigured), "not configured"); let configured = synctv_core::service::EmailService::new(Arc::new( TestEmailConfigProvider(Some(synctv_core::service::EmailConfig { smtp_host: "smtp.example.com".to_string(), smtp_port: 587, smtp_credentials: Some(synctv_core::service::SmtpCredentials { username: "user".to_string(), password: "password".to_string(), }), smtp_proxy: None, from_email: "noreply@example.com".to_string(), from_name: "SyncTV".to_string(), use_tls: true, })), ))?; assert_eq!(email_health(&configured), "configured"); Ok(()) } /// Verify that cgroup memory check is attempted on Linux. /// On non-containerized Linux, it should fall back to /proc/meminfo. #[cfg(target_os = "linux")] #[test] fn test_cgroup_memory_check_graceful_fallback() { // check_cgroup_memory returns None when not in a container // (cgroup files don't exist or limit is very large) let cgroup_result = check_cgroup_memory(); // On bare-metal Linux, this will be None; in a container, Some // Either way, the overall check_memory_health_linux should return Some let overall = check_memory_health_linux(); assert!(overall.is_some() || cgroup_result.is_some()); } /// Verify that proc_meminfo fallback works on Linux. #[cfg(target_os = "linux")] #[test] fn test_proc_meminfo_returns_some() -> TestResult { let result = check_proc_meminfo(); let mem = result.ok_or_else(|| test_error("proc meminfo should be available"))?; assert!(mem.usage_percent >= 0.0); assert!(mem.usage_percent <= 100.0); Ok(()) } }