feat: add HTTP API and integration coverage

Expose the daemon request surface over /v1 with Axum, reuse shared key
parsing between CLI and HTTP, and add an isolated end-to-end HTTP test
that boots a real daemon process with temp XDG paths.
This commit is contained in:
44r0n7
2026-04-15 15:40:50 -04:00
parent 45620b1ab5
commit b8a0a0ff16
9 changed files with 865 additions and 68 deletions
+226
View File
@@ -0,0 +1,226 @@
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"], "<redacted>");
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);
}