//! Daemon lifecycle integration tests. //! //! Tests the real daemon startup, PID file management, health serving, //! and graceful shutdown sequence. use axum::Router; use openfang_api::middleware; use openfang_api::routes::{self, AppState}; use openfang_api::server::{read_daemon_info, DaemonInfo}; use openfang_kernel::OpenFangKernel; use openfang_types::config::{DefaultModelConfig, KernelConfig}; use std::sync::Arc; use std::time::Instant; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// Test DaemonInfo serialization and deserialization round-trip. #[test] fn test_daemon_info_serde_roundtrip() { let info = DaemonInfo { pid: 12345, listen_addr: "127.0.0.1:4200".to_string(), started_at: "2024-01-01T00:00:00Z".to_string(), version: "0.1.0".to_string(), platform: "linux".to_string(), }; let json = serde_json::to_string_pretty(&info).unwrap(); let parsed: DaemonInfo = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.pid, 12345); assert_eq!(parsed.listen_addr, "127.0.0.1:4200"); assert_eq!(parsed.version, "0.1.0"); assert_eq!(parsed.platform, "linux"); } /// Test read_daemon_info from a file on disk. #[test] fn test_read_daemon_info_from_file() { let tmp = tempfile::tempdir().unwrap(); // Write a daemon.json let info = DaemonInfo { pid: std::process::id(), listen_addr: "127.0.0.1:9999".to_string(), started_at: chrono::Utc::now().to_rfc3339(), version: "0.1.0".to_string(), platform: "test".to_string(), }; let json = serde_json::to_string_pretty(&info).unwrap(); std::fs::write(tmp.path().join("daemon.json"), json).unwrap(); // Read it back let loaded = read_daemon_info(tmp.path()); assert!(loaded.is_some()); let loaded = loaded.unwrap(); assert_eq!(loaded.pid, std::process::id()); assert_eq!(loaded.listen_addr, "127.0.0.1:9999"); } /// Test read_daemon_info returns None when file doesn't exist. #[test] fn test_read_daemon_info_missing_file() { let tmp = tempfile::tempdir().unwrap(); let loaded = read_daemon_info(tmp.path()); assert!(loaded.is_none()); } /// Test read_daemon_info returns None for corrupt JSON. #[test] fn test_read_daemon_info_corrupt_json() { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("daemon.json"), "not json at all").unwrap(); let loaded = read_daemon_info(tmp.path()); assert!(loaded.is_none()); } /// Test the full daemon lifecycle: /// 1. Boot kernel + start server on random port /// 2. Write daemon info file /// 3. Verify health endpoint /// 4. Verify daemon info file contents match /// 5. Shut down and verify cleanup #[tokio::test] async fn test_full_daemon_lifecycle() { let tmp = tempfile::tempdir().unwrap(); let daemon_info_path = tmp.path().join("daemon.json"); let config = KernelConfig { home_dir: tmp.path().to_path_buf(), data_dir: tmp.path().join("data"), default_model: DefaultModelConfig { provider: "ollama".to_string(), model: "test".to_string(), api_key_env: "OLLAMA_API_KEY".to_string(), base_url: None, }, ..KernelConfig::default() }; let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot"); let kernel = Arc::new(kernel); kernel.set_self_handle(); let state = Arc::new(AppState { kernel: kernel.clone(), started_at: Instant::now(), peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let app = Router::new() .route("/api/health", axum::routing::get(routes::health)) .route("/api/status", axum::routing::get(routes::status)) .route("/api/shutdown", axum::routing::post(routes::shutdown)) .layer(axum::middleware::from_fn(middleware::request_logging)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state.clone()); // Bind to random port let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); // Spawn server tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); // Write daemon info file (like run_daemon does) let daemon_info = DaemonInfo { pid: std::process::id(), listen_addr: addr.to_string(), started_at: chrono::Utc::now().to_rfc3339(), version: env!("CARGO_PKG_VERSION").to_string(), platform: std::env::consts::OS.to_string(), }; let json = serde_json::to_string_pretty(&daemon_info).unwrap(); std::fs::write(&daemon_info_path, &json).unwrap(); // --- Verify daemon info file --- assert!(daemon_info_path.exists()); let loaded = read_daemon_info(tmp.path()).unwrap(); assert_eq!(loaded.pid, std::process::id()); assert_eq!(loaded.listen_addr, addr.to_string()); // --- Verify health endpoint --- let client = reqwest::Client::new(); let resp = client .get(format!("http://{}/api/health", addr)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["status"], "ok"); // --- Verify status endpoint --- let resp = client .get(format!("http://{}/api/status", addr)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["status"], "running"); // --- Shutdown --- let resp = client .post(format!("http://{}/api/shutdown", addr)) .send() .await .unwrap(); assert_eq!(resp.status(), 200); // Clean up daemon info file (like run_daemon does) let _ = std::fs::remove_file(&daemon_info_path); assert!(!daemon_info_path.exists()); kernel.shutdown(); } /// Test that stale daemon info is detected when no process is running at that PID. #[test] fn test_stale_daemon_info_detection() { let tmp = tempfile::tempdir().unwrap(); // Write daemon.json with a PID that almost certainly doesn't exist // (using a very high PID number) let info = DaemonInfo { pid: 99999999, // unlikely to be running listen_addr: "127.0.0.1:9999".to_string(), started_at: "2024-01-01T00:00:00Z".to_string(), version: "0.1.0".to_string(), platform: "test".to_string(), }; let json = serde_json::to_string_pretty(&info).unwrap(); std::fs::write(tmp.path().join("daemon.json"), json).unwrap(); // read_daemon_info just reads the file — it doesn't check if the PID is alive // (that check happens in run_daemon). So the file is readable: let loaded = read_daemon_info(tmp.path()); assert!(loaded.is_some()); assert_eq!(loaded.unwrap().pid, 99999999); } /// Test that the server starts and immediately responds to requests. #[tokio::test] async fn test_server_immediate_responsiveness() { let tmp = tempfile::tempdir().unwrap(); let config = KernelConfig { home_dir: tmp.path().to_path_buf(), data_dir: tmp.path().join("data"), default_model: DefaultModelConfig { provider: "ollama".to_string(), model: "test".to_string(), api_key_env: "OLLAMA_API_KEY".to_string(), base_url: None, }, ..KernelConfig::default() }; let kernel = OpenFangKernel::boot_with_config(config).unwrap(); let kernel = Arc::new(kernel); let state = Arc::new(AppState { kernel: kernel.clone(), started_at: Instant::now(), peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let app = Router::new() .route("/api/health", axum::routing::get(routes::health)) .with_state(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); // Hit health endpoint immediately — should respond fast let client = reqwest::Client::new(); let start = Instant::now(); let resp = client .get(format!("http://{}/api/health", addr)) .send() .await .unwrap(); let latency = start.elapsed(); assert_eq!(resp.status(), 200); assert!( latency.as_millis() < 1000, "Health endpoint should respond in <1s, took {}ms", latency.as_millis() ); kernel.shutdown(); }