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
|
||||
}
|
||||
|
||||
#[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> {
|
||||
if !Path::new(path).exists() {
|
||||
return Ok(Config::default());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use log::{info, warn};
|
||||
@@ -57,7 +57,7 @@ impl ServiceEntry {
|
||||
pub struct ServiceStore {
|
||||
entries: HashMap<String, ServiceEntry>,
|
||||
/// Services defined in numa.toml (not persisted to user file)
|
||||
config_services: std::collections::HashSet<String>,
|
||||
config_services: HashSet<String>,
|
||||
persist_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ impl ServiceStore {
|
||||
let persist_path = dirs_path();
|
||||
ServiceStore {
|
||||
entries: HashMap::new(),
|
||||
config_services: std::collections::HashSet::new(),
|
||||
config_services: HashSet::new(),
|
||||
persist_path,
|
||||
}
|
||||
}
|
||||
@@ -204,3 +204,157 @@ impl ServiceStore {
|
||||
fn dirs_path() -> PathBuf {
|
||||
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