add unit tests for route matching, config defaults, and service store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -250,6 +250,72 @@ fn default_lan_peer_timeout() -> u64 {
|
|||||||
90
|
90
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lan_disabled_by_default() {
|
||||||
|
assert!(!LanConfig::default().enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_binds_localhost_by_default() {
|
||||||
|
assert_eq!(ServerConfig::default().api_bind_addr, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_binds_localhost_by_default() {
|
||||||
|
assert_eq!(ProxyConfig::default().bind_addr, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_toml_gives_defaults() {
|
||||||
|
let config: Config = toml::from_str("").unwrap();
|
||||||
|
assert!(!config.lan.enabled);
|
||||||
|
assert_eq!(config.server.api_bind_addr, "127.0.0.1");
|
||||||
|
assert_eq!(config.proxy.bind_addr, "127.0.0.1");
|
||||||
|
assert_eq!(config.server.api_port, ServerConfig::default().api_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lan_enabled_parses() {
|
||||||
|
let config: Config = toml::from_str("[lan]\nenabled = true").unwrap();
|
||||||
|
assert!(config.lan.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_bind_addrs_parse() {
|
||||||
|
let toml = r#"
|
||||||
|
[server]
|
||||||
|
api_bind_addr = "0.0.0.0"
|
||||||
|
[proxy]
|
||||||
|
bind_addr = "0.0.0.0"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.server.api_bind_addr, "0.0.0.0");
|
||||||
|
assert_eq!(config.proxy.bind_addr, "0.0.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_routes_parse_from_toml() {
|
||||||
|
let toml = r#"
|
||||||
|
[[services]]
|
||||||
|
name = "app"
|
||||||
|
target_port = 3000
|
||||||
|
routes = [
|
||||||
|
{ path = "/api", port = 4000, strip = true },
|
||||||
|
{ path = "/static", port = 5000 },
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.services.len(), 1);
|
||||||
|
assert_eq!(config.services[0].routes.len(), 2);
|
||||||
|
assert!(config.services[0].routes[0].strip);
|
||||||
|
assert!(!config.services[0].routes[1].strip); // default false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_config(path: &str) -> Result<Config> {
|
pub fn load_config(path: &str) -> Result<Config> {
|
||||||
if !Path::new(path).exists() {
|
if !Path::new(path).exists() {
|
||||||
return Ok(Config::default());
|
return Ok(Config::default());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
@@ -57,7 +57,7 @@ impl ServiceEntry {
|
|||||||
pub struct ServiceStore {
|
pub struct ServiceStore {
|
||||||
entries: HashMap<String, ServiceEntry>,
|
entries: HashMap<String, ServiceEntry>,
|
||||||
/// Services defined in numa.toml (not persisted to user file)
|
/// Services defined in numa.toml (not persisted to user file)
|
||||||
config_services: std::collections::HashSet<String>,
|
config_services: HashSet<String>,
|
||||||
persist_path: PathBuf,
|
persist_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ impl ServiceStore {
|
|||||||
let persist_path = dirs_path();
|
let persist_path = dirs_path();
|
||||||
ServiceStore {
|
ServiceStore {
|
||||||
entries: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
config_services: std::collections::HashSet::new(),
|
config_services: HashSet::new(),
|
||||||
persist_path,
|
persist_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,3 +204,157 @@ impl ServiceStore {
|
|||||||
fn dirs_path() -> PathBuf {
|
fn dirs_path() -> PathBuf {
|
||||||
crate::config_dir().join("services.json")
|
crate::config_dir().join("services.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn entry(port: u16, routes: Vec<RouteEntry>) -> ServiceEntry {
|
||||||
|
ServiceEntry {
|
||||||
|
name: "app".into(),
|
||||||
|
target_port: port,
|
||||||
|
routes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(path: &str, port: u16, strip: bool) -> RouteEntry {
|
||||||
|
RouteEntry {
|
||||||
|
path: path.into(),
|
||||||
|
port,
|
||||||
|
strip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_store() -> ServiceStore {
|
||||||
|
ServiceStore {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
config_services: HashSet::new(),
|
||||||
|
persist_path: PathBuf::from("/dev/null"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolve_route ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_routes_returns_default_port() {
|
||||||
|
let e = entry(3000, vec![]);
|
||||||
|
assert_eq!(e.resolve_route("/anything"), (3000, "/anything".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_match() {
|
||||||
|
let e = entry(3000, vec![route("/api", 4000, false)]);
|
||||||
|
assert_eq!(e.resolve_route("/api"), (4000, "/api".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_match() {
|
||||||
|
let e = entry(3000, vec![route("/api", 4000, false)]);
|
||||||
|
assert_eq!(e.resolve_route("/api/users"), (4000, "/api/users".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segment_boundary_rejects_partial() {
|
||||||
|
let e = entry(3000, vec![route("/api", 4000, false)]);
|
||||||
|
// /apiary must NOT match /api — different segment
|
||||||
|
assert_eq!(e.resolve_route("/apiary"), (3000, "/apiary".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segment_boundary_rejects_apikey() {
|
||||||
|
let e = entry(3000, vec![route("/api", 4000, false)]);
|
||||||
|
assert_eq!(e.resolve_route("/apikey"), (3000, "/apikey".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longest_prefix_wins() {
|
||||||
|
let e = entry(
|
||||||
|
3000,
|
||||||
|
vec![route("/api", 4000, false), route("/api/v2", 5000, false)],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
e.resolve_route("/api/v2/users"),
|
||||||
|
(5000, "/api/v2/users".into())
|
||||||
|
);
|
||||||
|
// shorter prefix still works for non-v2 paths
|
||||||
|
assert_eq!(
|
||||||
|
e.resolve_route("/api/v1/users"),
|
||||||
|
(4000, "/api/v1/users".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_removes_prefix() {
|
||||||
|
let e = entry(3000, vec![route("/api", 4000, true)]);
|
||||||
|
assert_eq!(e.resolve_route("/api/users"), (4000, "/users".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_exact_path_gives_root() {
|
||||||
|
let e = entry(3000, vec![route("/api", 4000, true)]);
|
||||||
|
assert_eq!(e.resolve_route("/api"), (4000, "/".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_slash_route_matches() {
|
||||||
|
let e = entry(3000, vec![route("/app/", 4000, false)]);
|
||||||
|
assert_eq!(
|
||||||
|
e.resolve_route("/app/dashboard"),
|
||||||
|
(4000, "/app/dashboard".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ServiceStore: add_route / remove_route ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_route_to_existing_service() {
|
||||||
|
let mut store = test_store();
|
||||||
|
store.insert_from_config("app", 3000, vec![]);
|
||||||
|
assert!(store.add_route("app", "/api".into(), 4000, false));
|
||||||
|
let entry = store.lookup("app").unwrap();
|
||||||
|
assert_eq!(entry.routes.len(), 1);
|
||||||
|
assert_eq!(entry.routes[0].path, "/api");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_route_to_missing_service_returns_false() {
|
||||||
|
let mut store = test_store();
|
||||||
|
assert!(!store.add_route("ghost", "/api".into(), 4000, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_route_deduplicates_by_path() {
|
||||||
|
let mut store = test_store();
|
||||||
|
store.insert_from_config("app", 3000, vec![]);
|
||||||
|
store.add_route("app", "/api".into(), 4000, false);
|
||||||
|
store.add_route("app", "/api".into(), 5000, true);
|
||||||
|
let entry = store.lookup("app").unwrap();
|
||||||
|
assert_eq!(entry.routes.len(), 1);
|
||||||
|
assert_eq!(entry.routes[0].port, 5000);
|
||||||
|
assert!(entry.routes[0].strip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_route_returns_true_when_found() {
|
||||||
|
let mut store = test_store();
|
||||||
|
store.insert_from_config("app", 3000, vec![route("/api", 4000, false)]);
|
||||||
|
assert!(store.remove_route("app", "/api"));
|
||||||
|
assert!(store.lookup("app").unwrap().routes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_route_returns_false_when_missing() {
|
||||||
|
let mut store = test_store();
|
||||||
|
store.insert_from_config("app", 3000, vec![]);
|
||||||
|
assert!(!store.remove_route("app", "/nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_is_case_insensitive() {
|
||||||
|
let mut store = test_store();
|
||||||
|
store.insert_from_config("MyApp", 3000, vec![]);
|
||||||
|
assert!(store.lookup("myapp").is_some());
|
||||||
|
assert!(store.lookup("MYAPP").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user