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:
Razvan Dimescu
2026-03-23 07:49:06 +02:00
parent ed12659b26
commit c021d5a0c8
2 changed files with 223 additions and 3 deletions

View File

@@ -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());

View File

@@ -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());
}
}