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 asupersync::{Budget, Cx};
7use fastmcp_core::{McpContext, SessionState};
8
9/// Test context wrapper providing convenient testing utilities.
10///
11/// Wraps `Cx::for_testing()` and provides helper methods for:
12/// - Budget/timeout configuration
13/// - Creating `McpContext` instances
14/// - Running async operations with cleanup
15///
16/// # Example
17///
18/// ```ignore
19/// let ctx = TestContext::new();
20/// let mcp_ctx = ctx.mcp_context(1);  // Request ID 1
21///
22/// // With custom budget
23/// let ctx = TestContext::new().with_budget_secs(30);
24/// ```
25#[derive(Clone)]
26pub struct TestContext {
27    /// The underlying asupersync context.
28    cx: Cx,
29    /// Optional budget for timeout testing.
30    budget: Option<Budget>,
31    /// Session state for stateful tests.
32    session_state: Option<SessionState>,
33}
34
35impl std::fmt::Debug for TestContext {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("TestContext")
38            .field("has_budget", &self.budget.is_some())
39            .field("has_session_state", &self.session_state.is_some())
40            .finish()
41    }
42}
43
44impl Default for TestContext {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl TestContext {
51    /// Creates a new test context using `Cx::for_testing()`.
52    ///
53    /// # Example
54    ///
55    /// ```ignore
56    /// let ctx = TestContext::new();
57    /// ```
58    #[must_use]
59    pub fn new() -> Self {
60        Self {
61            cx: Cx::for_testing(),
62            budget: None,
63            session_state: None,
64        }
65    }
66
67    /// Creates a test context with a budget timeout.
68    ///
69    /// # Arguments
70    ///
71    /// * `secs` - Timeout in seconds
72    ///
73    /// # Example
74    ///
75    /// ```ignore
76    /// let ctx = TestContext::new().with_budget_secs(5);
77    /// ```
78    #[must_use]
79    pub fn with_budget_secs(mut self, secs: u64) -> Self {
80        self.budget = Some(Budget::with_deadline_secs(secs));
81        self
82    }
83
84    /// Creates a test context with a budget timeout in milliseconds.
85    ///
86    /// # Arguments
87    ///
88    /// * `ms` - Timeout in milliseconds
89    #[must_use]
90    pub fn with_budget_ms(mut self, ms: u64) -> Self {
91        // Convert ms to secs (rounded up)
92        let secs = (ms + 999) / 1000;
93        self.budget = Some(Budget::with_deadline_secs(secs));
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}