use std::{ net::TcpListener, path::PathBuf, process::{Child, Command, Stdio}, sync::Arc, time::Duration, }; use chrono::Utc; use reqwest::Client; use serde_json::Value; use tempfile::TempDir; use tokio::{net::TcpListener as TokioTcpListener, sync::Mutex, task::JoinHandle, time::sleep}; use tvctl::{ adapters::Device, daemon::{ Daemon, SharedDaemon, cache::AppCacheStore, config::{DaemonConfig, DevConfig, DiscoveryConfig, RuntimePaths, TvctlConfig}, discovery::DiscoveryService, registry::{AdapterRegistry, DeviceRegistry}, state::StateCache, }, }; use uuid::Uuid; struct TestDaemon { _temp_dir: TempDir, child: Child, base_url: String, device: Device, } struct InProcessApi { _temp_dir: TempDir, server: JoinHandle<()>, base_url: String, device: Device, socket_path: PathBuf, } impl TestDaemon { async fn start() -> Self { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let root = temp_dir.path(); let home = root.join("home"); let config_home = root.join("config"); let data_home = root.join("data"); let runtime_dir = root.join("runtime"); std::fs::create_dir_all(&home).expect("home dir should exist"); std::fs::create_dir_all(&config_home).expect("config dir should exist"); std::fs::create_dir_all(&data_home).expect("data dir should exist"); std::fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); let port = pick_unused_port(); let config = TvctlConfig { daemon: tvctl::daemon::config::DaemonConfig { socket: runtime_dir.join("tvctl.sock").display().to_string(), http_enabled: true, http_port: port, http_host: "127.0.0.1".to_string(), log_level: "info".to_string(), }, discovery: tvctl::daemon::config::DiscoveryConfig { auto_discover: false, interval_secs: 300, timeout_secs: 1, }, dev: tvctl::daemon::config::DevConfig { enabled: true, roku_username: "rokudev".to_string(), roku_password: "secret".to_string(), }, ..TvctlConfig::default() }; let config_path = config_home.join("tvctl/config.toml"); let data_dir = data_home.join("tvctl"); config .save_to_path(&config_path) .await .expect("config should save"); let device = Device { id: Uuid::new_v4(), name: "API Test Roku".to_string(), original_name: "API Test Roku".to_string(), platform: "roku".to_string(), address: "127.0.0.2".parse().expect("loopback should parse"), port: 8060, is_default: true, discovered_at: Utc::now(), last_seen: Utc::now(), }; std::fs::create_dir_all(&data_dir).expect("data dir should exist"); std::fs::write( data_dir.join("devices.json"), serde_json::to_vec_pretty(&vec![device.clone()]).expect("device should encode"), ) .expect("devices file should write"); let binary = std::env::var("CARGO_BIN_EXE_tvctl").expect("binary path should exist"); let child = Command::new(binary) .arg("__daemon_serve") .env("HOME", &home) .env("XDG_CONFIG_HOME", &config_home) .env("XDG_DATA_HOME", &data_home) .env("XDG_RUNTIME_DIR", &runtime_dir) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("daemon should start"); let instance = Self { _temp_dir: temp_dir, child, base_url: format!("http://127.0.0.1:{port}/v1"), device, }; instance.wait_until_ready().await; instance } async fn wait_until_ready(&self) { let client = Client::new(); for _ in 0..40 { if let Ok(response) = client .get(format!("{}/daemon/status", self.base_url)) .send() .await && response.status().is_success() { return; } sleep(Duration::from_millis(100)).await; } panic!("daemon HTTP API did not become ready"); } fn shutdown(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } impl Drop for TestDaemon { fn drop(&mut self) { self.shutdown(); } } impl InProcessApi { async fn start() -> Self { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let root = temp_dir.path(); let config_home = root.join("config"); let data_home = root.join("data"); let runtime_dir = root.join("runtime"); std::fs::create_dir_all(&config_home).expect("config dir should exist"); std::fs::create_dir_all(&data_home).expect("data dir should exist"); std::fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); let listener = TokioTcpListener::bind("127.0.0.1:0") .await .expect("ephemeral http port should bind"); let port = listener .local_addr() .expect("listener addr should exist") .port(); let socket_path = runtime_dir.join("tvctl.sock"); let config = TvctlConfig { daemon: DaemonConfig { socket: socket_path.display().to_string(), http_enabled: true, http_port: port, http_host: "127.0.0.1".to_string(), log_level: "info".to_string(), }, discovery: DiscoveryConfig { auto_discover: false, interval_secs: 300, timeout_secs: 1, }, dev: DevConfig { enabled: true, roku_username: "rokudev".to_string(), roku_password: "secret".to_string(), }, ..TvctlConfig::default() }; let config_path = config_home.join("tvctl/config.toml"); let data_dir = data_home.join("tvctl"); config .save_to_path(&config_path) .await .expect("config should save"); let device = Device { id: Uuid::new_v4(), name: "In Process Roku".to_string(), original_name: "In Process Roku".to_string(), platform: "roku".to_string(), address: "127.0.0.3".parse().expect("loopback should parse"), port: 8060, is_default: true, discovered_at: Utc::now(), last_seen: Utc::now(), }; std::fs::create_dir_all(&data_dir).expect("data dir should exist"); std::fs::write( data_dir.join("devices.json"), serde_json::to_vec_pretty(&vec![device.clone()]).expect("device should encode"), ) .expect("devices file should write"); let paths = RuntimePaths { config_file: config_path, data_dir: data_dir.clone(), devices_file: data_dir.join("devices.json"), cache_dir: data_dir.join("cache"), active_socket_file: data_dir.join("active_socket"), socket_file: socket_path.clone(), }; let adapters = AdapterRegistry::from_config(&config); let mut registry = DeviceRegistry::load(paths.devices_file.clone()) .await .expect("registry should load"); if !config.devices.default.is_empty() { let _ = registry.set_default(&config.devices.default); } else { registry.ensure_default(); } let daemon: SharedDaemon = Arc::new(Mutex::new(Daemon { config, paths: paths.clone(), registry, app_cache: AppCacheStore::new(paths.cache_dir.clone()), state_cache: StateCache::default(), adapters: adapters.clone(), discovery: DiscoveryService::new(adapters), })); let app = tvctl::api::router(daemon); let server = tokio::spawn(async move { axum::serve(listener, app) .await .expect("in-process api should serve"); }); let instance = Self { _temp_dir: temp_dir, server, base_url: format!("http://127.0.0.1:{port}/v1"), device, socket_path, }; instance.wait_until_ready().await; instance } async fn wait_until_ready(&self) { let client = Client::new(); for _ in 0..40 { if let Ok(response) = client .get(format!("{}/daemon/status", self.base_url)) .send() .await && response.status().is_success() { return; } sleep(Duration::from_millis(100)).await; } panic!("in-process HTTP API did not become ready"); } } impl Drop for InProcessApi { fn drop(&mut self) { self.server.abort(); } } fn pick_unused_port() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").expect("ephemeral port should bind"); let port = listener .local_addr() .expect("local addr should exist") .port(); drop(listener); port } #[tokio::test] async fn http_api_exposes_core_routes_and_config_patch() { let daemon = TestDaemon::start().await; let client = Client::new(); let status = client .get(format!("{}/daemon/status", daemon.base_url)) .send() .await .expect("status should respond"); assert!(status.status().is_success()); let status_json: Value = status.json().await.expect("status json should parse"); assert_eq!(status_json["ok"], true); assert_eq!(status_json["data"]["device_count"], 1); let devices = client .get(format!("{}/devices", daemon.base_url)) .send() .await .expect("devices should respond"); assert!(devices.status().is_success()); let devices_json: Value = devices.json().await.expect("devices json should parse"); assert_eq!(devices_json["data"][0]["name"], daemon.device.name); let device = client .get(format!("{}/devices/{}", daemon.base_url, daemon.device.id)) .send() .await .expect("device should respond"); assert!(device.status().is_success()); let device_json: Value = device.json().await.expect("device json should parse"); assert_eq!(device_json["data"]["id"], daemon.device.id.to_string()); let invalid_key = client .post(format!( "{}/devices/{}/remote/key", daemon.base_url, daemon.device.id )) .json(&serde_json::json!({ "key": "definitely-not-a-key" })) .send() .await .expect("invalid key request should respond"); assert_eq!(invalid_key.status(), 400); let invalid_key_json: Value = invalid_key .json() .await .expect("invalid key json should parse"); assert_eq!(invalid_key_json["error"]["code"], "invalid_key"); let patch = client .patch(format!("{}/config", daemon.base_url)) .json(&serde_json::json!({ "daemon.log_level": "debug", "remote.roku_press_duration_ms": 90 })) .send() .await .expect("config patch should respond"); assert!(patch.status().is_success()); let patch_json: Value = patch.json().await.expect("patch json should parse"); assert_eq!(patch_json["ok"], true); let config = client .get(format!("{}/config", daemon.base_url)) .send() .await .expect("config should respond"); assert!(config.status().is_success()); let config_json: Value = config.json().await.expect("config json should parse"); assert_eq!(config_json["data"]["daemon"]["log_level"], "debug"); assert_eq!(config_json["data"]["remote"]["roku_press_duration_ms"], 90); assert_eq!(config_json["data"]["dev"]["roku_password"], ""); let reload = client .post(format!("{}/config/reload", daemon.base_url)) .send() .await .expect("reload should respond"); assert!(reload.status().is_success()); let reload_json: Value = reload.json().await.expect("reload json should parse"); assert_eq!(reload_json["ok"], true); } #[tokio::test] async fn http_api_routes_requests_without_unix_socket_loopback() { let api = InProcessApi::start().await; assert!( !api.socket_path.exists(), "test should not start a daemon unix socket listener" ); let client = Client::new(); let devices = client .get(format!("{}/devices", api.base_url)) .send() .await .expect("devices should respond"); assert!(devices.status().is_success()); let devices_json: Value = devices.json().await.expect("devices json should parse"); assert_eq!(devices_json["ok"], true); assert_eq!(devices_json["data"][0]["id"], api.device.id.to_string()); }