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}