diff --git a/Cargo.lock b/Cargo.lock index 04a26590..6106b12b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2611,7 +2611,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", @@ -2650,7 +2650,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", @@ -2675,7 +2675,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-stream", @@ -2715,7 +2715,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", @@ -2734,7 +2734,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", diff --git a/crates/mcpmux-core/src/event_bus.rs b/crates/mcpmux-core/src/event_bus.rs index e6d746b6..5b09d3c2 100644 --- a/crates/mcpmux-core/src/event_bus.rs +++ b/crates/mcpmux-core/src/event_bus.rs @@ -287,4 +287,35 @@ mod tests { let count = sender.emit(DomainEvent::GatewayStopped); assert_eq!(count, 0); } + + #[test] + fn try_recv_none_when_empty() { + let bus = EventBus::new(); + let mut receiver = bus.subscribe(); + + // No events emitted yet + assert!(receiver.try_recv().is_none()); + } + + #[test] + fn try_recv_returns_event() { + let bus = EventBus::new(); + let sender = bus.sender(); + let mut receiver = bus.subscribe(); + + sender.emit(DomainEvent::GatewayStopped); + + let event = receiver.try_recv(); + assert!(event.is_some()); + assert_eq!(event.unwrap().type_name(), "gateway_stopped"); + } + + #[test] + fn emit_or_warn_no_receivers_does_not_panic() { + let bus = EventBus::new(); + let sender = bus.sender(); + + // Should not panic even with no receivers + sender.emit_or_warn(DomainEvent::GatewayStopped); + } } diff --git a/crates/mcpmux-gateway/src/pool/routing.rs b/crates/mcpmux-gateway/src/pool/routing.rs index 180e09e6..54543cfc 100644 --- a/crates/mcpmux-gateway/src/pool/routing.rs +++ b/crates/mcpmux-gateway/src/pool/routing.rs @@ -738,3 +738,67 @@ impl RoutingService { false } } + +#[cfg(test)] +mod tests { + use super::*; + + // is_auth_error tests + + #[test] + fn is_auth_error_401() { + assert!(RoutingService::is_auth_error("got 401 from server")); + } + + #[test] + fn is_auth_error_unauthorized() { + assert!(RoutingService::is_auth_error("unauthorized access denied")); + } + + #[test] + fn is_auth_error_invalid_token() { + assert!(RoutingService::is_auth_error( + "invalid_token: token revoked" + )); + } + + #[test] + fn is_auth_error_token_expired() { + assert!(RoutingService::is_auth_error("token expired at 12:00")); + } + + #[test] + fn is_auth_error_access_token() { + assert!(RoutingService::is_auth_error("access token is invalid")); + } + + #[test] + fn is_auth_error_unrelated() { + assert!(!RoutingService::is_auth_error("connection refused")); + } + + #[test] + fn is_auth_error_empty() { + assert!(!RoutingService::is_auth_error("")); + } + + // content_has_auth_error tests + + #[test] + fn content_auth_error_in_text() { + let content = vec![serde_json::json!({"type": "text", "text": "Error: 401 Unauthorized"})]; + assert!(RoutingService::content_has_auth_error(&content)); + } + + #[test] + fn content_no_text_field() { + let content = vec![serde_json::json!({"type": "image", "data": "base64..."})]; + assert!(!RoutingService::content_has_auth_error(&content)); + } + + #[test] + fn content_empty_slice() { + let content: Vec = vec![]; + assert!(!RoutingService::content_has_auth_error(&content)); + } +} diff --git a/crates/mcpmux-gateway/src/server/rate_limit.rs b/crates/mcpmux-gateway/src/server/rate_limit.rs index fdbe92c1..2a1de9fc 100644 --- a/crates/mcpmux-gateway/src/server/rate_limit.rs +++ b/crates/mcpmux-gateway/src/server/rate_limit.rs @@ -128,3 +128,136 @@ pub fn default_oauth_rate_limiter() -> RateLimiter { ), ]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_within_limit() { + let limiter = RateLimiter::new(vec![( + "/test".to_string(), + RateLimitConfig { + max_requests: 5, + window: Duration::from_secs(60), + }, + )]); + + for _ in 0..5 { + assert!(limiter.check("/test"), "Should allow requests within limit"); + } + } + + #[test] + fn blocks_at_limit() { + let limiter = RateLimiter::new(vec![( + "/test".to_string(), + RateLimitConfig { + max_requests: 3, + window: Duration::from_secs(60), + }, + )]); + + assert!(limiter.check("/test")); // 1 + assert!(limiter.check("/test")); // 2 + assert!(limiter.check("/test")); // 3 + assert!(!limiter.check("/test"), "Should block at limit"); + } + + #[test] + fn no_matching_rule_allows() { + let limiter = RateLimiter::new(vec![( + "/oauth".to_string(), + RateLimitConfig { + max_requests: 1, + window: Duration::from_secs(60), + }, + )]); + + assert!(limiter.check("/health"), "Unmatched path should be allowed"); + } + + #[test] + fn prefix_matching() { + let limiter = RateLimiter::new(vec![( + "/oauth/token".to_string(), + RateLimitConfig { + max_requests: 1, + window: Duration::from_secs(60), + }, + )]); + + assert!(limiter.check("/oauth/token/extra")); // matches prefix, count=1 + assert!( + !limiter.check("/oauth/token"), + "Second request to same prefix should be blocked" + ); + } + + #[test] + fn independent_buckets() { + let limiter = RateLimiter::new(vec![ + ( + "/oauth/authorize".to_string(), + RateLimitConfig { + max_requests: 1, + window: Duration::from_secs(60), + }, + ), + ( + "/oauth/token".to_string(), + RateLimitConfig { + max_requests: 1, + window: Duration::from_secs(60), + }, + ), + ]); + + assert!(limiter.check("/oauth/authorize")); + assert!( + !limiter.check("/oauth/authorize"), + "authorize should be blocked" + ); + // token should still be allowed (independent counter) + assert!(limiter.check("/oauth/token")); + } + + #[test] + fn default_has_expected_rules() { + let limiter = default_oauth_rate_limiter(); + // Verify it has rules for 5 OAuth paths by checking they are rate-limited + let paths = [ + "/oauth/authorize", + "/authorize", + "/oauth/token", + "/oauth/register", + "/oauth/clients", + ]; + for path in &paths { + assert!( + limiter.check(path), + "First request to {} should be allowed", + path + ); + } + } + + #[test] + fn window_reset() { + let limiter = RateLimiter::new(vec![( + "/test".to_string(), + RateLimitConfig { + max_requests: 1, + window: Duration::from_millis(1), // 1ms window + }, + )]); + + assert!(limiter.check("/test")); // count=1 + assert!(!limiter.check("/test")); // blocked + + // Sleep past the window + std::thread::sleep(Duration::from_millis(10)); + + assert!(limiter.check("/test"), "Should allow after window reset"); + } +} diff --git a/crates/mcpmux-gateway/src/server/state.rs b/crates/mcpmux-gateway/src/server/state.rs index 582f21af..9c0296fa 100644 --- a/crates/mcpmux-gateway/src/server/state.rs +++ b/crates/mcpmux-gateway/src/server/state.rs @@ -256,3 +256,148 @@ impl Default for GatewayState { Self::new(domain_event_tx) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::oauth::OAuthToken; + + fn make_state() -> GatewayState { + GatewayState::default() + } + + fn test_pending_auth(client_id: &str) -> PendingAuthorization { + PendingAuthorization { + client_id: client_id.to_string(), + client_name: Some("Test Client".to_string()), + redirect_uri: "http://localhost:8080/callback".to_string(), + scope: Some("read".to_string()), + state: Some("state123".to_string()), + code_challenge: Some("challenge".to_string()), + code_challenge_method: Some("S256".to_string()), + expires_at: chrono::Utc::now().timestamp() + 300, + consent_token: None, + } + } + + #[test] + fn store_consume_pending_auth_one_time() { + let mut state = make_state(); + let auth = test_pending_auth("client-1"); + + state.store_pending_authorization("code-abc", auth); + + let consumed = state.consume_pending_authorization("code-abc"); + assert!(consumed.is_some()); + assert_eq!(consumed.unwrap().client_id, "client-1"); + + // Second consume returns None (one-time use) + let second = state.consume_pending_authorization("code-abc"); + assert!(second.is_none()); + } + + #[test] + fn consume_nonexistent_returns_none() { + let mut state = make_state(); + let result = state.consume_pending_authorization("unknown-code"); + assert!(result.is_none()); + } + + #[test] + fn register_validate_access_key() { + let mut state = make_state(); + let client_id = Uuid::new_v4(); + + state.register_access_key("mcp_test_key".to_string(), client_id); + + let validated = state.validate_access_key("mcp_test_key"); + assert_eq!(validated, Some(client_id)); + } + + #[test] + fn validate_unknown_key_returns_none() { + let state = make_state(); + assert!(state.validate_access_key("unknown_key").is_none()); + } + + #[test] + fn register_same_key_overwrites() { + let mut state = make_state(); + let client1 = Uuid::new_v4(); + let client2 = Uuid::new_v4(); + + state.register_access_key("shared_key".to_string(), client1); + state.register_access_key("shared_key".to_string(), client2); + + assert_eq!(state.validate_access_key("shared_key"), Some(client2)); + } + + #[test] + fn create_get_remove_session() { + let mut state = make_state(); + let client_id = Uuid::new_v4(); + let space_id = Uuid::new_v4(); + + let session = state.create_session(client_id, "key".to_string(), space_id); + let session_id = session.id; + + // Get session + let found = state.get_session(&session_id); + assert!(found.is_some()); + assert_eq!(found.unwrap().client_id, client_id); + + // Remove session + let removed = state.remove_session(&session_id); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().space_id, space_id); + + // Gone after removal + assert!(state.get_session(&session_id).is_none()); + } + + #[test] + fn remove_nonexistent_session_none() { + let mut state = make_state(); + assert!(state.remove_session(&Uuid::new_v4()).is_none()); + } + + #[test] + fn jwt_secret_none_before_set() { + let state = make_state(); + assert!(state.get_jwt_secret().is_none()); + assert!(!state.has_jwt_secret()); + } + + #[test] + fn set_get_jwt_secret() { + let mut state = make_state(); + let secret = Zeroizing::new([42u8; JWT_SECRET_SIZE]); + state.set_jwt_secret(secret); + + assert!(state.has_jwt_secret()); + let retrieved = state.get_jwt_secret().unwrap(); + assert_eq!(retrieved[0], 42); + } + + #[test] + fn store_get_oauth_token() { + let mut state = make_state(); + let token = OAuthToken { + access_token: "access-123".to_string(), + token_type: "Bearer".to_string(), + refresh_token: None, + expires_at: None, + scope: None, + id_token: None, + }; + + state.store_oauth_token("server-1".to_string(), token); + + let found = state.get_oauth_token("server-1"); + assert!(found.is_some()); + assert_eq!(found.unwrap().access_token, "access-123"); + + // Non-existent server + assert!(state.get_oauth_token("server-2").is_none()); + } +} diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml index 74f1a9b8..4a7c3397 100644 --- a/tests/rust/Cargo.toml +++ b/tests/rust/Cargo.toml @@ -88,3 +88,7 @@ path = "tests/integration/mod.rs" [[test]] name = "streamable_http" path = "tests/streamable_http/mod.rs" + +[[test]] +name = "application" +path = "tests/application/mod.rs" diff --git a/tests/rust/tests/application/client_app_service.rs b/tests/rust/tests/application/client_app_service.rs new file mode 100644 index 00000000..8b25e6f5 --- /dev/null +++ b/tests/rust/tests/application/client_app_service.rs @@ -0,0 +1,192 @@ +//! Tests for ClientAppService +//! +//! Validates client creation, OAuth registration, updates, deletion, +//! and event emission. + +use std::sync::Arc; +use uuid::Uuid; + +use mcpmux_core::application::ClientAppService; +use mcpmux_core::domain::DomainEvent; +use mcpmux_core::event_bus::EventBus; +use mcpmux_core::repository::InboundMcpClientRepository; +use tests::mocks::*; + +fn make_service( + client_repo: Arc, +) -> ( + ClientAppService, + tokio::sync::broadcast::Receiver, +) { + let bus = EventBus::new(); + let rx = bus.raw_sender().subscribe(); + let sender = bus.sender(); + let svc = ClientAppService::new(client_repo, sender); + (svc, rx) +} + +#[tokio::test] +async fn create_generates_key_persists_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.clients.clone()); + + let client = svc.create("Test Client", "api_key").await.unwrap(); + + assert!( + client.access_key.is_some(), + "Created client should have an access key" + ); + assert!(!client.access_key.as_ref().unwrap().is_empty()); + + // Verify persisted + let found = mocks.clients.get(&client.id).await.unwrap(); + assert!(found.is_some()); + + // Verify event + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ClientRegistered { + client_id, + client_name, + registration_type, + } => { + assert_eq!(client_id, client.id.to_string()); + assert_eq!(client_name, "Test Client"); + assert_eq!(registration_type, Some("api_key".to_string())); + } + other => panic!("Expected ClientRegistered, got {:?}", other), + } +} + +#[tokio::test] +async fn register_oauth_uses_provided_id() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.clients.clone()); + + let expected_id = Uuid::new_v4(); + let client = svc + .register_oauth_client(&expected_id.to_string(), "OAuth Client", "oauth") + .await + .unwrap(); + + assert_eq!( + client.id, expected_id, + "OAuth client should use provided UUID" + ); +} + +#[tokio::test] +async fn register_oauth_emits_with_oauth_type() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.clients.clone()); + + let id = Uuid::new_v4(); + svc.register_oauth_client(&id.to_string(), "OAuth Client", "oauth") + .await + .unwrap(); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ClientRegistered { + registration_type, .. + } => { + assert_eq!(registration_type, Some("oauth".to_string())); + } + other => panic!("Expected ClientRegistered, got {:?}", other), + } +} + +#[tokio::test] +async fn register_oauth_invalid_uuid_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.clients.clone()); + + let result = svc + .register_oauth_client("not-a-uuid", "Bad Client", "oauth") + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid client ID")); +} + +#[tokio::test] +async fn update_changes_fields_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.clients.clone()); + + let client = svc.create("Original", "api_key").await.unwrap(); + let _ = rx.try_recv(); // drain create event + + let updated = svc + .update( + client.id, + Some("Renamed".to_string()), + Some("custom".to_string()), + ) + .await + .unwrap(); + + assert_eq!(updated.name, "Renamed"); + assert_eq!(updated.client_type, "custom"); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ClientUpdated { client_id } => { + assert_eq!(client_id, client.id.to_string()); + } + other => panic!("Expected ClientUpdated, got {:?}", other), + } +} + +#[tokio::test] +async fn update_not_found_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.clients.clone()); + + let result = svc + .update(Uuid::new_v4(), Some("Name".to_string()), None) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); +} + +#[tokio::test] +async fn delete_removes_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.clients.clone()); + + let client = svc.create("To Delete", "api_key").await.unwrap(); + let _ = rx.try_recv(); // drain create + + svc.delete(client.id).await.unwrap(); + + let found = mocks.clients.get(&client.id).await.unwrap(); + assert!(found.is_none(), "Client should be deleted"); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ClientDeleted { client_id } => { + assert_eq!(client_id, client.id.to_string()); + } + other => panic!("Expected ClientDeleted, got {:?}", other), + } +} + +#[tokio::test] +async fn record_token_issued_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.clients.clone()); + + let client_id = Uuid::new_v4().to_string(); + svc.record_token_issued(&client_id); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ClientTokenIssued { client_id: cid, .. } => { + assert_eq!(cid, client_id); + } + other => panic!("Expected ClientTokenIssued, got {:?}", other), + } +} diff --git a/tests/rust/tests/application/mod.rs b/tests/rust/tests/application/mod.rs new file mode 100644 index 00000000..403dda17 --- /dev/null +++ b/tests/rust/tests/application/mod.rs @@ -0,0 +1,9 @@ +//! Application service tests +//! +//! Tests for the core application services that orchestrate +//! domain operations with event emission. + +mod client_app_service; +mod permission_app_service; +mod server_app_service; +mod space_app_service; diff --git a/tests/rust/tests/application/permission_app_service.rs b/tests/rust/tests/application/permission_app_service.rs new file mode 100644 index 00000000..36fdcd1b --- /dev/null +++ b/tests/rust/tests/application/permission_app_service.rs @@ -0,0 +1,350 @@ +//! Tests for PermissionAppService +//! +//! Validates feature set CRUD, member management, and grant operations +//! with event emission. + +use std::sync::Arc; +use uuid::Uuid; + +use mcpmux_core::application::PermissionAppService; +use mcpmux_core::domain::{Client, DomainEvent, MemberMode}; +use mcpmux_core::event_bus::EventBus; +use mcpmux_core::repository::{FeatureSetRepository, InboundMcpClientRepository}; +use tests::mocks::*; + +fn make_service( + fs_repo: Arc, + client_repo: Option>, +) -> ( + PermissionAppService, + tokio::sync::broadcast::Receiver, +) { + let bus = EventBus::new(); + let rx = bus.raw_sender().subscribe(); + let sender = bus.sender(); + let svc = PermissionAppService::new( + fs_repo, + client_repo.map(|r| r as Arc), + sender, + ); + (svc, rx) +} + +#[tokio::test] +async fn create_fs_persists_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.feature_sets.clone(), None); + + let space_id = Uuid::new_v4(); + let fs = svc + .create_feature_set(&space_id.to_string(), "My FS", None, None) + .await + .unwrap(); + + // Verify persisted + let found = mocks.feature_sets.get(&fs.id).await.unwrap(); + assert!(found.is_some()); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::FeatureSetCreated { + space_id: sid, + feature_set_id, + name, + .. + } => { + assert_eq!(sid, space_id); + assert_eq!(feature_set_id, fs.id); + assert_eq!(name, "My FS"); + } + other => panic!("Expected FeatureSetCreated, got {:?}", other), + } +} + +#[tokio::test] +async fn create_fs_with_desc_icon() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.feature_sets.clone(), None); + + let space_id = Uuid::new_v4(); + let fs = svc + .create_feature_set( + &space_id.to_string(), + "Fancy FS", + Some("A description".to_string()), + Some("๐Ÿ”ง".to_string()), + ) + .await + .unwrap(); + + assert_eq!(fs.description, Some("A description".to_string())); + assert_eq!(fs.icon, Some("๐Ÿ”ง".to_string())); +} + +#[tokio::test] +async fn update_fs_changes_name_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.feature_sets.clone(), None); + + let space_id = Uuid::new_v4(); + let fs = svc + .create_feature_set(&space_id.to_string(), "Original", None, None) + .await + .unwrap(); + let _ = rx.try_recv(); // drain create + + let updated = svc + .update_feature_set(&fs.id, Some("Renamed".to_string()), None, None) + .await + .unwrap(); + + assert_eq!(updated.name, "Renamed"); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::FeatureSetUpdated { + feature_set_id, + name, + .. + } => { + assert_eq!(feature_set_id, fs.id); + assert_eq!(name, "Renamed"); + } + other => panic!("Expected FeatureSetUpdated, got {:?}", other), + } +} + +#[tokio::test] +async fn update_fs_not_found_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.feature_sets.clone(), None); + + let result = svc + .update_feature_set("nonexistent", Some("Name".to_string()), None, None) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); +} + +#[tokio::test] +async fn delete_custom_fs_succeeds() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.feature_sets.clone(), None); + + let space_id = Uuid::new_v4(); + let fs = svc + .create_feature_set(&space_id.to_string(), "To Delete", None, None) + .await + .unwrap(); + let _ = rx.try_recv(); // drain create + + svc.delete_feature_set(&fs.id).await.unwrap(); + + let found = mocks.feature_sets.get(&fs.id).await.unwrap(); + assert!(found.is_none()); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::FeatureSetDeleted { + space_id: sid, + feature_set_id, + } => { + assert_eq!(sid, space_id); + assert_eq!(feature_set_id, fs.id); + } + other => panic!("Expected FeatureSetDeleted, got {:?}", other), + } +} + +#[tokio::test] +async fn delete_builtin_fs_returns_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.feature_sets.clone(), None); + + let space_id = Uuid::new_v4(); + let all_fs = mcpmux_core::domain::FeatureSet::new_all(&space_id.to_string()); + mocks.feature_sets.create(&all_fs).await.unwrap(); + + let result = svc.delete_feature_set(&all_fs.id).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot delete builtin")); +} + +#[tokio::test] +async fn add_member_persists_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.feature_sets.clone(), None); + + let space_id = Uuid::new_v4(); + let fs = svc + .create_feature_set(&space_id.to_string(), "Test FS", None, None) + .await + .unwrap(); + let _ = rx.try_recv(); // drain create + + svc.add_feature_member(&fs.id, "feature-123", MemberMode::Include) + .await + .unwrap(); + + let members = mocks + .feature_sets + .get_feature_members(&fs.id) + .await + .unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].member_id, "feature-123"); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::FeatureSetMembersChanged { + feature_set_id, + added_count, + removed_count, + .. + } => { + assert_eq!(feature_set_id, fs.id); + assert_eq!(added_count, 1); + assert_eq!(removed_count, 0); + } + other => panic!("Expected FeatureSetMembersChanged, got {:?}", other), + } +} + +#[tokio::test] +async fn remove_member_persists_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.feature_sets.clone(), None); + + let space_id = Uuid::new_v4(); + let fs = svc + .create_feature_set(&space_id.to_string(), "Test FS", None, None) + .await + .unwrap(); + svc.add_feature_member(&fs.id, "feature-123", MemberMode::Include) + .await + .unwrap(); + let _ = rx.try_recv(); // drain create + let _ = rx.try_recv(); // drain add + + svc.remove_feature_member(&fs.id, "feature-123") + .await + .unwrap(); + + let members = mocks + .feature_sets + .get_feature_members(&fs.id) + .await + .unwrap(); + assert!(members.is_empty()); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::FeatureSetMembersChanged { + added_count, + removed_count, + .. + } => { + assert_eq!(added_count, 0); + assert_eq!(removed_count, 1); + } + other => panic!("Expected FeatureSetMembersChanged, got {:?}", other), + } +} + +#[tokio::test] +async fn grant_no_client_repo_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.feature_sets.clone(), None); + + let result = svc + .grant_feature_set(Uuid::new_v4(), &Uuid::new_v4().to_string(), "fs-123") + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Client repository not configured")); +} + +#[tokio::test] +async fn grant_client_not_found_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.feature_sets.clone(), Some(mocks.clients.clone())); + + let space_id = Uuid::new_v4(); + let fs = svc + .create_feature_set(&space_id.to_string(), "FS", None, None) + .await + .unwrap(); + + let result = svc + .grant_feature_set(Uuid::new_v4(), &space_id.to_string(), &fs.id) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Client not found")); +} + +#[tokio::test] +async fn grant_fs_not_found_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.feature_sets.clone(), Some(mocks.clients.clone())); + + let client = Client::new("Test", "api_key"); + mocks.clients.create(&client).await.unwrap(); + + let result = svc + .grant_feature_set(client.id, &Uuid::new_v4().to_string(), "nonexistent") + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Feature set not found")); +} + +#[tokio::test] +async fn grant_succeeds_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.feature_sets.clone(), Some(mocks.clients.clone())); + + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + + let client = Client::new("Test", "api_key"); + mocks.clients.create(&client).await.unwrap(); + + let fs = svc + .create_feature_set(&space_id_str, "My FS", None, None) + .await + .unwrap(); + let _ = rx.try_recv(); // drain FS create + + svc.grant_feature_set(client.id, &space_id_str, &fs.id) + .await + .unwrap(); + + // Verify grant persisted + let grants = mocks + .clients + .get_grants_for_space(&client.id, &space_id_str) + .await + .unwrap(); + assert!(grants.contains(&fs.id)); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::GrantIssued { + client_id, + space_id: sid, + feature_set_id, + } => { + assert_eq!(client_id, client.id.to_string()); + assert_eq!(sid, space_id); + assert_eq!(feature_set_id, fs.id); + } + other => panic!("Expected GrantIssued, got {:?}", other), + } +} diff --git a/tests/rust/tests/application/server_app_service.rs b/tests/rust/tests/application/server_app_service.rs new file mode 100644 index 00000000..27b14dbd --- /dev/null +++ b/tests/rust/tests/application/server_app_service.rs @@ -0,0 +1,561 @@ +//! Tests for ServerAppService +//! +//! Validates server installation, uninstallation, enable/disable, +//! config updates, and event emission. + +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +use mcpmux_core::application::ServerAppService; +use mcpmux_core::domain::{DomainEvent, InstallationSource, InstalledServer, ServerDefinition}; +use mcpmux_core::event_bus::EventBus; +use mcpmux_core::repository::{FeatureSetRepository, InstalledServerRepository}; +use tests::mocks::*; + +/// Create a test ServerDefinition via JSON deserialization (fills defaults for all fields) +fn test_definition(id: &str, name: &str) -> ServerDefinition { + serde_json::from_value(serde_json::json!({ + "id": id, + "name": name, + "description": "Test server", + "transport": { + "type": "stdio", + "command": "echo" + } + })) + .unwrap() +} + +/// Create a ServerAppService wired up with mock repos and an event bus +fn make_service( + server_repo: Arc, + fs_repo: Arc, + feature_repo: Arc, + cred_repo: Arc, +) -> ( + ServerAppService, + tokio::sync::broadcast::Receiver, +) { + let bus = EventBus::new(); + let rx = bus.raw_sender().subscribe(); + let sender = bus.sender(); + let svc = ServerAppService::new( + server_repo, + Some(fs_repo), + Some(feature_repo), + Some(cred_repo), + sender, + ); + (svc, rx) +} + +#[tokio::test] +async fn install_persists_server_disabled() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + let server = svc + .install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + + assert!( + !server.enabled, + "Installed server should be disabled by default" + ); + assert_eq!(server.server_id, "test-server"); + + // Verify persisted in repo + let found = mocks + .installed_servers + .get_by_server_id(&space_id.to_string(), "test-server") + .await + .unwrap(); + assert!(found.is_some()); +} + +#[tokio::test] +async fn install_emits_server_installed() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ServerInstalled { + space_id: sid, + server_id, + server_name, + } => { + assert_eq!(sid, space_id); + assert_eq!(server_id, "test-server"); + assert_eq!(server_name, "Test Server"); + } + other => panic!("Expected ServerInstalled, got {:?}", other), + } +} + +#[tokio::test] +async fn install_creates_server_all_fs() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + + let server_all = mocks + .feature_sets + .get_server_all(&space_id.to_string(), "test-server") + .await + .unwrap(); + assert!( + server_all.is_some(), + "ServerAll feature set should be created on install" + ); +} + +#[tokio::test] +async fn install_duplicate_returns_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + + let result = svc + .install(space_id, "test-server", &def, HashMap::new()) + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("already installed")); +} + +#[tokio::test] +async fn uninstall_removes_and_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + let _ = rx.try_recv(); // drain install event + + svc.uninstall(space_id, "test-server").await.unwrap(); + + // Verify removed from repo + let found = mocks + .installed_servers + .get_by_server_id(&space_id.to_string(), "test-server") + .await + .unwrap(); + assert!(found.is_none(), "Server should be removed after uninstall"); + + // Verify event + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ServerUninstalled { + space_id: sid, + server_id, + } => { + assert_eq!(sid, space_id); + assert_eq!(server_id, "test-server"); + } + other => panic!("Expected ServerUninstalled, got {:?}", other), + } +} + +#[tokio::test] +async fn uninstall_deletes_fs_features_creds() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + + // Pre-populate features and credentials + use mcpmux_core::repository::{CredentialRepository, ServerFeatureRepository}; + let feature = mcpmux_core::ServerFeature::tool(&space_id_str, "test-server", "my_tool"); + mocks.features.upsert(&feature).await.unwrap(); + let cred = mcpmux_core::domain::Credential::api_key(space_id, "test-server", "secret-key"); + mocks.credentials.save(&cred).await.unwrap(); + + svc.uninstall(space_id, "test-server").await.unwrap(); + + // Verify ServerAll FS deleted + let server_all = mocks + .feature_sets + .get_server_all(&space_id_str, "test-server") + .await + .unwrap(); + assert!(server_all.is_none(), "ServerAll FS should be deleted"); + + // Verify features deleted + let features = mocks + .features + .list_for_server(&space_id_str, "test-server") + .await + .unwrap(); + assert!(features.is_empty(), "Features should be deleted"); + + // Verify credentials deleted + let creds = mocks + .credentials + .get_all(&space_id, "test-server") + .await + .unwrap(); + assert!(creds.is_empty(), "Credentials should be deleted"); +} + +#[tokio::test] +async fn uninstall_not_found_returns_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + + let result = svc.uninstall(space_id, "nonexistent").await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not installed")); +} + +#[tokio::test] +async fn uninstall_userconfig_modifies_json() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + + // Create a temp config file + let temp_dir = tempfile::TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + let config_content = serde_json::json!({ + "mcpServers": { + "test-server": { + "command": "echo", + "args": ["hello"] + }, + "other-server": { + "command": "echo" + } + } + }); + std::fs::write( + &config_path, + serde_json::to_string_pretty(&config_content).unwrap(), + ) + .unwrap(); + + // Manually insert a server with UserConfig source + let server = InstalledServer::new(&space_id.to_string(), "test-server") + .with_source(InstallationSource::UserConfig { + file_path: config_path.clone(), + }) + .with_enabled(false); + mocks.installed_servers.install(&server).await.unwrap(); + + svc.uninstall(space_id, "test-server").await.unwrap(); + + // Verify the JSON file was modified + let content = std::fs::read_to_string(&config_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + let servers = parsed["mcpServers"].as_object().unwrap(); + assert!( + !servers.contains_key("test-server"), + "test-server should be removed from config" + ); + assert!( + servers.contains_key("other-server"), + "other-server should remain" + ); +} + +#[tokio::test] +async fn enable_sets_true_emits_event() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + let _ = rx.try_recv(); // drain install event + + svc.enable(space_id, "test-server").await.unwrap(); + + // Verify enabled in repo + let server = mocks + .installed_servers + .get_by_server_id(&space_id.to_string(), "test-server") + .await + .unwrap() + .unwrap(); + assert!(server.enabled); + + // Verify event + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ServerEnabled { + space_id: sid, + server_id, + } => { + assert_eq!(sid, space_id); + assert_eq!(server_id, "test-server"); + } + other => panic!("Expected ServerEnabled, got {:?}", other), + } +} + +#[tokio::test] +async fn disable_sets_false_emits_event() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + svc.enable(space_id, "test-server").await.unwrap(); + let _ = rx.try_recv(); // drain install + let _ = rx.try_recv(); // drain enable + + svc.disable(space_id, "test-server").await.unwrap(); + + let server = mocks + .installed_servers + .get_by_server_id(&space_id.to_string(), "test-server") + .await + .unwrap() + .unwrap(); + assert!(!server.enabled); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ServerDisabled { + space_id: sid, + server_id, + } => { + assert_eq!(sid, space_id); + assert_eq!(server_id, "test-server"); + } + other => panic!("Expected ServerDisabled, got {:?}", other), + } +} + +#[tokio::test] +async fn enable_not_installed_returns_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + + let result = svc.enable(space_id, "nonexistent").await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not installed")); +} + +#[tokio::test] +async fn update_config_updates_fields_emits() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + let _ = rx.try_recv(); // drain install + + let mut inputs = HashMap::new(); + inputs.insert("key".to_string(), "value".to_string()); + let mut env = HashMap::new(); + env.insert("MY_VAR".to_string(), "my_val".to_string()); + let args = vec!["--flag".to_string()]; + let mut headers = HashMap::new(); + headers.insert("X-Custom".to_string(), "val".to_string()); + + let updated = svc + .update_config( + space_id, + "test-server", + inputs.clone(), + Some(env.clone()), + Some(args.clone()), + Some(headers.clone()), + ) + .await + .unwrap(); + + assert_eq!(updated.input_values, inputs); + assert_eq!(updated.env_overrides, env); + assert_eq!(updated.args_append, args); + assert_eq!(updated.extra_headers, headers); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::ServerConfigUpdated { + space_id: sid, + server_id, + } => { + assert_eq!(sid, space_id); + assert_eq!(server_id, "test-server"); + } + other => panic!("Expected ServerConfigUpdated, got {:?}", other), + } +} + +#[tokio::test] +async fn update_config_partial_fields() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + + let mut env = HashMap::new(); + env.insert("MY_VAR".to_string(), "my_val".to_string()); + + // Only set env_overrides, args/headers left as None + let updated = svc + .update_config( + space_id, + "test-server", + HashMap::new(), + Some(env.clone()), + None, + None, + ) + .await + .unwrap(); + + assert_eq!(updated.env_overrides, env); + assert!(updated.args_append.is_empty(), "args should remain empty"); + assert!( + updated.extra_headers.is_empty(), + "headers should remain empty" + ); +} + +#[tokio::test] +async fn set_oauth_connected_no_event() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service( + mocks.installed_servers.clone(), + mocks.feature_sets.clone(), + mocks.features.clone(), + mocks.credentials.clone(), + ); + let space_id = Uuid::new_v4(); + let def = test_definition("test-server", "Test Server"); + + svc.install(space_id, "test-server", &def, HashMap::new()) + .await + .unwrap(); + let _ = rx.try_recv(); // drain install + + svc.set_oauth_connected(space_id, "test-server", true) + .await + .unwrap(); + + let server = mocks + .installed_servers + .get_by_server_id(&space_id.to_string(), "test-server") + .await + .unwrap() + .unwrap(); + assert!(server.oauth_connected); + + // No event should be emitted + assert!( + rx.try_recv().is_err(), + "No event expected for set_oauth_connected" + ); +} diff --git a/tests/rust/tests/application/space_app_service.rs b/tests/rust/tests/application/space_app_service.rs new file mode 100644 index 00000000..a780af36 --- /dev/null +++ b/tests/rust/tests/application/space_app_service.rs @@ -0,0 +1,232 @@ +//! Tests for SpaceAppService +//! +//! Validates space creation, deletion, activation, and event emission. + +use std::sync::Arc; +use uuid::Uuid; + +use mcpmux_core::application::SpaceAppService; +use mcpmux_core::domain::DomainEvent; +use mcpmux_core::event_bus::EventBus; +use mcpmux_core::repository::{FeatureSetRepository, SpaceRepository}; +use tests::mocks::*; + +fn make_service( + space_repo: Arc, + fs_repo: Arc, +) -> ( + SpaceAppService, + tokio::sync::broadcast::Receiver, +) { + let bus = EventBus::new(); + let rx = bus.raw_sender().subscribe(); + let sender = bus.sender(); + let svc = SpaceAppService::new(space_repo, Some(fs_repo), sender); + (svc, rx) +} + +#[tokio::test] +async fn create_first_auto_default() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let space = svc.create("My Space", None).await.unwrap(); + assert!(space.is_default, "First space should auto-become default"); +} + +#[tokio::test] +async fn create_second_not_default() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let first = svc.create("First", None).await.unwrap(); + assert!(first.is_default); + + let second = svc.create("Second", None).await.unwrap(); + assert!(!second.is_default, "Second space should not be default"); +} + +#[tokio::test] +async fn create_emits_space_created() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let space = svc + .create("Test Space", Some("๐Ÿงช".to_string())) + .await + .unwrap(); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::SpaceCreated { + space_id, + name, + icon, + } => { + assert_eq!(space_id, space.id); + assert_eq!(name, "Test Space"); + assert_eq!(icon, Some("๐Ÿงช".to_string())); + } + other => panic!("Expected SpaceCreated, got {:?}", other), + } +} + +#[tokio::test] +async fn create_with_fs_repo_ensures_builtins() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let space = svc.create("Test", None).await.unwrap(); + + let space_id_str = space.id.to_string(); + let all_fs = mocks + .feature_sets + .get_all_for_space(&space_id_str) + .await + .unwrap(); + assert!(all_fs.is_some(), "All feature set should be created"); + + let default_fs = mocks + .feature_sets + .get_default_for_space(&space_id_str) + .await + .unwrap(); + assert!( + default_fs.is_some(), + "Default feature set should be created" + ); +} + +#[tokio::test] +async fn delete_default_returns_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let space = svc.create("Default Space", None).await.unwrap(); + assert!(space.is_default); + + let result = svc.delete(space.id).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot delete the default space")); +} + +#[tokio::test] +async fn delete_non_default_succeeds() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let _first = svc.create("Default", None).await.unwrap(); + let second = svc.create("Second", None).await.unwrap(); + let _ = rx.try_recv(); // drain first create + let _ = rx.try_recv(); // drain second create + + svc.delete(second.id).await.unwrap(); + + let found = mocks.spaces.get(&second.id).await.unwrap(); + assert!(found.is_none(), "Space should be deleted"); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::SpaceDeleted { space_id } => { + assert_eq!(space_id, second.id); + } + other => panic!("Expected SpaceDeleted, got {:?}", other), + } +} + +#[tokio::test] +async fn delete_not_found_returns_err() { + let mocks = MockRepositories::new(); + let (svc, _rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let result = svc.delete(Uuid::new_v4()).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); +} + +#[tokio::test] +async fn set_active_emits_with_from_to() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let first = svc.create("First", None).await.unwrap(); + let second = svc.create("Second", None).await.unwrap(); + // first is auto-default since it was first + mocks.spaces.set_default(&first.id).await.unwrap(); + let _ = rx.try_recv(); // drain events + let _ = rx.try_recv(); + + svc.set_active(second.id).await.unwrap(); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::SpaceActivated { + from_space_id, + to_space_id, + to_space_name, + } => { + assert_eq!(from_space_id, Some(first.id)); + assert_eq!(to_space_id, second.id); + assert_eq!(to_space_name, "Second"); + } + other => panic!("Expected SpaceActivated, got {:?}", other), + } +} + +#[tokio::test] +async fn set_active_no_previous_default() { + // Use a space repo with a manually inserted space but no default set + let space = mcpmux_core::domain::Space::new("Manual"); + let space_repo = Arc::new(MockSpaceRepository::new().with_space(space.clone())); + let fs_repo = Arc::new(MockFeatureSetRepository::new()); + let (svc, mut rx) = make_service(space_repo, fs_repo); + + // No default is set, so get_default() returns None + svc.set_active(space.id).await.unwrap(); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::SpaceActivated { + from_space_id, + to_space_id, + .. + } => { + assert_eq!(from_space_id, None, "No previous default"); + assert_eq!(to_space_id, space.id); + } + other => panic!("Expected SpaceActivated, got {:?}", other), + } +} + +#[tokio::test] +async fn update_partial_fields() { + let mocks = MockRepositories::new(); + let (svc, mut rx) = make_service(mocks.spaces.clone(), mocks.feature_sets.clone()); + + let space = svc + .create("Original", Some("๐ŸŽฏ".to_string())) + .await + .unwrap(); + let _ = rx.try_recv(); // drain create + + let updated = svc + .update(space.id, Some("Renamed".to_string()), None, None) + .await + .unwrap(); + + assert_eq!(updated.name, "Renamed"); + // Icon should be preserved from creation + assert_eq!(updated.icon, Some("๐ŸŽฏ".to_string())); + + let event = rx.try_recv().unwrap(); + match event { + DomainEvent::SpaceUpdated { space_id, name } => { + assert_eq!(space_id, space.id); + assert_eq!(name, "Renamed"); + } + other => panic!("Expected SpaceUpdated, got {:?}", other), + } +} diff --git a/tests/rust/tests/gateway/authorization.rs b/tests/rust/tests/gateway/authorization.rs new file mode 100644 index 00000000..132a15ca --- /dev/null +++ b/tests/rust/tests/gateway/authorization.rs @@ -0,0 +1,239 @@ +//! Authorization service integration tests +//! +//! Tests for AuthorizationService using real SQLite repositories. +//! Note: SqliteSpaceRepository::create auto-creates builtin feature sets +//! (All + Default) for each space, and the DB migration seeds a default space. + +use mcpmux_core::FeatureSetRepository; +use mcpmux_gateway::services::AuthorizationService; +use mcpmux_storage::{ + InboundClient, InboundClientRepository, RegistrationType, SqliteFeatureSetRepository, + SqliteSpaceRepository, +}; +use std::sync::Arc; +use tests::db::TestDatabase; +use tests::fixtures; +use tokio::sync::Mutex; +use uuid::Uuid; + +fn test_client(client_id: &str) -> InboundClient { + InboundClient { + client_id: client_id.to_string(), + registration_type: RegistrationType::Preregistered, + client_name: "Test Client".to_string(), + client_alias: None, + redirect_uris: vec![], + grant_types: vec![], + response_types: vec![], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + connection_mode: "follow_active".to_string(), + locked_space_id: None, + last_seen: None, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + } +} + +struct TestSetup { + svc: AuthorizationService, + client_repo: Arc, + fs_repo: Arc, + space_id: Uuid, +} + +/// Creates a test setup with a real space. +/// SqliteSpaceRepository::create auto-creates Default + All feature sets. +async fn setup_with_space() -> TestSetup { + let test_db = TestDatabase::in_memory(); + let db = Arc::new(Mutex::new(test_db.db)); + let client_repo = Arc::new(InboundClientRepository::new(db.clone())); + let fs_repo = Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let space_repo = SqliteSpaceRepository::new(db); + + let space = fixtures::test_space("Auth Test Space"); + use mcpmux_core::SpaceRepository; + space_repo.create(&space).await.unwrap(); + + let svc = AuthorizationService::new(client_repo.clone(), fs_repo.clone()); + TestSetup { + svc, + client_repo, + fs_repo, + space_id: space.id, + } +} + +#[tokio::test] +async fn explicit_grants_plus_default_fs() { + let t = setup_with_space().await; + let space_id_str = t.space_id.to_string(); + + // Default FS is auto-created by space_repo.create, so it already exists + let default_fs_id = format!("fs_default_{}", space_id_str); + + // Create a custom FS in DB for the explicit grant + let custom_fs = mcpmux_core::domain::FeatureSet { + id: "custom-fs-1".to_string(), + name: "Custom".to_string(), + description: None, + icon: None, + space_id: Some(space_id_str.clone()), + feature_set_type: mcpmux_core::domain::FeatureSetType::Custom, + server_id: None, + is_builtin: false, + is_deleted: false, + members: vec![], + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + t.fs_repo.create(&custom_fs).await.unwrap(); + + let client = test_client("client-1"); + t.client_repo.save_client(&client).await.unwrap(); + t.client_repo + .grant_feature_set("client-1", &space_id_str, "custom-fs-1") + .await + .unwrap(); + + let grants = t + .svc + .get_client_grants("client-1", &t.space_id) + .await + .unwrap(); + + assert!(grants.contains(&"custom-fs-1".to_string())); + assert!(grants.contains(&default_fs_id)); + assert_eq!(grants.len(), 2); +} + +#[tokio::test] +async fn no_explicit_grants_gets_default() { + let t = setup_with_space().await; + let default_fs_id = format!("fs_default_{}", t.space_id); + + let client = test_client("client-2"); + t.client_repo.save_client(&client).await.unwrap(); + + let grants = t + .svc + .get_client_grants("client-2", &t.space_id) + .await + .unwrap(); + + assert_eq!(grants, vec![default_fs_id]); +} + +#[tokio::test] +async fn no_duplicate_default_fs() { + let t = setup_with_space().await; + let space_id_str = t.space_id.to_string(); + let default_fs_id = format!("fs_default_{}", space_id_str); + + // Grant the default FS explicitly (it was already auto-created) + let client = test_client("client-3"); + t.client_repo.save_client(&client).await.unwrap(); + t.client_repo + .grant_feature_set("client-3", &space_id_str, &default_fs_id) + .await + .unwrap(); + + let grants = t + .svc + .get_client_grants("client-3", &t.space_id) + .await + .unwrap(); + + assert_eq!( + grants.iter().filter(|g| *g == &default_fs_id).count(), + 1, + "Default FS should appear exactly once" + ); +} + +#[tokio::test] +async fn no_default_fs_means_no_baseline() { + // Use a space ID that doesn't exist in the DB as feature_set space + // to test the path where get_default_for_space returns None. + let test_db = TestDatabase::in_memory(); + let db = Arc::new(Mutex::new(test_db.db)); + let client_repo = Arc::new(InboundClientRepository::new(db.clone())); + let fs_repo = Arc::new(SqliteFeatureSetRepository::new(db)); + + let svc = AuthorizationService::new(client_repo.clone(), fs_repo); + + // Use the seeded space from migration but query with a random space_id + let client = test_client("client-4"); + client_repo.save_client(&client).await.unwrap(); + + // Query with a space that has no feature sets at all + let random_space_id = Uuid::new_v4(); + let grants = svc + .get_client_grants("client-4", &random_space_id) + .await + .unwrap(); + + // No explicit grants and no Default FS for this space = empty + assert!(grants.is_empty()); +} + +#[tokio::test] +async fn has_access_with_default_fs() { + let t = setup_with_space().await; + + let client = test_client("client-5"); + t.client_repo.save_client(&client).await.unwrap(); + + // Should have access via the auto-created default FS baseline + assert!(t.svc.has_access("client-5", &t.space_id).await.unwrap()); +} + +#[tokio::test] +async fn has_feature_set_access_granted_vs_not() { + let t = setup_with_space().await; + let space_id_str = t.space_id.to_string(); + + // Create a custom FS to grant + let granted_fs = mcpmux_core::domain::FeatureSet { + id: "granted-fs".to_string(), + name: "Granted".to_string(), + description: None, + icon: None, + space_id: Some(space_id_str.clone()), + feature_set_type: mcpmux_core::domain::FeatureSetType::Custom, + server_id: None, + is_builtin: false, + is_deleted: false, + members: vec![], + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + t.fs_repo.create(&granted_fs).await.unwrap(); + + let client = test_client("client-6"); + t.client_repo.save_client(&client).await.unwrap(); + t.client_repo + .grant_feature_set("client-6", &space_id_str, "granted-fs") + .await + .unwrap(); + + assert!(t + .svc + .has_feature_set_access("client-6", &t.space_id, "granted-fs") + .await + .unwrap()); + + assert!(!t + .svc + .has_feature_set_access("client-6", &t.space_id, "missing-fs") + .await + .unwrap()); +} diff --git a/tests/rust/tests/gateway/mod.rs b/tests/rust/tests/gateway/mod.rs index b0583897..c76797b9 100644 --- a/tests/rust/tests/gateway/mod.rs +++ b/tests/rust/tests/gateway/mod.rs @@ -2,5 +2,7 @@ //! //! Tests for ServerManager state machine and connection handling. +mod authorization; mod server_manager; +mod space_resolver; mod stdio_transport; diff --git a/tests/rust/tests/gateway/space_resolver.rs b/tests/rust/tests/gateway/space_resolver.rs new file mode 100644 index 00000000..6db2917b --- /dev/null +++ b/tests/rust/tests/gateway/space_resolver.rs @@ -0,0 +1,148 @@ +//! Space resolver service integration tests +//! +//! Tests for SpaceResolverService using real InboundClientRepository (SQLite) +//! and MockSpaceRepository (for controllable space resolution). + +use mcpmux_core::SpaceRepository; +use mcpmux_gateway::services::SpaceResolverService; +use mcpmux_storage::{InboundClient, InboundClientRepository, RegistrationType}; +use std::sync::Arc; +use tests::db::TestDatabase; +use tests::fixtures; +use tests::mocks::MockSpaceRepository; +use tokio::sync::Mutex; +use uuid::Uuid; + +fn test_client_with_mode( + client_id: &str, + mode: &str, + locked_space_id: Option, +) -> InboundClient { + InboundClient { + client_id: client_id.to_string(), + registration_type: RegistrationType::Preregistered, + client_name: "Test Client".to_string(), + client_alias: None, + redirect_uris: vec![], + grant_types: vec![], + response_types: vec![], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + connection_mode: mode.to_string(), + locked_space_id, + last_seen: None, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + } +} + +struct TestSetup { + client_repo: Arc, + space_repo: Arc, +} + +async fn setup() -> TestSetup { + let test_db = TestDatabase::in_memory(); + let db = Arc::new(Mutex::new(test_db.db)); + let client_repo = Arc::new(InboundClientRepository::new(db)); + let space_repo = Arc::new(MockSpaceRepository::new()); + TestSetup { + client_repo, + space_repo, + } +} + +#[tokio::test] +async fn locked_mode_returns_locked_space() { + let t = setup().await; + + // Use the seeded default space ID to satisfy FK constraint on locked_space_id + let locked_id: Uuid = "00000000-0000-0000-0000-000000000001".parse().unwrap(); + + let client = test_client_with_mode("client-locked", "locked", Some(locked_id.to_string())); + t.client_repo.save_client(&client).await.unwrap(); + + let svc = SpaceResolverService::new(t.client_repo, t.space_repo); + let result = svc.resolve_space_for_client("client-locked").await.unwrap(); + assert_eq!(result, locked_id); +} + +#[tokio::test] +async fn locked_no_space_id_err() { + let t = setup().await; + + let client = test_client_with_mode("client-locked-no-id", "locked", None); + t.client_repo.save_client(&client).await.unwrap(); + + let svc = SpaceResolverService::new(t.client_repo, t.space_repo); + let result = svc.resolve_space_for_client("client-locked-no-id").await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no locked_space_id"), + "Error should mention missing locked_space_id" + ); +} + +#[tokio::test] +async fn follow_active_returns_default() { + let t = setup().await; + + // Set up a default space in the mock + let space = fixtures::test_space("Active Space"); + t.space_repo.create(&space).await.unwrap(); + t.space_repo.set_default(&space.id).await.unwrap(); + + let client = test_client_with_mode("client-follow", "follow_active", None); + t.client_repo.save_client(&client).await.unwrap(); + + let svc = SpaceResolverService::new(t.client_repo, t.space_repo); + let result = svc.resolve_space_for_client("client-follow").await.unwrap(); + assert_eq!(result, space.id); +} + +#[tokio::test] +async fn follow_active_no_default_err() { + let t = setup().await; + + // MockSpaceRepository starts empty โ€” no default space + let client = test_client_with_mode("client-no-default", "follow_active", None); + t.client_repo.save_client(&client).await.unwrap(); + + let svc = SpaceResolverService::new(t.client_repo, t.space_repo); + let result = svc.resolve_space_for_client("client-no-default").await; + assert!(result.is_err()); + assert!( + result.unwrap_err().to_string().contains("No active space"), + "Error should mention no active space" + ); +} + +#[tokio::test] +async fn unknown_mode_falls_back() { + let t = setup().await; + + let space = fixtures::test_space("Fallback Space"); + t.space_repo.create(&space).await.unwrap(); + t.space_repo.set_default(&space.id).await.unwrap(); + + let client = test_client_with_mode("client-unknown", "some_future_mode", None); + t.client_repo.save_client(&client).await.unwrap(); + + let svc = SpaceResolverService::new(t.client_repo, t.space_repo); + let result = svc + .resolve_space_for_client("client-unknown") + .await + .unwrap(); + assert_eq!(result, space.id); +} diff --git a/tests/ts/hooks/useDomainEvents.test.ts b/tests/ts/hooks/useDomainEvents.test.ts new file mode 100644 index 00000000..63bb3ce1 --- /dev/null +++ b/tests/ts/hooks/useDomainEvents.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { listen } from '@tauri-apps/api/event'; +import { useDomainEvents } from '@/hooks/useDomainEvents'; + +const mockListen = vi.mocked(listen); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useDomainEvents', () => { + it('subscribe calls listen with the correct channel name', () => { + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const { result } = renderHook(() => useDomainEvents()); + + act(() => { + result.current.subscribe('server-status-changed', vi.fn()); + }); + + expect(mockListen).toHaveBeenCalledWith( + 'server-status-changed', + expect.any(Function) + ); + }); + + it('subscribe callback receives event payload', async () => { + let capturedHandler: ((event: unknown) => void) | undefined; + mockListen.mockImplementation(async (_channel, handler) => { + capturedHandler = handler as (event: unknown) => void; + return vi.fn(); + }); + + const callback = vi.fn(); + const { result } = renderHook(() => useDomainEvents()); + + act(() => { + result.current.subscribe('space-changed', callback); + }); + + // Simulate an event + const payload = { action: 'created', space_id: 'sp-1', name: 'New Space' }; + act(() => { + capturedHandler?.({ payload }); + }); + + expect(callback).toHaveBeenCalledWith(payload); + }); + + it('subscribe returns cleanup that calls unlisten', async () => { + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const { result } = renderHook(() => useDomainEvents()); + + let cleanup: (() => void) | undefined; + act(() => { + cleanup = result.current.subscribe('server-changed', vi.fn()); + }); + + // Wait for listen promise to resolve + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + act(() => { + cleanup?.(); + }); + + expect(unlisten).toHaveBeenCalled(); + }); + + it('subscribe updates lastEvent state', async () => { + let capturedHandler: ((event: unknown) => void) | undefined; + mockListen.mockImplementation(async (_channel, handler) => { + capturedHandler = handler as (event: unknown) => void; + return vi.fn(); + }); + + const { result } = renderHook(() => useDomainEvents()); + + act(() => { + result.current.subscribe('gateway-changed', vi.fn()); + }); + + const payload = { action: 'started', url: 'http://localhost:45818' }; + act(() => { + capturedHandler?.({ payload }); + }); + + expect(result.current.lastEvent).toEqual({ + channel: 'gateway-changed', + payload, + }); + }); + + it('subscribeAll registers all channels', () => { + mockListen.mockResolvedValue(vi.fn()); + + const { result } = renderHook(() => useDomainEvents()); + + act(() => { + result.current.subscribeAll(vi.fn()); + }); + + // Should have registered listeners for all 10 channels + expect(mockListen).toHaveBeenCalledTimes(10); + const channels = mockListen.mock.calls.map((call) => call[0]); + expect(channels).toContain('space-changed'); + expect(channels).toContain('server-changed'); + expect(channels).toContain('server-status-changed'); + expect(channels).toContain('gateway-changed'); + expect(channels).toContain('mcp-notification'); + }); + + it('subscribeAll cleanup removes all listeners', async () => { + const unlistenFns = Array.from({ length: 10 }, () => vi.fn()); + let callIdx = 0; + mockListen.mockImplementation(async () => { + return unlistenFns[callIdx++] ?? vi.fn(); + }); + + const { result } = renderHook(() => useDomainEvents()); + + let cleanup: (() => void) | undefined; + act(() => { + cleanup = result.current.subscribeAll(vi.fn()); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + act(() => { + cleanup?.(); + }); + + for (const fn of unlistenFns) { + expect(fn).toHaveBeenCalled(); + } + }); + + it('subscribeMany only registers specified channels', () => { + mockListen.mockResolvedValue(vi.fn()); + + const { result } = renderHook(() => useDomainEvents()); + + act(() => { + result.current.subscribeMany( + ['client-changed', 'grants-changed'], + vi.fn() + ); + }); + + expect(mockListen).toHaveBeenCalledTimes(2); + const channels = mockListen.mock.calls.map((call) => call[0]); + expect(channels).toEqual(['client-changed', 'grants-changed']); + }); + + it('unmount cleans up all active listeners', async () => { + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const { result, unmount } = renderHook(() => useDomainEvents()); + + act(() => { + result.current.subscribe('space-changed', vi.fn()); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + unmount(); + + expect(unlisten).toHaveBeenCalled(); + }); +}); diff --git a/tests/ts/hooks/useServerManager.test.ts b/tests/ts/hooks/useServerManager.test.ts new file mode 100644 index 00000000..64e68a80 --- /dev/null +++ b/tests/ts/hooks/useServerManager.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { useServerManager } from '@/hooks/useServerManager'; +import type { ServerStatusResponse } from '@/lib/api/serverManager'; + +const mockInvoke = vi.mocked(invoke); +const mockListen = vi.mocked(listen); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function mockStatuses( + statuses: Record +) { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === 'get_server_statuses') return statuses; + return undefined; + }); +} + +function makeStatus( + serverId: string, + status: ServerStatusResponse['status'] = 'disconnected', + flowId = 1 +): ServerStatusResponse { + return { + server_id: serverId, + status, + flow_id: flowId, + has_connected_before: false, + message: null, + }; +} + +describe('useServerManager', () => { + it('initial fetch populates statuses', async () => { + const data = { + 'srv-1': makeStatus('srv-1', 'connected'), + 'srv-2': makeStatus('srv-2', 'disconnected'), + }; + mockStatuses(data); + mockListen.mockResolvedValue(vi.fn()); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.statuses['srv-1']?.status).toBe('connected'); + expect(result.current.statuses['srv-2']?.status).toBe('disconnected'); + }); + + it('loading is true then false', async () => { + mockStatuses({}); + mockListen.mockResolvedValue(vi.fn()); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + // Initially loading + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('error on invoke rejection', async () => { + mockInvoke.mockRejectedValue(new Error('Network error')); + mockListen.mockResolvedValue(vi.fn()); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Network error'); + }); + + it('empty statuses when no servers', async () => { + mockStatuses({}); + mockListen.mockResolvedValue(vi.fn()); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(Object.keys(result.current.statuses)).toHaveLength(0); + }); + + it('null spaceId skips fetch', async () => { + mockListen.mockResolvedValue(vi.fn()); + + const { result } = renderHook(() => + useServerManager({ spaceId: '' }) + ); + + // Should not have called invoke since spaceId is empty + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(mockInvoke).not.toHaveBeenCalledWith( + 'get_server_statuses', + expect.anything() + ); + }); + + it('status event updates matching server state', async () => { + const data = { 'srv-1': makeStatus('srv-1', 'connecting', 1) }; + mockStatuses(data); + + // Capture the status event handler + let statusHandler: ((event: unknown) => void) | undefined; + mockListen.mockImplementation(async (channel, handler) => { + if (channel === 'server-status-changed') { + statusHandler = handler as (event: unknown) => void; + } + return vi.fn(); + }); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Simulate a status change event + act(() => { + statusHandler?.({ + payload: { + space_id: 'space-1', + server_id: 'srv-1', + status: 'connected', + flow_id: 2, + has_connected_before: true, + }, + }); + }); + + expect(result.current.statuses['srv-1']?.status).toBe('connected'); + }); + + it('wrong space event is ignored', async () => { + const data = { 'srv-1': makeStatus('srv-1', 'connecting', 1) }; + mockStatuses(data); + + let statusHandler: ((event: unknown) => void) | undefined; + mockListen.mockImplementation(async (channel, handler) => { + if (channel === 'server-status-changed') { + statusHandler = handler as (event: unknown) => void; + } + return vi.fn(); + }); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Event from different space + act(() => { + statusHandler?.({ + payload: { + space_id: 'space-OTHER', + server_id: 'srv-1', + status: 'error', + flow_id: 99, + has_connected_before: false, + }, + }); + }); + + // Status unchanged โ€” still from the initial fetch + expect(result.current.statuses['srv-1']?.status).toBe('connecting'); + }); + + it('older flow_id event is ignored', async () => { + const data = { 'srv-1': makeStatus('srv-1', 'connected', 5) }; + mockStatuses(data); + + let statusHandler: ((event: unknown) => void) | undefined; + mockListen.mockImplementation(async (channel, handler) => { + if (channel === 'server-status-changed') { + statusHandler = handler as (event: unknown) => void; + } + return vi.fn(); + }); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Event with older flow_id + act(() => { + statusHandler?.({ + payload: { + space_id: 'space-1', + server_id: 'srv-1', + status: 'error', + flow_id: 3, + has_connected_before: false, + }, + }); + }); + + // Should remain 'connected' from the initial fetch + expect(result.current.statuses['srv-1']?.status).toBe('connected'); + }); + + it('newer flow_id event is accepted', async () => { + const data = { 'srv-1': makeStatus('srv-1', 'connecting', 1) }; + mockStatuses(data); + + let statusHandler: ((event: unknown) => void) | undefined; + mockListen.mockImplementation(async (channel, handler) => { + if (channel === 'server-status-changed') { + statusHandler = handler as (event: unknown) => void; + } + return vi.fn(); + }); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + statusHandler?.({ + payload: { + space_id: 'space-1', + server_id: 'srv-1', + status: 'connected', + flow_id: 10, + has_connected_before: true, + }, + }); + }); + + expect(result.current.statuses['srv-1']?.status).toBe('connected'); + expect(result.current.statuses['srv-1']?.flow_id).toBe(10); + }); + + it('auth progress event sets authProgress state', async () => { + mockStatuses({}); + + let authHandler: ((event: unknown) => void) | undefined; + mockListen.mockImplementation(async (channel, handler) => { + if (channel === 'server-auth-progress') { + authHandler = handler as (event: unknown) => void; + } + return vi.fn(); + }); + + const { result } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + authHandler?.({ + payload: { + space_id: 'space-1', + server_id: 'srv-1', + remaining_seconds: 42, + flow_id: 1, + }, + }); + }); + + expect(result.current.authProgress['srv-1']).toBe(42); + }); + + it('unmount calls unlisten functions', async () => { + mockStatuses({}); + + const unlistenFns = [vi.fn(), vi.fn(), vi.fn()]; + let idx = 0; + mockListen.mockImplementation(async () => { + return unlistenFns[idx++] ?? vi.fn(); + }); + + const { unmount } = renderHook(() => + useServerManager({ spaceId: 'space-1' }) + ); + + // Wait for listeners to be set up + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + unmount(); + + // All 3 event listeners (status, auth, features) should be unsubscribed + for (const fn of unlistenFns) { + expect(fn).toHaveBeenCalled(); + } + }); +}); diff --git a/tests/ts/hooks/useSpaces.test.ts b/tests/ts/hooks/useSpaces.test.ts new file mode 100644 index 00000000..334f5bf6 --- /dev/null +++ b/tests/ts/hooks/useSpaces.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { invoke } from '@tauri-apps/api/core'; +import { useSpaces } from '@/hooks/useSpaces'; +import { createTestSpace, createDefaultSpace } from '../fixtures'; + +const mockInvoke = vi.mocked(invoke); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useSpaces', () => { + it('load fetches spaces and active space', async () => { + const space1 = createDefaultSpace({ id: 'sp-1' }); + const space2 = createTestSpace({ id: 'sp-2' }); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_spaces') return [space1, space2]; + if (cmd === 'get_active_space') return space1; + return null; + }); + + const { result } = renderHook(() => useSpaces()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.spaces).toHaveLength(2); + expect(result.current.activeSpace).toEqual(space1); + }); + + it('loading is true during fetch, false after', async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_spaces') return []; + if (cmd === 'get_active_space') return null; + return null; + }); + + const { result } = renderHook(() => useSpaces()); + + // Initially loading + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('sets error on fetch failure', async () => { + mockInvoke.mockRejectedValue(new Error('Database connection failed')); + + const { result } = renderHook(() => useSpaces()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Database connection failed'); + }); + + it('create calls invoke then refreshes', async () => { + const existing = createDefaultSpace({ id: 'sp-1' }); + const created = createTestSpace({ id: 'sp-2', name: 'New Space' }); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_spaces') return [existing, created]; + if (cmd === 'get_active_space') return existing; + if (cmd === 'create_space') return created; + return null; + }); + + const { result } = renderHook(() => useSpaces()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.create('New Space', '๐Ÿ†•'); + }); + + // create_space should have been called + expect(mockInvoke).toHaveBeenCalledWith('create_space', { + name: 'New Space', + icon: '๐Ÿ†•', + }); + + // And list_spaces should have been called again (refresh) + const listCalls = mockInvoke.mock.calls.filter( + (call) => call[0] === 'list_spaces' + ); + expect(listCalls.length).toBeGreaterThanOrEqual(2); + }); + + it('remove calls invoke then refreshes', async () => { + const space = createDefaultSpace({ id: 'sp-1' }); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_spaces') return [space]; + if (cmd === 'get_active_space') return space; + if (cmd === 'delete_space') return undefined; + return null; + }); + + const { result } = renderHook(() => useSpaces()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.remove('sp-1'); + }); + + expect(mockInvoke).toHaveBeenCalledWith('delete_space', { id: 'sp-1' }); + }); + + it('setActive calls invoke then refreshes', async () => { + const space = createDefaultSpace({ id: 'sp-1' }); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === 'list_spaces') return [space]; + if (cmd === 'get_active_space') return space; + if (cmd === 'set_active_space') return undefined; + return null; + }); + + const { result } = renderHook(() => useSpaces()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.setActive('sp-1'); + }); + + expect(mockInvoke).toHaveBeenCalledWith('set_active_space', { id: 'sp-1' }); + }); +}); diff --git a/tests/ts/lib/serverManager.test.ts b/tests/ts/lib/serverManager.test.ts new file mode 100644 index 00000000..0f676884 --- /dev/null +++ b/tests/ts/lib/serverManager.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { + getConnectButtonLabel, + getServerAction, + type ConnectionStatus, +} from '@/lib/api/serverManager'; + +describe('getConnectButtonLabel', () => { + it('oauth_required with no prior connection returns "Connect"', () => { + expect(getConnectButtonLabel('oauth_required', false)).toBe('Connect'); + }); + + it('oauth_required with prior connection returns "Reconnect"', () => { + expect(getConnectButtonLabel('oauth_required', true)).toBe('Reconnect'); + }); + + it('error with no prior connection returns "Connect"', () => { + expect(getConnectButtonLabel('error', false)).toBe('Connect'); + }); + + it('error with prior connection returns "Reconnect"', () => { + expect(getConnectButtonLabel('error', true)).toBe('Reconnect'); + }); + + it('authenticating returns "Authenticating..."', () => { + expect(getConnectButtonLabel('authenticating', false)).toBe('Authenticating...'); + }); + + it('connecting returns "Connecting..."', () => { + expect(getConnectButtonLabel('connecting', false)).toBe('Connecting...'); + }); + + it('connected returns "Connect"', () => { + expect(getConnectButtonLabel('connected', false)).toBe('Connect'); + }); + + it('disconnected returns "Connect"', () => { + expect(getConnectButtonLabel('disconnected', false)).toBe('Connect'); + }); +}); + +describe('getServerAction', () => { + it('disconnected returns "enable"', () => { + expect(getServerAction('disconnected')).toBe('enable'); + }); + + it('connecting returns "connecting"', () => { + expect(getServerAction('connecting')).toBe('connecting'); + }); + + it('refreshing returns "connecting"', () => { + expect(getServerAction('refreshing')).toBe('connecting'); + }); + + it('connected returns "connected"', () => { + expect(getServerAction('connected')).toBe('connected'); + }); + + it('oauth_required returns "connect"', () => { + expect(getServerAction('oauth_required')).toBe('connect'); + }); + + it('authenticating returns "cancel"', () => { + expect(getServerAction('authenticating')).toBe('cancel'); + }); + + it('error returns "retry"', () => { + expect(getServerAction('error')).toBe('retry'); + }); + + it('all statuses return defined values', () => { + const statuses: ConnectionStatus[] = [ + 'disconnected', + 'connecting', + 'connected', + 'refreshing', + 'oauth_required', + 'authenticating', + 'error', + ]; + for (const status of statuses) { + expect(getServerAction(status)).toBeDefined(); + } + }); +}); diff --git a/tests/ts/stores/selectors.test.ts b/tests/ts/stores/selectors.test.ts new file mode 100644 index 00000000..466fa41f --- /dev/null +++ b/tests/ts/stores/selectors.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAppStore } from '@/stores/appStore'; +import { useActiveSpace, useViewSpace, useIsLoading } from '@/stores/selectors'; +import { createTestSpace, createDefaultSpace } from '../fixtures'; + +beforeEach(() => { + // Reset store to initial state before each test + useAppStore.setState({ + spaces: [], + activeSpaceId: null, + viewSpaceId: null, + loading: { spaces: false, servers: false }, + }); +}); + +describe('useActiveSpace', () => { + it('returns matching space when activeSpaceId is set', () => { + const space = createDefaultSpace({ id: 'space-1' }); + useAppStore.setState({ spaces: [space], activeSpaceId: 'space-1' }); + + const { result } = renderHook(() => useActiveSpace()); + expect(result.current).toEqual(space); + }); + + it('returns null when no activeSpaceId', () => { + useAppStore.setState({ spaces: [createTestSpace()], activeSpaceId: null }); + + const { result } = renderHook(() => useActiveSpace()); + expect(result.current).toBeNull(); + }); + + it('returns null when activeSpaceId not in list', () => { + useAppStore.setState({ + spaces: [createTestSpace({ id: 'space-a' })], + activeSpaceId: 'space-missing', + }); + + const { result } = renderHook(() => useActiveSpace()); + expect(result.current).toBeNull(); + }); +}); + +describe('useViewSpace', () => { + it('uses viewSpaceId when it differs from activeSpaceId', () => { + const space1 = createTestSpace({ id: 'space-1', name: 'Space 1' }); + const space2 = createTestSpace({ id: 'space-2', name: 'Space 2' }); + useAppStore.setState({ + spaces: [space1, space2], + activeSpaceId: 'space-1', + viewSpaceId: 'space-2', + }); + + const { result } = renderHook(() => useViewSpace()); + expect(result.current).toEqual(space2); + }); + + it('falls back to activeSpaceId when viewSpaceId is null', () => { + const space = createDefaultSpace({ id: 'space-1' }); + useAppStore.setState({ + spaces: [space], + activeSpaceId: 'space-1', + viewSpaceId: null, + }); + + const { result } = renderHook(() => useViewSpace()); + expect(result.current).toEqual(space); + }); + + it('returns null when both viewSpaceId and activeSpaceId are null', () => { + useAppStore.setState({ + spaces: [createTestSpace()], + activeSpaceId: null, + viewSpaceId: null, + }); + + const { result } = renderHook(() => useViewSpace()); + expect(result.current).toBeNull(); + }); +}); + +describe('useIsLoading', () => { + it('initially returns false for spaces', () => { + const { result } = renderHook(() => useIsLoading('spaces')); + expect(result.current).toBe(false); + }); + + it('reflects setLoading for spaces', () => { + act(() => { + useAppStore.getState().setLoading('spaces', true); + }); + + const { result } = renderHook(() => useIsLoading('spaces')); + expect(result.current).toBe(true); + }); +});