feat: finalize HTTP direct dispatch refactor and pending milestone updates
Switch API execution to direct daemon request handling, add regression coverage for non-socket HTTP dispatch, and include the remaining pending local updates across CLI/daemon/docs from the current worktree.
This commit is contained in:
+176
-2
@@ -1,6 +1,8 @@
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
path::PathBuf,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
@@ -8,8 +10,18 @@ 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 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 {
|
||||
@@ -19,6 +31,14 @@ struct TestDaemon {
|
||||
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");
|
||||
@@ -131,6 +151,139 @@ impl Drop for TestDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -223,3 +376,24 @@ async fn http_api_exposes_core_routes_and_config_patch() {
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user