Skip to main content

fastmcp_rust/testing/
context.rs

1//! Test context wrapper for asupersync integration.
2//!
3//! Provides a convenient wrapper around `Cx::for_testing()` with
4//! helper methods for common test scenarios.
5
6use std::time::Duration;
7
8use asupersync::{Budget, Cx, time::wall_now};
9use fastmcp_core::{McpContext, SessionState};
10
11/// Test context wrapper providing convenient testing utilities.
12///
13/// Wraps `Cx::for_testing()` and provides helper methods for:
14/// - Budget/timeout configuration
15/// - Creating `McpContext` instances
16/// - Running async operations with cleanup
17///
18/// # Example
19///
20/// ```ignore
21/// let ctx = TestContext::new();
22/// let mcp_ctx = ctx.mcp_context(1);  // Request ID 1
23///
24/// // With custom budget
25/// let ctx = TestContext::new().with_budget_secs(30);
26/// ```
27#[derive(Clone)]
28pub struct TestContext {
29    /// The underlying asupersync context.
30    cx: Cx,
31    /// Optional budget for timeout testing.
32    budget: Option<Budget>,
33    /// Session state for stateful tests.
34    session_state: Option<SessionState>,
35}
36
37impl std::fmt::Debug for TestContext {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("TestContext")
40            .field("has_budget", &self.budget.is_some())
41            .field("has_session_state", &self.session_state.is_some())
42            .finish()
43    }
44}
45
46impl Default for TestContext {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl TestContext {
53    /// Creates a new test context using `Cx::for_testing()`.
54    ///
55    /// # Example
56    ///
57    /// ```ignore
58    /// let ctx = TestContext::new();
59    /// ```
60    #[must_use]
61    pub fn new() -> Self {
62        Self {
63            cx: Cx::for_testing(),
64            budget: None,
65            session_state: None,
66        }
67    }
68
69    /// Creates a test context with a budget timeout.
70    ///
71    /// # Arguments
72    ///
73    /// * `secs` - Timeout in seconds
74    ///
75    /// # Example
76    ///
77    /// ```ignore
78    /// let ctx = TestContext::new().with_budget_secs(5);
79    /// ```
80    #[must_use]
81    pub fn with_budget_secs(mut self, secs: u64) -> Self {
82        self.budget = Some(Budget::new().with_timeout(wall_now(), Duration::from_secs(secs)));
83        self
84    }
85
86    /// Creates a test context with a budget timeout in milliseconds.
87    ///
88    /// # Arguments
89    ///
90    /// * `ms` - Timeout in milliseconds
91    #[must_use]
92    pub fn with_budget_ms(mut self, ms: u64) -> Self {
93        self.budget = Some(Budget::new().with_timeout(wall_now(), Duration::from_millis(ms)));
94        self
95    }
96
97    /// Creates a test context with shared session state.
98    ///
99    /// Useful for testing state persistence across multiple contexts.
100    ///
101    /// # Example
102    ///
103    /// ```ignore
104    /// let state = SessionState::new();
105    /// let ctx1 = TestContext::new().with_session_state(state.clone());
106    /// let ctx2 = TestContext::new().with_session_state(state.clone());
107    /// // Both contexts share the same session state
108    /// ```
109    #[must_use]
110    pub fn with_session_state(mut self, state: SessionState) -> Self {
111        self.session_state = Some(state);
112        self
113    }
114
115    /// Returns the underlying `Cx`.
116    #[must_use]
117    pub fn cx(&self) -> &Cx {
118        &self.cx
119    }
120
121    /// Returns a clone of the underlying `Cx`.
122    #[must_use]
123    pub fn cx_clone(&self) -> Cx {
124        self.cx.clone()
125    }
126
127    /// Returns the budget if configured.
128    #[must_use]
129    pub fn budget(&self) -> Option<&Budget> {
130        self.budget.as_ref()
131    }
132
133    /// Creates an `McpContext` for handler testing.
134    ///
135    /// # Arguments
136    ///
137    /// * `request_id` - The request ID for this context
138    ///
139    /// # Example
140    ///
141    /// ```ignore
142    /// let ctx = TestContext::new();
143    /// let mcp_ctx = ctx.mcp_context(1);
144    ///
145    /// // Use in handler testing
146    /// let result = my_tool_handler.call(&mcp_ctx, args)?;
147    /// ```
148    #[must_use]
149    pub fn mcp_context(&self, request_id: u64) -> McpContext {
150        if let Some(state) = &self.session_state {
151            McpContext::with_state(self.cx.clone(), request_id, state.clone())
152        } else {
153            McpContext::new(self.cx.clone(), request_id)
154        }
155    }
156
157    /// Creates an `McpContext` with shared session state.
158    ///
159    /// # Arguments
160    ///
161    /// * `request_id` - The request ID
162    /// * `state` - Session state to attach
163    #[must_use]
164    pub fn mcp_context_with_state(&self, request_id: u64, state: SessionState) -> McpContext {
165        McpContext::with_state(self.cx.clone(), request_id, state)
166    }
167
168    /// Checks if cancellation has been requested.
169    #[must_use]
170    pub fn is_cancelled(&self) -> bool {
171        self.cx.is_cancel_requested()
172    }
173
174    /// Performs a cancellation checkpoint.
175    ///
176    /// Returns `Err(CancelledError)` if cancellation was requested.
177    pub fn checkpoint(&self) -> fastmcp_core::McpResult<()> {
178        if self.cx.is_cancel_requested() {
179            Err(fastmcp_core::McpError::request_cancelled())
180        } else {
181            Ok(())
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_context_creation() {
192        let ctx = TestContext::new();
193        assert!(ctx.budget().is_none());
194        assert!(!ctx.is_cancelled());
195    }
196
197    #[test]
198    fn test_context_with_budget() {
199        let ctx = TestContext::new().with_budget_secs(10);
200        assert!(ctx.budget().is_some());
201    }
202
203    #[test]
204    fn test_context_with_session_state() {
205        let state = SessionState::new();
206        let ctx = TestContext::new().with_session_state(state);
207        assert!(ctx.session_state.is_some());
208    }
209
210    #[test]
211    fn test_mcp_context_creation() {
212        let ctx = TestContext::new();
213        let mcp_ctx = ctx.mcp_context(42);
214        assert_eq!(mcp_ctx.request_id(), 42);
215    }
216
217    #[test]
218    fn test_mcp_context_with_shared_state() {
219        let state = SessionState::new();
220
221        // First context sets a value
222        {
223            let ctx = TestContext::new().with_session_state(state.clone());
224            let mcp_ctx = ctx.mcp_context(1);
225            mcp_ctx.set_state("test_key", "test_value".to_string());
226        }
227
228        // Second context can read the value
229        {
230            let ctx = TestContext::new().with_session_state(state.clone());
231            let mcp_ctx = ctx.mcp_context(2);
232            let value: Option<String> = mcp_ctx.get_state("test_key");
233            assert_eq!(value, Some("test_value".to_string()));
234        }
235    }
236
237    #[test]
238    fn test_checkpoint_not_cancelled() {
239        let ctx = TestContext::new();
240        assert!(ctx.checkpoint().is_ok());
241    }
242
243    // =========================================================================
244    // Additional coverage tests (bd-1fnm)
245    // =========================================================================
246
247    #[test]
248    fn default_matches_new() {
249        let def = TestContext::default();
250        let new = TestContext::new();
251        assert!(def.budget().is_none());
252        assert!(new.budget().is_none());
253        assert!(!def.is_cancelled());
254    }
255
256    #[test]
257    fn debug_output() {
258        let ctx = TestContext::new();
259        let debug = format!("{ctx:?}");
260        assert!(debug.contains("TestContext"));
261        assert!(debug.contains("has_budget"));
262        assert!(debug.contains("has_session_state"));
263    }
264
265    #[test]
266    fn clone_produces_independent_context() {
267        let ctx = TestContext::new().with_budget_secs(30);
268        let cloned = ctx.clone();
269        assert!(cloned.budget().is_some());
270    }
271
272    #[test]
273    fn with_budget_ms_sets_budget() {
274        let ctx = TestContext::new().with_budget_ms(5000);
275        assert!(ctx.budget().is_some());
276    }
277
278    #[test]
279    fn cx_and_cx_clone_accessors() {
280        let ctx = TestContext::new();
281        let _cx_ref = ctx.cx();
282        let _cx_owned = ctx.cx_clone();
283    }
284
285    #[test]
286    fn mcp_context_with_state_method() {
287        let state = SessionState::new();
288        let ctx = TestContext::new();
289        let mcp_ctx = ctx.mcp_context_with_state(99, state);
290        assert_eq!(mcp_ctx.request_id(), 99);
291    }
292}