clawspec_core/test_client/
test_server.rs

1use std::future::Future;
2use std::net::TcpListener;
3use std::time::Duration;
4
5use crate::{ApiClient, ApiClientBuilder};
6
7/// A trait for implementing test server abstractions for different web frameworks.
8///
9/// This trait provides a generic interface for launching and managing test servers
10/// for various web frameworks (e.g., Axum, Warp, actix-web). It allows the TestClient
11/// to work with any server implementation in a framework-agnostic way.
12///
13/// # Associated Types
14///
15/// - [`Error`](TestServer::Error): Error type that can occur during server operations
16///
17/// # Required Methods
18///
19/// - [`launch`](TestServer::launch): Starts the server with the provided TcpListener
20///
21/// # Optional Methods
22///
23/// - [`is_healthy`](TestServer::is_healthy): Checks if the server is ready to accept requests
24/// - [`config`](TestServer::config): Provides configuration for the test framework
25///
26/// # Example
27///
28/// ```rust
29/// use clawspec_core::test_client::{TestServer, TestServerConfig, HealthStatus};
30/// use std::net::TcpListener;
31/// use std::time::Duration;
32///
33/// #[derive(Debug)]
34/// struct ServerError;
35///
36/// impl std::fmt::Display for ServerError {
37///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38///         write!(f, "Server error")
39///     }
40/// }
41///
42/// impl std::error::Error for ServerError {}
43///
44/// #[derive(Debug)]
45/// struct MyTestServer;
46///
47/// impl TestServer for MyTestServer {
48///     type Error = ServerError;
49///
50///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
51///         // Convert to non-blocking for tokio compatibility
52///         listener.set_nonblocking(true).map_err(|_| ServerError)?;
53///         let tokio_listener = tokio::net::TcpListener::from_std(listener)
54///             .map_err(|_| ServerError)?;
55///         
56///         // Start your web server here
57///         // For example, with axum:
58///         // axum::serve(tokio_listener, app).await.map_err(|_| ServerError)?;
59///         Ok(())
60///     }
61///
62///     async fn is_healthy(&self, client: &mut clawspec_core::ApiClient) -> Result<HealthStatus, Self::Error> {
63///         // Check if server is ready by making a health check request
64///         match client.get("/health").unwrap().await {
65///             Ok(_) => Ok(HealthStatus::Healthy),
66///             Err(_) => Ok(HealthStatus::Unhealthy),
67///         }
68///     }
69///
70///     fn config(&self) -> TestServerConfig {
71///         TestServerConfig {
72///             api_client: Some(
73///                 clawspec_core::ApiClient::builder()
74///                     .with_host("localhost")
75///                     .with_base_path("/api").unwrap()
76///             ),
77///             min_backoff_delay: Duration::from_millis(10),
78///             max_backoff_delay: Duration::from_secs(1),
79///             backoff_jitter: true,
80///             max_retry_attempts: 10,
81///         }
82///     }
83/// }
84/// ```
85///
86/// # Framework Integration
87///
88/// ## Framework Integration Example
89///
90/// ```rust,no_run
91/// use clawspec_core::test_client::{TestServer, TestServerConfig};
92/// use std::net::TcpListener;
93///
94/// #[derive(Debug)]
95/// struct WebFrameworkTestServer {
96///     // Your web framework's app/router would go here
97///     // For example: app: axum::Router, or app: warp::Filter, etc.
98/// }
99///
100/// impl WebFrameworkTestServer {
101///     fn new(/* app: YourApp */) -> Self {
102///         Self { /* app */ }
103///     }
104/// }
105///
106/// impl TestServer for WebFrameworkTestServer {
107///     type Error = std::io::Error; // or your custom error type
108///
109///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
110///         listener.set_nonblocking(true)?;
111///         let tokio_listener = tokio::net::TcpListener::from_std(listener)?;
112///         
113///         // Start your web framework here:
114///         // For Axum: axum::serve(tokio_listener, self.app.clone()).await?;
115///         // For Warp: warp::serve(self.app.clone()).run_async(tokio_listener).await;
116///         // For actix-web: HttpServer::new(|| self.app.clone()).listen(tokio_listener)?.run().await?;
117///         Ok(())
118///     }
119/// }
120/// ```
121///
122/// # Health Checking
123///
124/// The `is_healthy` method allows implementing custom health check logic:
125///
126/// - Return `Ok(HealthStatus::Healthy)` if the server is ready to accept requests
127/// - Return `Ok(HealthStatus::Unhealthy)` if the server is not ready
128/// - Return `Ok(HealthStatus::Uncheckable)` to use the default TCP connection test
129/// - Return `Err(Self::Error)` if an error occurs during health checking
130///
131/// The TestClient will wait for the server to become healthy before returning success.
132/// Health check status returned by the `is_healthy` method.
133///
134/// This enum provides more explicit control over health checking behavior
135/// compared to the previous `Option<bool>` approach.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum HealthStatus {
138    /// Server is healthy and ready to accept requests.
139    Healthy,
140    /// Server is not healthy or not ready yet.
141    Unhealthy,
142    /// Use the default TCP connection test to determine health.
143    /// This is equivalent to the previous `None` return value.
144    Uncheckable,
145}
146
147pub trait TestServer {
148    /// The error type that can be returned by server operations.
149    ///
150    /// This should implement [`std::error::Error`] to provide proper error handling
151    /// and error chain support.
152    type Error: std::error::Error + Send + Sync + 'static;
153
154    /// Launch the server using the provided TcpListener.
155    ///
156    /// This method should start the web server and bind it to the given listener.
157    /// The implementation should convert the std::net::TcpListener to a tokio::net::TcpListener
158    /// for async compatibility.
159    ///
160    /// # Arguments
161    ///
162    /// * `listener` - A TcpListener bound to a random port for testing
163    ///
164    /// # Returns
165    ///
166    /// * `Ok(())` - Server launched successfully
167    /// * `Err(Self::Error)` - Server failed to launch
168    ///
169    /// # Example
170    ///
171    /// ```rust
172    /// # use clawspec_core::test_client::{TestServer, HealthStatus};
173    /// # use std::net::TcpListener;
174    /// # #[derive(Debug)] struct ServerError;
175    /// # impl std::fmt::Display for ServerError {
176    /// #     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177    /// #         write!(f, "Server error")
178    /// #     }
179    /// # }
180    /// # impl std::error::Error for ServerError {}
181    /// # struct MyServer;
182    /// impl TestServer for MyServer {
183    ///     type Error = ServerError;
184    ///
185    ///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
186    ///         listener.set_nonblocking(true).map_err(|_| ServerError)?;
187    ///         let tokio_listener = tokio::net::TcpListener::from_std(listener)
188    ///             .map_err(|_| ServerError)?;
189    ///         
190    ///         // Start your server here
191    ///         loop {
192    ///             if let Ok((stream, _)) = tokio_listener.accept().await {
193    ///                 // Handle connection
194    ///             }
195    ///         }
196    ///     }
197    /// }
198    /// ```
199    fn launch(&self, listener: TcpListener)
200    -> impl Future<Output = Result<(), Self::Error>> + Send;
201
202    /// Check if the server is healthy and ready to accept requests.
203    ///
204    /// This method is called periodically during server startup to determine
205    /// when the server is ready. The default implementation returns `HealthStatus::Uncheckable`,
206    /// which triggers a TCP connection test.
207    ///
208    /// # Arguments
209    ///
210    /// * `client` - An ApiClient configured for this test server
211    ///
212    /// # Returns
213    ///
214    /// * `Ok(HealthStatus::Healthy)` - Server is healthy and ready
215    /// * `Ok(HealthStatus::Unhealthy)` - Server is not healthy
216    /// * `Ok(HealthStatus::Uncheckable)` - Use default TCP connection test
217    /// * `Err(Self::Error)` - Error occurred during health check
218    ///
219    /// # Example
220    ///
221    /// ```rust
222    /// # use clawspec_core::{test_client::{TestServer, HealthStatus}, ApiClient};
223    /// # use std::net::TcpListener;
224    /// # #[derive(Debug)] struct ServerError;
225    /// # impl std::fmt::Display for ServerError {
226    /// #     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227    /// #         write!(f, "Server error")
228    /// #     }
229    /// # }
230    /// # impl std::error::Error for ServerError {}
231    /// # struct MyServer;
232    /// impl TestServer for MyServer {
233    ///     # type Error = ServerError;
234    ///     # async fn launch(&self, _listener: TcpListener) -> Result<(), Self::Error> { Ok(()) }
235    ///     async fn is_healthy(&self, client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
236    ///         // Try to make a health check request
237    ///         match client.get("/health").unwrap().await {
238    ///             Ok(_) => Ok(HealthStatus::Healthy),
239    ///             Err(_) => Ok(HealthStatus::Unhealthy),
240    ///         }
241    ///     }
242    /// }
243    /// ```
244    fn is_healthy(
245        &self,
246        _client: &mut ApiClient,
247    ) -> impl Future<Output = Result<HealthStatus, Self::Error>> + Send {
248        std::future::ready(Ok(HealthStatus::Uncheckable))
249    }
250
251    /// Provide configuration for the test framework.
252    ///
253    /// This method allows customizing the ApiClient and health check behavior
254    /// for the specific server implementation.
255    ///
256    /// # Returns
257    ///
258    /// A TestServerConfig with custom settings, or default if not overridden.
259    ///
260    /// # Example
261    ///
262    /// ```rust
263    /// # use clawspec_core::{test_client::{TestServer, TestServerConfig}, ApiClient};
264    /// # use std::{net::TcpListener, time::Duration};
265    /// # struct MyServer;
266    /// impl TestServer for MyServer {
267    ///     # type Error = std::io::Error;
268    ///     # async fn launch(&self, _listener: TcpListener) -> Result<(), Self::Error> { Ok(()) }
269    ///     fn config(&self) -> TestServerConfig {
270    ///         TestServerConfig {
271    ///             api_client: Some(
272    ///                 ApiClient::builder()
273    ///                     .with_host("localhost")
274    ///                     .with_base_path("/api").unwrap()
275    ///             ),
276    ///             min_backoff_delay: Duration::from_millis(10),
277    ///             max_backoff_delay: Duration::from_secs(1),
278    ///             backoff_jitter: true,
279    ///             max_retry_attempts: 10,
280    ///         }
281    ///     }
282    /// }
283    /// ```
284    fn config(&self) -> TestServerConfig {
285        TestServerConfig::default()
286    }
287}
288
289/// Configuration for test server behavior and client setup.
290///
291/// This struct allows customizing how the TestClient interacts with the test server,
292/// including the ApiClient configuration and exponential backoff timing for health checks.
293///
294/// # Fields
295///
296/// * `api_client` - Optional pre-configured ApiClient builder for custom client setup
297/// * `min_backoff_delay` - Minimum delay for exponential backoff between health check retries
298/// * `max_backoff_delay` - Maximum delay for exponential backoff between health check retries
299/// * `backoff_jitter` - Whether to add jitter to exponential backoff delays
300/// * `max_retry_attempts` - Maximum number of health check retry attempts
301///
302/// # Examples
303///
304/// ## Default Configuration
305///
306/// ```rust
307/// use clawspec_core::test_client::TestServerConfig;
308/// use std::time::Duration;
309///
310/// let config = TestServerConfig::default();
311/// assert!(config.api_client.is_none());
312/// assert_eq!(config.min_backoff_delay, Duration::from_millis(10));
313/// assert_eq!(config.max_backoff_delay, Duration::from_secs(1));
314/// assert_eq!(config.backoff_jitter, true);
315/// assert_eq!(config.max_retry_attempts, 10);
316/// ```
317///
318/// ## Custom Configuration
319///
320/// ```rust
321/// use clawspec_core::{test_client::TestServerConfig, ApiClient};
322/// use std::time::Duration;
323///
324/// let config = TestServerConfig {
325///     api_client: Some(
326///         ApiClient::builder()
327///             .with_host("test-server.local")
328///             .with_port(3000)
329///             .with_base_path("/api/v1").unwrap()
330///     ),
331///     min_backoff_delay: Duration::from_millis(50),
332///     max_backoff_delay: Duration::from_secs(5),
333///     backoff_jitter: false,
334///     max_retry_attempts: 3,
335/// };
336/// ```
337///
338/// ## Using with TestServer
339///
340/// ```rust
341/// use clawspec_core::test_client::{TestServer, TestServerConfig};
342/// use std::{net::TcpListener, time::Duration};
343///
344/// #[derive(Debug)]
345/// struct MyTestServer;
346///
347/// impl TestServer for MyTestServer {
348///     type Error = std::io::Error;
349///
350///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
351///         // Server implementation
352///         listener.set_nonblocking(true)?;
353///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
354///         Ok(())
355///     }
356///
357///     fn config(&self) -> TestServerConfig {
358///         TestServerConfig {
359///             api_client: Some(
360///                 clawspec_core::ApiClient::builder()
361///                     .with_host("localhost")
362///                     .with_base_path("/api").unwrap()
363///             ),
364///             min_backoff_delay: Duration::from_millis(25),
365///             max_backoff_delay: Duration::from_secs(2),
366///             backoff_jitter: true,
367///             max_retry_attempts: 15,
368///         }
369///     }
370/// }
371/// ```
372#[derive(Debug, Clone)]
373pub struct TestServerConfig {
374    /// Optional pre-configured ApiClient builder.
375    ///
376    /// If provided, this builder will be used as the base for creating the ApiClient.
377    /// If None, a default builder will be used. The TestClient will automatically
378    /// configure the port based on the bound server address.
379    ///
380    /// # Example
381    ///
382    /// ```rust
383    /// use clawspec_core::{test_client::TestServerConfig, ApiClient};
384    ///
385    /// let config = TestServerConfig {
386    ///     api_client: Some(
387    ///         ApiClient::builder()
388    ///             .with_host("api.example.com")
389    ///             .with_base_path("/v1").unwrap()
390    ///     ),
391    ///     min_backoff_delay: std::time::Duration::from_millis(10),
392    ///     max_backoff_delay: std::time::Duration::from_secs(1),
393    ///     backoff_jitter: true,
394    ///     max_retry_attempts: 10,
395    /// };
396    /// ```
397    pub api_client: Option<ApiClientBuilder>,
398
399    /// Minimum delay for exponential backoff between health check retries.
400    ///
401    /// This is the initial delay used when the server is unhealthy and needs
402    /// to be retried. The delay will increase exponentially up to `max_backoff_delay`.
403    ///
404    /// # Default
405    ///
406    /// 10 milliseconds
407    ///
408    /// # Example
409    ///
410    /// ```rust
411    /// use clawspec_core::test_client::TestServerConfig;
412    /// use std::time::Duration;
413    ///
414    /// let config = TestServerConfig {
415    ///     min_backoff_delay: Duration::from_millis(50), // Start with 50ms
416    ///     ..Default::default()
417    /// };
418    /// ```
419    pub min_backoff_delay: Duration,
420
421    /// Maximum delay for exponential backoff between health check retries.
422    ///
423    /// This is the upper bound for the exponential backoff delay. Once the
424    /// delay reaches this value, it will not increase further.
425    ///
426    /// # Default
427    ///
428    /// 1 second
429    ///
430    /// # Example
431    ///
432    /// ```rust
433    /// use clawspec_core::test_client::TestServerConfig;
434    /// use std::time::Duration;
435    ///
436    /// let config = TestServerConfig {
437    ///     max_backoff_delay: Duration::from_secs(5), // Max 5 seconds
438    ///     ..Default::default()
439    /// };
440    /// ```
441    pub max_backoff_delay: Duration,
442
443    /// Whether to add jitter to the exponential backoff delays.
444    ///
445    /// Jitter adds randomization to retry delays to prevent the "thundering herd"
446    /// problem when multiple clients retry simultaneously. This is generally
447    /// recommended for production use.
448    ///
449    /// # Default
450    ///
451    /// `true` (jitter enabled)
452    ///
453    /// # Example
454    ///
455    /// ```rust
456    /// use clawspec_core::test_client::TestServerConfig;
457    ///
458    /// let config = TestServerConfig {
459    ///     backoff_jitter: false, // Disable jitter for predictable timing
460    ///     ..Default::default()
461    /// };
462    /// ```
463    pub backoff_jitter: bool,
464
465    /// Maximum number of health check retry attempts.
466    ///
467    /// This limits the total number of health check attempts before giving up.
468    /// The health check will stop retrying once this number of attempts is reached,
469    /// preventing infinite loops when a server never becomes healthy.
470    ///
471    /// # Default
472    ///
473    /// 10 attempts
474    ///
475    /// # Example
476    ///
477    /// ```rust
478    /// use clawspec_core::test_client::TestServerConfig;
479    ///
480    /// let config = TestServerConfig {
481    ///     max_retry_attempts: 5, // Only try 5 times before giving up
482    ///     ..Default::default()
483    /// };
484    /// ```
485    pub max_retry_attempts: usize,
486}
487
488impl Default for TestServerConfig {
489    fn default() -> Self {
490        Self {
491            api_client: None,
492            min_backoff_delay: Duration::from_millis(10),
493            max_backoff_delay: Duration::from_secs(1),
494            backoff_jitter: true,
495            max_retry_attempts: 10,
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::ApiClient;
504    use std::net::TcpListener;
505    use std::time::Duration;
506    use tokio::net::TcpListener as TokioTcpListener;
507
508    /// Mock server implementation for testing
509    #[derive(Debug)]
510    struct MockServer {
511        config: TestServerConfig,
512        should_be_healthy: bool,
513    }
514
515    impl MockServer {
516        fn new() -> Self {
517            Self {
518                config: TestServerConfig::default(),
519                should_be_healthy: true,
520            }
521        }
522
523        fn with_health_status(mut self, healthy: bool) -> Self {
524            self.should_be_healthy = healthy;
525            self
526        }
527    }
528
529    impl TestServer for MockServer {
530        type Error = std::io::Error;
531
532        async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
533            // Convert to non-blocking for tokio compatibility
534            listener.set_nonblocking(true)?;
535            let tokio_listener = TokioTcpListener::from_std(listener)?;
536
537            // Simple echo server for testing
538            loop {
539                if let Ok((mut stream, _)) = tokio_listener.accept().await {
540                    tokio::spawn(async move {
541                        // Simple HTTP response
542                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
543                        if let Err(e) =
544                            tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes())
545                                .await
546                        {
547                            eprintln!("Failed to write response: {e}");
548                        }
549                    });
550                }
551            }
552        }
553
554        async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
555            Ok(if self.should_be_healthy {
556                HealthStatus::Healthy
557            } else {
558                HealthStatus::Unhealthy
559            })
560        }
561
562        fn config(&self) -> TestServerConfig {
563            self.config.clone()
564        }
565    }
566
567    #[test]
568    fn test_test_server_config_default() {
569        let config = TestServerConfig::default();
570
571        assert!(config.api_client.is_none());
572        assert_eq!(config.min_backoff_delay, Duration::from_millis(10));
573        assert_eq!(config.max_backoff_delay, Duration::from_secs(1));
574        assert!(config.backoff_jitter);
575        assert_eq!(config.max_retry_attempts, 10);
576    }
577
578    #[test]
579    fn test_test_server_config_custom() {
580        let min_delay = Duration::from_millis(50);
581        let max_delay = Duration::from_secs(5);
582        let client_builder = ApiClient::builder()
583            .with_host("test.example.com")
584            .with_port(8080);
585
586        let config = TestServerConfig {
587            api_client: Some(client_builder),
588            min_backoff_delay: min_delay,
589            max_backoff_delay: max_delay,
590            backoff_jitter: false,
591            max_retry_attempts: 5,
592        };
593
594        assert!(config.api_client.is_some());
595        assert_eq!(config.min_backoff_delay, min_delay);
596        assert_eq!(config.max_backoff_delay, max_delay);
597        assert!(!config.backoff_jitter);
598        assert_eq!(config.max_retry_attempts, 5);
599    }
600
601    #[tokio::test]
602    async fn test_mock_server_health_check_healthy() {
603        let server = MockServer::new().with_health_status(true);
604        let mut client = ApiClient::builder().build().expect("valid client");
605
606        let result = server.is_healthy(&mut client).await;
607        assert!(result.is_ok());
608        assert_eq!(result.unwrap(), HealthStatus::Healthy);
609    }
610
611    #[tokio::test]
612    async fn test_mock_server_health_check_unhealthy() {
613        let server = MockServer::new().with_health_status(false);
614        let mut client = ApiClient::builder().build().expect("valid client");
615
616        let result = server.is_healthy(&mut client).await;
617        assert!(result.is_ok());
618        assert_eq!(result.unwrap(), HealthStatus::Unhealthy);
619    }
620
621    #[test]
622    fn test_default_backoff_configuration() {
623        let config = TestServerConfig::default();
624
625        // Verify the default backoff configuration values
626        assert_eq!(config.min_backoff_delay, Duration::from_millis(10));
627        assert_eq!(config.max_backoff_delay, Duration::from_secs(1));
628        assert!(config.backoff_jitter);
629        assert_eq!(config.max_retry_attempts, 10);
630    }
631
632    #[test]
633    fn test_test_server_trait_bounds() {
634        // Test that MockServer implements the required traits
635        fn assert_test_server<T: TestServer + Send + Sync + 'static>(_: T) {}
636
637        let server = MockServer::new();
638        assert_test_server(server);
639    }
640}