From c021d5a0c88e81b8cfe9ba41c5186d308f8b2686 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 07:49:06 +0200 Subject: [PATCH] add unit tests for route matching, config defaults, and service store Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 66 ++++++++++++++++++ src/service_store.rs | 160 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 44e9fd7..e7eb607 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { if !Path::new(path).exists() { return Ok(Config::default()); diff --git a/src/service_store.rs b/src/service_store.rs index e8a4ebc..e7c6d87 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -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, /// Services defined in numa.toml (not persisted to user file) - config_services: std::collections::HashSet, + config_services: HashSet, 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) -> 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()); + } +}