PicoBot/tests/test_config_reload.rs

147 lines
4.0 KiB
Rust

use std::net::TcpListener;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use serde_json::{Value, json};
use tokio::process::{Child, Command};
struct GatewayProcess(Child);
impl Drop for GatewayProcess {
fn drop(&mut self) {
let _ = self.0.start_kill();
}
}
fn available_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn config(workspace: &Path, model_id: &str) -> Value {
json!({
"providers": {
"provider": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test"
}
},
"models": { "model": { "model_id": model_id } },
"agents": { "default": { "provider": "provider", "model": "model" } },
"gateway": { "require_pairing": false },
"workspace_dir": workspace
})
}
async fn wait_for_health(client: &reqwest::Client, base: &str) {
for _ in 0..100 {
if client
.get(format!("{base}/health"))
.send()
.await
.is_ok_and(|response| response.status().is_success())
{
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("gateway did not become healthy");
}
#[tokio::test]
async fn gateway_reloads_valid_config_and_keeps_serving_after_invalid_config() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let config_dir = home.join(".picobot");
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::create_dir_all(&workspace).unwrap();
let config_path = config_dir.join("config.json");
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&config(&workspace, "old-model")).unwrap(),
)
.unwrap();
let port = available_port();
let child = Command::new(env!("CARGO_BIN_EXE_picobot"))
.args([
"gateway",
"--host",
"127.0.0.1",
"--port",
&port.to_string(),
])
.env("HOME", &home)
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut gateway = GatewayProcess(child);
let client = reqwest::Client::new();
let base = format!("http://127.0.0.1:{port}");
wait_for_health(&client, &base).await;
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&config(&workspace, "new-model")).unwrap(),
)
.unwrap();
let accepted: Value = client
.post(format!("{base}/api/config/reload"))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(accepted["generation"], 2);
let mut active = false;
for _ in 0..100 {
let response = client
.get(format!("{base}/api/config/reload/status"))
.send()
.await;
if let Ok(response) = response
&& let Ok(status) = response.json::<Value>().await
&& status["generation"] == 2
&& status["phase"] == "active"
{
active = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(active, "reloaded generation did not become active");
std::fs::write(&config_path, b"{").unwrap();
let invalid = client
.post(format!("{base}/api/config/reload"))
.send()
.await
.unwrap();
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
assert!(
client
.get(format!("{base}/health"))
.send()
.await
.unwrap()
.status()
.is_success(),
"invalid candidate stopped the active generation"
);
gateway.0.start_kill().unwrap();
gateway.0.wait().await.unwrap();
}