Skip to content

Commit 25f2e60

Browse files
committed
test: add ~134 tests across application services, gateway, and frontend
Cover critical gaps identified in test coverage analysis: - Application services (ServerAppService, SpaceAppService, ClientAppService, PermissionAppService) had zero test coverage — now 44 integration tests - Gateway unit tests for GatewayState, RateLimiter, and routing auth error detection — 27 inline tests - Gateway integration tests for AuthorizationService and SpaceResolverService — 11 tests with real SQLite repos - TypeScript tests for pure functions (getConnectButtonLabel, getServerAction), Zustand selectors, and React hooks (useDomainEvents, useSpaces, useServerManager) — 49 tests - EventBus edge cases (try_recv, emit_or_warn) — 3 tests Signed-off-by: Myko <myko@mcpmux.com> Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent d43c6fd commit 25f2e60

19 files changed

Lines changed: 2946 additions & 5 deletions

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/mcpmux-core/src/event_bus.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,4 +287,35 @@ mod tests {
287287
let count = sender.emit(DomainEvent::GatewayStopped);
288288
assert_eq!(count, 0);
289289
}
290+
291+
#[test]
292+
fn try_recv_none_when_empty() {
293+
let bus = EventBus::new();
294+
let mut receiver = bus.subscribe();
295+
296+
// No events emitted yet
297+
assert!(receiver.try_recv().is_none());
298+
}
299+
300+
#[test]
301+
fn try_recv_returns_event() {
302+
let bus = EventBus::new();
303+
let sender = bus.sender();
304+
let mut receiver = bus.subscribe();
305+
306+
sender.emit(DomainEvent::GatewayStopped);
307+
308+
let event = receiver.try_recv();
309+
assert!(event.is_some());
310+
assert_eq!(event.unwrap().type_name(), "gateway_stopped");
311+
}
312+
313+
#[test]
314+
fn emit_or_warn_no_receivers_does_not_panic() {
315+
let bus = EventBus::new();
316+
let sender = bus.sender();
317+
318+
// Should not panic even with no receivers
319+
sender.emit_or_warn(DomainEvent::GatewayStopped);
320+
}
290321
}

crates/mcpmux-gateway/src/pool/routing.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,3 +738,67 @@ impl RoutingService {
738738
false
739739
}
740740
}
741+
742+
#[cfg(test)]
743+
mod tests {
744+
use super::*;
745+
746+
// is_auth_error tests
747+
748+
#[test]
749+
fn is_auth_error_401() {
750+
assert!(RoutingService::is_auth_error("got 401 from server"));
751+
}
752+
753+
#[test]
754+
fn is_auth_error_unauthorized() {
755+
assert!(RoutingService::is_auth_error("unauthorized access denied"));
756+
}
757+
758+
#[test]
759+
fn is_auth_error_invalid_token() {
760+
assert!(RoutingService::is_auth_error(
761+
"invalid_token: token revoked"
762+
));
763+
}
764+
765+
#[test]
766+
fn is_auth_error_token_expired() {
767+
assert!(RoutingService::is_auth_error("token expired at 12:00"));
768+
}
769+
770+
#[test]
771+
fn is_auth_error_access_token() {
772+
assert!(RoutingService::is_auth_error("access token is invalid"));
773+
}
774+
775+
#[test]
776+
fn is_auth_error_unrelated() {
777+
assert!(!RoutingService::is_auth_error("connection refused"));
778+
}
779+
780+
#[test]
781+
fn is_auth_error_empty() {
782+
assert!(!RoutingService::is_auth_error(""));
783+
}
784+
785+
// content_has_auth_error tests
786+
787+
#[test]
788+
fn content_auth_error_in_text() {
789+
let content = vec![serde_json::json!({"type": "text", "text": "Error: 401 Unauthorized"})];
790+
assert!(RoutingService::content_has_auth_error(&content));
791+
}
792+
793+
#[test]
794+
fn content_no_text_field() {
795+
let content = vec![serde_json::json!({"type": "image", "data": "base64..."})];
796+
assert!(!RoutingService::content_has_auth_error(&content));
797+
}
798+
799+
#[test]
800+
fn content_empty_slice() {
801+
let content: Vec<Value> = vec![];
802+
assert!(!RoutingService::content_has_auth_error(&content));
803+
}
804+
}

crates/mcpmux-gateway/src/server/rate_limit.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,136 @@ pub fn default_oauth_rate_limiter() -> RateLimiter {
128128
),
129129
])
130130
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::*;
135+
136+
#[test]
137+
fn allows_within_limit() {
138+
let limiter = RateLimiter::new(vec![(
139+
"/test".to_string(),
140+
RateLimitConfig {
141+
max_requests: 5,
142+
window: Duration::from_secs(60),
143+
},
144+
)]);
145+
146+
for _ in 0..5 {
147+
assert!(limiter.check("/test"), "Should allow requests within limit");
148+
}
149+
}
150+
151+
#[test]
152+
fn blocks_at_limit() {
153+
let limiter = RateLimiter::new(vec![(
154+
"/test".to_string(),
155+
RateLimitConfig {
156+
max_requests: 3,
157+
window: Duration::from_secs(60),
158+
},
159+
)]);
160+
161+
assert!(limiter.check("/test")); // 1
162+
assert!(limiter.check("/test")); // 2
163+
assert!(limiter.check("/test")); // 3
164+
assert!(!limiter.check("/test"), "Should block at limit");
165+
}
166+
167+
#[test]
168+
fn no_matching_rule_allows() {
169+
let limiter = RateLimiter::new(vec![(
170+
"/oauth".to_string(),
171+
RateLimitConfig {
172+
max_requests: 1,
173+
window: Duration::from_secs(60),
174+
},
175+
)]);
176+
177+
assert!(limiter.check("/health"), "Unmatched path should be allowed");
178+
}
179+
180+
#[test]
181+
fn prefix_matching() {
182+
let limiter = RateLimiter::new(vec![(
183+
"/oauth/token".to_string(),
184+
RateLimitConfig {
185+
max_requests: 1,
186+
window: Duration::from_secs(60),
187+
},
188+
)]);
189+
190+
assert!(limiter.check("/oauth/token/extra")); // matches prefix, count=1
191+
assert!(
192+
!limiter.check("/oauth/token"),
193+
"Second request to same prefix should be blocked"
194+
);
195+
}
196+
197+
#[test]
198+
fn independent_buckets() {
199+
let limiter = RateLimiter::new(vec![
200+
(
201+
"/oauth/authorize".to_string(),
202+
RateLimitConfig {
203+
max_requests: 1,
204+
window: Duration::from_secs(60),
205+
},
206+
),
207+
(
208+
"/oauth/token".to_string(),
209+
RateLimitConfig {
210+
max_requests: 1,
211+
window: Duration::from_secs(60),
212+
},
213+
),
214+
]);
215+
216+
assert!(limiter.check("/oauth/authorize"));
217+
assert!(
218+
!limiter.check("/oauth/authorize"),
219+
"authorize should be blocked"
220+
);
221+
// token should still be allowed (independent counter)
222+
assert!(limiter.check("/oauth/token"));
223+
}
224+
225+
#[test]
226+
fn default_has_expected_rules() {
227+
let limiter = default_oauth_rate_limiter();
228+
// Verify it has rules for 5 OAuth paths by checking they are rate-limited
229+
let paths = [
230+
"/oauth/authorize",
231+
"/authorize",
232+
"/oauth/token",
233+
"/oauth/register",
234+
"/oauth/clients",
235+
];
236+
for path in &paths {
237+
assert!(
238+
limiter.check(path),
239+
"First request to {} should be allowed",
240+
path
241+
);
242+
}
243+
}
244+
245+
#[test]
246+
fn window_reset() {
247+
let limiter = RateLimiter::new(vec![(
248+
"/test".to_string(),
249+
RateLimitConfig {
250+
max_requests: 1,
251+
window: Duration::from_millis(1), // 1ms window
252+
},
253+
)]);
254+
255+
assert!(limiter.check("/test")); // count=1
256+
assert!(!limiter.check("/test")); // blocked
257+
258+
// Sleep past the window
259+
std::thread::sleep(Duration::from_millis(10));
260+
261+
assert!(limiter.check("/test"), "Should allow after window reset");
262+
}
263+
}

0 commit comments

Comments
 (0)