use std::{ net::TcpListener, process::{Child, Command, Stdio}, time::Duration, }; use chrono::Utc; use reqwest::Client; use serde_json::Value; use tempfile::TempDir; use tokio::time::sleep; use tvctl::{adapters::Device, daemon::config::TvctlConfig}; use uuid::Uuid; struct TestDaemon { _temp_dir: TempDir, child: Child, base_url: String, device: Device, } 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 { if 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(); } } 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); }