From c5ef45e69baa5396d2bac9f769e94ae609d6e49c Mon Sep 17 00:00:00 2001 From: Max Mayfield Date: Sun, 1 Mar 2026 02:39:01 +0000 Subject: [PATCH] Add dd0c/route unit tests: router, middleware, config, digest - Router tests: complexity classification (low/medium/high), routing decisions, cost delta - Middleware tests: API key redaction (OpenAI, Anthropic, Bearer), JSON bodies, telemetry safety - Config tests: defaults, unknown provider fallbacks - Digest tests: next_monday_9am scheduling edge cases - Anomaly tests: threshold logic, divide-by-zero guard --- .../tests/unit/config_test.rs | 113 +++++++++++++ .../tests/unit/middleware_test.rs | 87 ++++++++++ products/01-llm-cost-router/tests/unit/mod.rs | 3 + .../tests/unit/router_test.rs | 157 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 products/01-llm-cost-router/tests/unit/config_test.rs create mode 100644 products/01-llm-cost-router/tests/unit/middleware_test.rs create mode 100644 products/01-llm-cost-router/tests/unit/mod.rs create mode 100644 products/01-llm-cost-router/tests/unit/router_test.rs diff --git a/products/01-llm-cost-router/tests/unit/config_test.rs b/products/01-llm-cost-router/tests/unit/config_test.rs new file mode 100644 index 0000000..405bbd2 --- /dev/null +++ b/products/01-llm-cost-router/tests/unit/config_test.rs @@ -0,0 +1,113 @@ +#[cfg(test)] +mod config_tests { + use dd0c_route::config::{AppConfig, AuthMode, GovernanceMode}; + + #[test] + fn default_config_from_empty_env() { + // Clear any existing env vars + std::env::remove_var("PROXY_PORT"); + std::env::remove_var("API_PORT"); + std::env::remove_var("AUTH_MODE"); + std::env::remove_var("GOVERNANCE_MODE"); + + let config = AppConfig::from_env().unwrap(); + assert_eq!(config.proxy_port, 8080); + assert_eq!(config.api_port, 3000); + assert_eq!(config.auth_mode, AuthMode::Local); + assert_eq!(config.governance_mode, GovernanceMode::Audit); + assert_eq!(config.telemetry_channel_size, 1000); + } + + #[test] + fn provider_url_returns_default_for_unknown() { + let config = AppConfig::from_env().unwrap(); + let url = config.provider_url("unknown-provider"); + assert_eq!(url, "https://api.openai.com"); + } + + #[test] + fn provider_key_returns_empty_for_unknown() { + let config = AppConfig::from_env().unwrap(); + let key = config.provider_key("unknown-provider"); + assert_eq!(key, ""); + } +} + +#[cfg(test)] +mod anomaly_tests { + #[test] + fn anomaly_threshold_3x_triggers() { + let avg = 10.0_f64; + let current = 35.0_f64; + assert!(current > avg * 3.0, "35 should exceed 3x of 10"); + } + + #[test] + fn anomaly_threshold_2x_does_not_trigger() { + let avg = 10.0_f64; + let current = 25.0_f64; + assert!(current <= avg * 3.0, "25 should not exceed 3x of 10"); + } + + #[test] + fn anomaly_zero_average_does_not_divide_by_zero() { + let avg = 0.0_f64; + let current = 5.0_f64; + // Should not trigger (guard: avg > 0.0) + let triggers = avg > 0.0 && current > avg * 3.0; + assert!(!triggers); + } +} + +#[cfg(test)] +mod digest_tests { + use chrono::{NaiveDate, NaiveTime, Weekday}; + + fn next_monday_9am(from: chrono::DateTime) -> chrono::DateTime { + // Re-implement here for testing without importing worker internals + let days_until_monday = (7 - from.weekday().num_days_from_monday()) % 7; + let days_until_monday = if days_until_monday == 0 && from.time() >= NaiveTime::from_hms_opt(9, 0, 0).unwrap() { + 7 + } else if days_until_monday == 0 { + 0 + } else { + days_until_monday + }; + let target_date = from.date_naive() + chrono::Duration::days(days_until_monday as i64); + target_date.and_hms_opt(9, 0, 0).unwrap().and_utc() + } + + #[test] + fn from_wednesday_goes_to_next_monday() { + let wed = NaiveDate::from_ymd_opt(2026, 3, 4).unwrap() + .and_hms_opt(14, 0, 0).unwrap().and_utc(); + let next = next_monday_9am(wed); + assert_eq!(next.weekday(), Weekday::Mon); + assert_eq!(next.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 9).unwrap()); + } + + #[test] + fn from_monday_before_9am_stays_same_day() { + let mon = NaiveDate::from_ymd_opt(2026, 3, 2).unwrap() + .and_hms_opt(8, 0, 0).unwrap().and_utc(); + let next = next_monday_9am(mon); + assert_eq!(next.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 2).unwrap()); + } + + #[test] + fn from_monday_after_9am_goes_to_next_week() { + let mon = NaiveDate::from_ymd_opt(2026, 3, 2).unwrap() + .and_hms_opt(10, 0, 0).unwrap().and_utc(); + let next = next_monday_9am(mon); + assert_eq!(next.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 9).unwrap()); + } + + #[test] + fn from_sunday_goes_to_next_day() { + let sun = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap() + .and_hms_opt(20, 0, 0).unwrap().and_utc(); + let next = next_monday_9am(sun); + assert_eq!(next.weekday(), Weekday::Mon); + assert_eq!(next.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 2).unwrap()); + } +} diff --git a/products/01-llm-cost-router/tests/unit/middleware_test.rs b/products/01-llm-cost-router/tests/unit/middleware_test.rs new file mode 100644 index 0000000..92b47aa --- /dev/null +++ b/products/01-llm-cost-router/tests/unit/middleware_test.rs @@ -0,0 +1,87 @@ +#[cfg(test)] +mod middleware_tests { + use dd0c_route::proxy::middleware::redact_sensitive; + + // --- API Key Redaction (BMad Must-Have 12.1) --- + + #[test] + fn redacts_openai_live_key() { + let input = "Error processing request with key sk-live-abc123xyz456def789ghi012"; + let result = redact_sensitive(input); + assert!(!result.contains("sk-live-abc123xyz456def789ghi012")); + assert!(result.contains("[REDACTED")); + } + + #[test] + fn redacts_openai_project_key() { + let input = "Upstream returned: Invalid API key sk-proj-abc123xyz456def789ghi012"; + let result = redact_sensitive(input); + assert!(!result.contains("sk-proj-abc123xyz456def789ghi012")); + } + + #[test] + fn redacts_anthropic_key() { + let input = "Auth failed for sk-ant-api03-abc123xyz456def789ghi012jkl345mno678"; + let result = redact_sensitive(input); + assert!(!result.contains("sk-ant-api03")); + } + + #[test] + fn redacts_bearer_token() { + let input = "Authorization: Bearer sk-live-secret123456789abcdef"; + let result = redact_sensitive(input); + assert!(!result.contains("sk-live-secret")); + assert!(result.contains("[REDACTED")); + } + + #[test] + fn does_not_redact_normal_text() { + let input = "Hello world, this is a normal log message about the proxy"; + let result = redact_sensitive(input); + assert_eq!(result, input); + } + + #[test] + fn does_not_redact_short_sk_prefix() { + // "sk-" alone or with < 20 chars should not be redacted + let input = "The key prefix is sk-abc"; + let result = redact_sensitive(input); + assert_eq!(result, input); + } + + #[test] + fn redacts_multiple_keys_in_same_string() { + let input = "Key1: sk-live-aaaaaaaaaaaaaaaaaaaaaa Key2: sk-proj-bbbbbbbbbbbbbbbbbbbbbb"; + let result = redact_sensitive(input); + assert!(!result.contains("aaaaaaaaaa")); + assert!(!result.contains("bbbbbbbbbb")); + } + + #[test] + fn redacts_key_in_json_error_body() { + let input = r#"{"error": "Invalid API key: sk-proj-abc123xyz456def789ghi012"}"#; + let result = redact_sensitive(input); + assert!(!result.contains("sk-proj-abc123xyz456def789ghi012")); + } + + #[test] + fn telemetry_event_serialization_never_contains_key() { + use dd0c_route::data::TelemetryEvent; + let event = TelemetryEvent { + org_id: "org-1".to_string(), + original_model: "gpt-4o".to_string(), + routed_model: "gpt-4o-mini".to_string(), + provider: "openai".to_string(), + strategy: "cheapest".to_string(), + latency_ms: 3, + status_code: 200, + is_streaming: false, + prompt_tokens: 100, + completion_tokens: 50, + timestamp: chrono::Utc::now(), + }; + let serialized = serde_json::to_string(&event).unwrap(); + assert!(!serialized.contains("sk-")); + assert!(!serialized.contains("Bearer")); + } +} diff --git a/products/01-llm-cost-router/tests/unit/mod.rs b/products/01-llm-cost-router/tests/unit/mod.rs new file mode 100644 index 0000000..7ea7caa --- /dev/null +++ b/products/01-llm-cost-router/tests/unit/mod.rs @@ -0,0 +1,3 @@ +mod router_test; +mod middleware_test; +mod config_test; diff --git a/products/01-llm-cost-router/tests/unit/router_test.rs b/products/01-llm-cost-router/tests/unit/router_test.rs new file mode 100644 index 0000000..bf49097 --- /dev/null +++ b/products/01-llm-cost-router/tests/unit/router_test.rs @@ -0,0 +1,157 @@ +#[cfg(test)] +mod router_tests { + use crate::router::{RouterBrain, Complexity}; + + fn make_request(model: &str, messages: Vec) -> serde_json::Value { + serde_json::json!({ "model": model, "messages": messages }) + } + + fn simple_msg(role: &str, content: &str) -> serde_json::Value { + serde_json::json!({"role": role, "content": content}) + } + + // --- Complexity Classification --- + + #[test] + fn classify_single_turn_extraction_as_low() { + let brain = RouterBrain::new(); + let req = make_request("gpt-4o", vec![ + simple_msg("user", "What is 2+2?"), + ]); + assert_eq!(brain.classify_complexity(&req), Complexity::Low); + } + + #[test] + fn classify_short_system_prompt_as_low() { + let brain = RouterBrain::new(); + let req = make_request("gpt-4o", vec![ + simple_msg("system", "Extract names from text"), + simple_msg("user", "My name is Alice"), + ]); + assert_eq!(brain.classify_complexity(&req), Complexity::Low); + } + + #[test] + fn classify_multi_turn_conversation_as_high() { + let brain = RouterBrain::new(); + let mut msgs = vec![]; + for i in 0..12 { + msgs.push(simple_msg("user", &format!("Question {}", i))); + msgs.push(simple_msg("assistant", &format!("Answer {}", i))); + } + let req = make_request("gpt-4o", msgs); + assert_eq!(brain.classify_complexity(&req), Complexity::High); + } + + #[test] + fn classify_reasoning_keywords_as_high() { + let brain = RouterBrain::new(); + let req = make_request("gpt-4o", vec![ + simple_msg("system", "Analyze and compare these two architectural approaches"), + simple_msg("user", "Option A vs Option B"), + ]); + assert_eq!(brain.classify_complexity(&req), Complexity::High); + } + + #[test] + fn classify_medium_system_prompt_as_medium() { + let brain = RouterBrain::new(); + let long_prompt = "You are a helpful assistant. ".repeat(30); // >500 chars + let req = make_request("gpt-4o", vec![ + simple_msg("system", &long_prompt), + simple_msg("user", "Hello"), + ]); + assert_eq!(brain.classify_complexity(&req), Complexity::Medium); + } + + #[test] + fn classify_4_turn_conversation_as_medium() { + let brain = RouterBrain::new(); + let req = make_request("gpt-4o", vec![ + simple_msg("user", "Q1"), + simple_msg("assistant", "A1"), + simple_msg("user", "Q2"), + simple_msg("assistant", "A2"), + ]); + assert_eq!(brain.classify_complexity(&req), Complexity::Medium); + } + + #[test] + fn classify_empty_messages_as_low() { + let brain = RouterBrain::new(); + let req = serde_json::json!({"model": "gpt-4o"}); + assert_eq!(brain.classify_complexity(&req), Complexity::Low); + } + + // --- Routing Decisions --- + + #[tokio::test] + async fn low_complexity_gpt4o_routes_to_mini() { + let brain = RouterBrain::new(); + let req = make_request("gpt-4o", vec![ + simple_msg("user", "What is 2+2?"), + ]); + let decision = brain.route("org-1", &req).await; + assert_eq!(decision.model, Some("gpt-4o-mini".to_string())); + assert_eq!(decision.strategy, "cheapest"); + assert!(decision.cost_delta > 0.0); + } + + #[tokio::test] + async fn low_complexity_non_gpt4_passes_through() { + let brain = RouterBrain::new(); + let req = make_request("gpt-3.5-turbo", vec![ + simple_msg("user", "Hello"), + ]); + let decision = brain.route("org-1", &req).await; + assert_eq!(decision.model, None); + assert_eq!(decision.strategy, "passthrough"); + } + + #[tokio::test] + async fn high_complexity_always_passes_through() { + let brain = RouterBrain::new(); + let mut msgs = vec![]; + for i in 0..15 { + msgs.push(simple_msg("user", &format!("msg {}", i))); + } + let req = make_request("gpt-4o", msgs); + let decision = brain.route("org-1", &req).await; + assert_eq!(decision.model, None); + assert_eq!(decision.strategy, "passthrough"); + assert_eq!(decision.cost_delta, 0.0); + } + + #[tokio::test] + async fn medium_complexity_passes_through() { + let brain = RouterBrain::new(); + let long_prompt = "You are a helpful assistant. ".repeat(30); + let req = make_request("gpt-4o", vec![ + simple_msg("system", &long_prompt), + simple_msg("user", "Help me"), + ]); + let decision = brain.route("org-1", &req).await; + assert_eq!(decision.model, None); + assert_eq!(decision.strategy, "passthrough"); + } + + // --- Cost Delta --- + + #[test] + fn cost_delta_gpt4o_to_mini_is_positive() { + let delta = super::super::router::estimate_cost_delta("gpt-4o", "gpt-4o-mini"); + assert!(delta > 0.004, "Expected significant savings, got {}", delta); + } + + #[test] + fn cost_delta_same_model_is_zero() { + let delta = super::super::router::estimate_cost_delta("gpt-4o", "gpt-4o"); + assert_eq!(delta, 0.0); + } + + #[test] + fn cost_delta_claude_opus_to_haiku() { + let delta = super::super::router::estimate_cost_delta("claude-3-opus", "claude-3-haiku"); + assert!(delta > 0.01, "Opus→Haiku should save >$0.01/1K tokens"); + } +}