#[path = "../src/models.rs"] mod models; #[path = "../src/adapters/proxifyre.rs"] mod proxifyre; #[path = "../src/adapters/proxy_router.rs"] mod proxy_router; #[path = "../src/adapters/singbox.rs"] mod singbox; use models::{ ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetKind, }; use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig}; use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest}; use singbox::{ SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG, SINGBOX_OUTPUT_FILE, }; use std::{ cell::RefCell, path::{Path, PathBuf}, }; #[test] fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplied() { let adapter = SingBoxAdapter::default(); let config = local_singbox_config("nl-1"); let cache = subscription_cache(); let checker = RecordingChecker::ok("configuration OK"); let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe"); let generated = adapter .generate_config( SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)), &checker, ) .expect("selected outbound should generate config"); let generated_config: serde_json::Value = serde_json::from_str(&generated.contents).expect("generated sing-box json"); assert_eq!(generated.adapter_id, "singbox"); assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE); assert_eq!(generated.selected_server_tag, "nl-1"); assert_eq!(generated.listen, "127.0.0.1"); assert_eq!(generated.listen_port, 1080); assert_eq!( generated.check, Some(SingBoxCheckResult { checked: true, success: true, message: "configuration OK".to_string(), }) ); assert_eq!(generated_config["log"]["level"], "info"); assert_eq!(generated_config["inbounds"][0]["type"], "mixed"); assert_eq!(generated_config["inbounds"][0]["listen"], "127.0.0.1"); assert_eq!(generated_config["inbounds"][0]["listen_port"], 1080); assert_eq!(generated_config["inbounds"][0]["set_system_proxy"], false); assert_eq!(generated_config["outbounds"][0]["type"], "vless"); assert_eq!( generated_config["outbounds"][0]["tag"], DEFAULT_VPN_OUTBOUND_TAG ); assert_eq!( generated_config["outbounds"][0]["server"], "nl.example.test" ); assert_eq!(generated_config["outbounds"][0]["packet_encoding"], "xudp"); assert_eq!(generated_config["route"]["final"], DEFAULT_VPN_OUTBOUND_TAG); let calls = checker.calls.borrow(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0.as_path(), binary_path); assert!(calls[0].1.contains(r#""type": "mixed""#)); assert!(calls[0].1.contains(r#""tag": "vpn""#)); } #[test] fn skips_singbox_check_when_binary_path_is_not_supplied() { let adapter = SingBoxAdapter::default(); let config = local_singbox_config("nl-1"); let cache = subscription_cache(); let checker = RecordingChecker::ok("should not run"); let generated = adapter .generate_config( SingBoxGenerationRequest::new(&config, &cache, None), &checker, ) .expect("binary path is optional"); assert_eq!(generated.check, None); assert!(checker.calls.borrow().is_empty()); } #[test] fn blocks_config_when_server_is_not_selected() { let adapter = SingBoxAdapter::default(); let mut config = local_singbox_config("nl-1"); config.selected_server_tag = None; let cache = subscription_cache(); let checker = RecordingChecker::ok("should not run"); let error = adapter .generate_config( SingBoxGenerationRequest::new(&config, &cache, None), &checker, ) .expect_err("missing selected server should block config"); assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedServer); assert!(checker.calls.borrow().is_empty()); } #[test] fn blocks_config_when_selected_outbound_is_missing() { let adapter = SingBoxAdapter::default(); let config = local_singbox_config("missing-server"); let cache = subscription_cache(); let checker = RecordingChecker::ok("should not run"); let error = adapter .generate_config( SingBoxGenerationRequest::new(&config, &cache, None), &checker, ) .expect_err("missing outbound should block config"); assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound); assert!(error.message.contains("missing-server")); assert!(checker.calls.borrow().is_empty()); } #[test] fn propagates_failed_singbox_check_as_structured_error() { let adapter = SingBoxAdapter::default(); let config = local_singbox_config("nl-1"); let cache = subscription_cache(); let checker = RecordingChecker::err("invalid config"); let error = adapter .generate_config( SingBoxGenerationRequest::new(&config, &cache, Some(Path::new("sing-box.exe"))), &checker, ) .expect_err("failed sing-box check should block generated config"); assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed); assert!(error.message.contains("invalid config")); } #[test] fn external_proxifyre_apply_does_not_require_singbox_component() { let adapter = ProxiFyreAdapter::default(); let profiles = vec![discord_profile("home-gateway")]; let targets = vec![external_socks5_target()]; let components = vec![missing_singbox_component()]; let generated = adapter .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) .expect("external SOCKS5 target should not require local sing-box"); let config: ProxiFyreConfig = serde_json::from_str(&generated.contents).expect("generated proxifyre json"); assert_eq!(config.proxies.len(), 1); assert_eq!( config.proxies[0].socks5_proxy_endpoint, "192.168.50.111:8080" ); } struct RecordingChecker { calls: RefCell>, result: Result, } impl RecordingChecker { fn ok(message: &str) -> Self { Self { calls: RefCell::new(Vec::new()), result: Ok(SingBoxCheckResult { checked: true, success: true, message: message.to_string(), }), } } fn err(message: &str) -> Self { Self { calls: RefCell::new(Vec::new()), result: Err(SingBoxConfigError::new( SingBoxConfigErrorKind::CheckFailed, message, )), } } } impl SingBoxConfigChecker for RecordingChecker { fn check_config( &self, binary_path: &Path, config_json: &str, ) -> Result { self.calls .borrow_mut() .push((binary_path.to_path_buf(), config_json.to_string())); self.result.clone() } } fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig { LocalSingBoxConfig { subscription_url: Some("https://sub.example.test/list".to_string()), selected_server_tag: Some(selected_server_tag.to_string()), listen_host: "127.0.0.1".to_string(), listen_port: 1080, service_name: "VpnProxySingBox".to_string(), install_root: r"C:\Program Files\VpnProxy\sing-box".to_string(), updated_at: Some("2026-07-07T10:00:00Z".to_string()), } } fn subscription_cache() -> SubscriptionCache { SubscriptionCache { config: serde_json::json!({ "outbounds": [ { "type": "vless", "tag": "nl-1", "server": "nl.example.test", "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111" }, { "type": "direct", "tag": "direct" } ] }), servers: vec![SubscriptionServer { tag: "nl-1".to_string(), server_type: "vless".to_string(), server: "nl.example.test".to_string(), server_port: 443, }], user_info: serde_json::Map::new(), fetched_at: "2026-07-07T10:00:00Z".to_string(), } } fn discord_profile(target_id: &str) -> Profile { Profile { id: "discord".to_string(), name: "Discord".to_string(), enabled: true, target_id: target_id.to_string(), protocols: vec![Protocol::Tcp, Protocol::Udp], items: vec![ProfileItem { item_type: ProfileItemType::Process, value: "Discord".to_string(), recursive: false, }], } } fn external_socks5_target() -> Target { Target { id: "home-gateway".to_string(), name: "Home Gateway".to_string(), kind: TargetKind::External, protocol: ProxyProtocol::Socks5, host: "192.168.50.111".to_string(), port: 8080, requires_component: None, } } fn missing_singbox_component() -> ComponentStatus { ComponentStatus { id: ComponentId::Singbox, name: "Local sing-box".to_string(), state: ComponentState::Missing, installed: false, running: false, version: None, path: None, problems: vec!["Local sing-box is not installed".to_string()], actions: vec!["Install Local sing-box".to_string()], } }