clawspec_core/test_client/
mod.rs

1//! Generic test client framework for async server testing.
2//!
3//! This module provides a generic testing framework that works with any async server
4//! implementation through the [`TestServer`] trait. It allows you to start a server,
5//! make API calls, and generate OpenAPI specifications from your tests.
6//!
7//! # Core Components
8//!
9//! - [`TestClient<T>`]: Generic test client that wraps any server implementing [`TestServer`]
10//! - [`TestServer`]: Trait for server implementations (Axum, Warp, actix-web, etc.)
11//! - [`TestServerConfig`]: Configuration for test behavior
12//! - [`TestAppError`]: Error types for test operations
13//!
14//! # Quick Start
15//!
16//! For a complete working example, see the [axum example](https://github.com/ilaborie/clawspec/tree/main/examples/axum-example).
17//!
18//! ```rust,no_run
19//! use clawspec_core::test_client::{TestClient, TestServer, TestServerConfig};
20//! use std::net::TcpListener;
21//!
22//! #[derive(Debug)]
23//! struct MyTestServer;
24//!
25//! impl TestServer for MyTestServer {
26//!     type Error = std::io::Error;
27//!
28//!     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
29//!         // Start your server here
30//!         listener.set_nonblocking(true)?;
31//!         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
32//!         // Your server startup logic
33//!         Ok(())
34//!     }
35//! }
36//!
37//! #[tokio::test]
38//! async fn test_my_api() -> Result<(), Box<dyn std::error::Error>> {
39//!     let mut test_client = TestClient::start(MyTestServer).await?;
40//!     
41//!     let response = test_client
42//!         .get("/api/users")?
43//!         
44//!         .await?;
45//!     
46//!     // Generate OpenAPI documentation
47//!     test_client.write_openapi("openapi.yml").await?;
48//!     
49//!     Ok(())
50//! }
51//! ```
52
53use std::fs;
54use std::marker::Sync;
55use std::net::{Ipv4Addr, SocketAddr, TcpListener};
56use std::path::Path;
57use std::sync::Arc;
58use std::time::Duration;
59
60use backon::{ExponentialBuilder, Retryable};
61use tracing::{debug, error};
62
63use crate::ApiClient;
64
65mod error;
66pub use self::error::*;
67
68mod test_server;
69pub use self::test_server::*;
70
71/// A generic test client for async server testing.
72///
73/// `TestClient<T>` provides a framework-agnostic way to test web servers by wrapping
74/// any server implementation that implements the [`TestServer`] trait. It manages
75/// server lifecycle, health checking, and provides convenient access to the underlying
76/// [`ApiClient`] for making requests and generating OpenAPI specifications.
77///
78/// # Type Parameters
79///
80/// * `T` - The server type that implements [`TestServer`]
81///
82/// # Features
83///
84/// - **Server Lifecycle Management**: Automatically starts and stops the server
85/// - **Health Checking**: Waits for server to be ready before returning success
86/// - **OpenAPI Generation**: Collects API calls and generates OpenAPI specifications
87/// - **Deref to ApiClient**: Direct access to all [`ApiClient`] methods
88///
89/// # Examples
90///
91/// ## Basic Usage
92///
93/// ```rust,no_run
94/// use clawspec_core::test_client::{TestClient, TestServer};
95/// use std::net::TcpListener;
96///
97/// #[derive(Debug)]
98/// struct MyServer;
99///
100/// impl TestServer for MyServer {
101///     type Error = std::io::Error;
102///
103///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
104///         listener.set_nonblocking(true)?;
105///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
106///         // Start your server here
107///         Ok(())
108///     }
109/// }
110///
111/// #[tokio::test]
112/// async fn test_api() -> Result<(), Box<dyn std::error::Error>> {
113///     let mut client = TestClient::start(MyServer).await?;
114///     
115///     let response = client.get("/users")?.await?.as_raw().await?;
116///     assert_eq!(response.status_code(), http::StatusCode::OK);
117///     
118///     Ok(())
119/// }
120/// ```
121///
122/// ## With Custom Configuration
123///
124/// ```rust,no_run
125/// use clawspec_core::{test_client::{TestClient, TestServer, TestServerConfig}, ApiClient};
126/// use std::{net::TcpListener, time::Duration};
127///
128/// #[derive(Debug)]
129/// struct MyServer;
130///
131/// impl TestServer for MyServer {
132///     type Error = std::io::Error;
133///
134///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
135///         // Server implementation
136///         listener.set_nonblocking(true)?;
137///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
138///         Ok(())
139///     }
140///
141///     fn config(&self) -> TestServerConfig {
142///         TestServerConfig {
143///             api_client: Some(
144///                 ApiClient::builder()
145///                     .with_host("localhost")
146///                     .with_base_path("/api/v1").unwrap()
147///             ),
148///             min_backoff_delay: Duration::from_millis(25),
149///             max_backoff_delay: Duration::from_secs(2),
150///             backoff_jitter: true,
151///             max_retry_attempts: 15,
152///         }
153///     }
154/// }
155///
156/// #[tokio::test]
157/// async fn test_with_config() -> Result<(), Box<dyn std::error::Error>> {
158///     let mut client = TestClient::start(MyServer).await?;
159///     
160///     // Client is already configured with base path /api/v1
161///     let response = client.get("/users")?.await?; // Calls /api/v1/users
162///     
163///     Ok(())
164/// }
165/// ```
166///
167/// ## Generating OpenAPI Documentation
168///
169/// ```rust,no_run
170/// use clawspec_core::test_client::{TestClient, TestServer};
171/// use std::net::TcpListener;
172///
173/// #[derive(Debug)]
174/// struct MyServer;
175///
176/// impl TestServer for MyServer {
177///     type Error = std::io::Error;
178///
179///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
180///         listener.set_nonblocking(true)?;
181///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
182///         // Server implementation
183///         Ok(())
184///     }
185/// }
186///
187/// #[tokio::test]
188/// async fn generate_docs() -> Result<(), Box<dyn std::error::Error>> {
189///     let mut client = TestClient::start(MyServer).await?;
190///     
191///     // Make various API calls
192///     client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
193///     client.post("/users")?.json(&serde_json::json!({"name": "John"}))?.await?.as_json::<serde_json::Value>().await?;
194///     client.get("/users/123")?.await?.as_json::<serde_json::Value>().await?;
195///     
196///     // Generate OpenAPI specification
197///     client.write_openapi("docs/openapi.yml").await?;
198///     
199///     Ok(())
200/// }
201/// ```
202///
203/// # Implementation Details
204///
205/// The `TestClient` uses [`derive_more::Deref`] and [`derive_more::DerefMut`] to provide
206/// transparent access to the underlying [`ApiClient`]. This means you can call any
207/// [`ApiClient`] method directly on the `TestClient`:
208///
209/// ```rust,no_run
210/// # use clawspec_core::test_client::{TestClient, TestServer};
211/// # use std::net::TcpListener;
212/// # #[derive(Debug)] struct MyServer;
213/// # impl TestServer for MyServer {
214/// #   type Error = std::io::Error;
215/// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
216/// #       listener.set_nonblocking(true)?;
217/// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
218/// #       Ok(())
219/// #   }
220/// # }
221/// # #[tokio::test]
222/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
223/// let mut client = TestClient::start(MyServer).await?;
224///
225/// // These are all ApiClient methods available directly on TestClient
226/// let response = client.get("/endpoint")?.await?.as_json::<serde_json::Value>().await?;
227/// let openapi = client.collected_openapi().await;
228/// client.register_schema::<MyType>().await;
229/// # Ok(())
230/// # }
231/// # #[derive(serde::Serialize, utoipa::ToSchema)] struct MyType;
232/// ```
233///
234/// # Lifecycle Management
235///
236/// When a `TestClient` is dropped, it automatically aborts the server task:
237///
238/// ```rust,no_run
239/// # use clawspec_core::test_client::{TestClient, TestServer};
240/// # use std::net::TcpListener;
241/// # #[derive(Debug)] struct MyServer;
242/// # impl TestServer for MyServer {
243/// #   type Error = std::io::Error;
244/// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
245/// #       listener.set_nonblocking(true)?;
246/// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
247/// #       Ok(())
248/// #   }
249/// # }
250/// # #[tokio::test]
251/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
252/// {
253///     let client = TestClient::start(MyServer).await?;
254///     // Server is running
255/// } // Server is automatically stopped when client is dropped
256/// # Ok(())
257/// # }
258/// ```
259#[derive(Debug, derive_more::Deref, derive_more::DerefMut)]
260pub struct TestClient<T> {
261    #[allow(dead_code)]
262    local_addr: SocketAddr,
263    #[deref]
264    #[deref_mut]
265    client: ApiClient,
266    handle: Option<ServerTaskHandle>,
267    #[allow(dead_code)]
268    test_server: Arc<T>,
269}
270
271/// Private wrapper for the server task handle.
272///
273/// This type encapsulates the tokio-specific `JoinHandle` to avoid
274/// exposing runtime-specific types in the public API.
275struct ServerTaskHandle(tokio::task::JoinHandle<()>);
276
277impl ServerTaskHandle {
278    fn new(handle: tokio::task::JoinHandle<()>) -> Self {
279        Self(handle)
280    }
281
282    fn abort(&self) {
283        self.0.abort();
284    }
285
286    #[cfg(test)]
287    fn is_finished(&self) -> bool {
288        self.0.is_finished()
289    }
290}
291
292impl std::fmt::Debug for ServerTaskHandle {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.debug_tuple("ServerTaskHandle")
295            .field(&self.0.id())
296            .finish()
297    }
298}
299
300impl<T> TestClient<T>
301where
302    T: TestServer + Send + Sync + 'static,
303{
304    /// Start a test server and create a TestClient.
305    ///
306    /// This method creates a new TestClient by:
307    /// 1. Binding to a random localhost port
308    /// 2. Starting the server in a background task
309    /// 3. Configuring an ApiClient with the server's address
310    /// 4. Waiting for the server to become healthy
311    ///
312    /// # Arguments
313    ///
314    /// * `test_server` - An implementation of [`TestServer`] to start
315    ///
316    /// # Returns
317    ///
318    /// * `Ok(TestClient<T>)` - A ready-to-use test client
319    /// * `Err(TestAppError)` - If server startup or health check fails
320    ///
321    /// # Errors
322    ///
323    /// This method can fail for several reasons:
324    /// - Port binding failure (system resource issues)
325    /// - Server startup failure (implementation errors)
326    /// - Health check timeout (server not becoming ready)
327    /// - ApiClient configuration errors
328    ///
329    /// # Examples
330    ///
331    /// ## Basic Usage
332    ///
333    /// ```rust,no_run
334    /// use clawspec_core::test_client::{TestClient, TestServer};
335    /// use std::net::TcpListener;
336    ///
337    /// #[derive(Debug)]
338    /// struct MyServer;
339    ///
340    /// impl TestServer for MyServer {
341    ///     type Error = std::io::Error;
342    ///
343    ///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
344    ///         listener.set_nonblocking(true)?;
345    ///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
346    ///         // Start your server
347    ///         Ok(())
348    ///     }
349    /// }
350    ///
351    /// #[tokio::test]
352    /// async fn test_server_start() -> Result<(), Box<dyn std::error::Error>> {
353    ///     let client = TestClient::start(MyServer).await?;
354    ///     // Server is now running and ready for requests
355    ///     Ok(())
356    /// }
357    /// ```
358    ///
359    /// ## With Health Check
360    ///
361    /// ```rust,no_run
362    /// use clawspec_core::{test_client::{TestClient, TestServer}, ApiClient};
363    /// use std::net::TcpListener;
364    ///
365    /// #[derive(Debug)]
366    /// struct MyServer;
367    ///
368    /// impl TestServer for MyServer {
369    ///     type Error = std::io::Error;
370    ///
371    ///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
372    ///         // Server implementation
373    ///         listener.set_nonblocking(true)?;
374    ///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
375    ///         Ok(())
376    ///     }
377    ///
378    ///     async fn is_healthy(&self, client: &mut ApiClient) -> Result<clawspec_core::test_client::HealthStatus, Self::Error> {
379    ///         // Custom health check
380    ///         match client.get("/health").unwrap().await {
381    ///             Ok(_) => Ok(clawspec_core::test_client::HealthStatus::Healthy),
382    ///             Err(_) => Ok(clawspec_core::test_client::HealthStatus::Unhealthy),
383    ///         }
384    ///     }
385    /// }
386    ///
387    /// #[tokio::test]
388    /// async fn test_with_health_check() -> Result<(), Box<dyn std::error::Error>> {
389    ///     let client = TestClient::start(MyServer).await?;
390    ///     // Server is guaranteed to be healthy
391    ///     Ok(())
392    /// }
393    /// ```
394    pub async fn start(test_server: T) -> Result<Self, TestAppError> {
395        let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
396        let listener = TcpListener::bind(addr)?;
397        let local_addr = listener.local_addr()?;
398
399        let test_server = Arc::new(test_server);
400        let handle = tokio::spawn({
401            let server = Arc::clone(&test_server);
402            async move {
403                if let Err(error) = server.launch(listener).await {
404                    error!(?error, "Server launch failed");
405                }
406            }
407        });
408
409        let TestServerConfig {
410            api_client,
411            min_backoff_delay,
412            max_backoff_delay,
413            backoff_jitter,
414            max_retry_attempts,
415        } = test_server.config();
416
417        // Build client with comprehensive OpenAPI metadata
418        let client = api_client.unwrap_or_else(ApiClient::builder);
419        let client = client.with_port(local_addr.port()).build()?;
420
421        // Wait until ready with exponential backoff
422        let healthy = Self::wait_for_health(
423            &test_server,
424            &client,
425            local_addr,
426            min_backoff_delay,
427            max_backoff_delay,
428            backoff_jitter,
429            max_retry_attempts,
430        )
431        .await;
432
433        if !healthy {
434            return Err(TestAppError::UnhealthyServer {
435                timeout: max_backoff_delay,
436            });
437        }
438
439        let result = Self {
440            local_addr,
441            client,
442            handle: Some(ServerTaskHandle::new(handle)),
443            test_server,
444        };
445        Ok(result)
446    }
447
448    /// Wait for the server to become healthy using exponential backoff.
449    ///
450    /// This method implements a retry mechanism with exponential backoff to check
451    /// if the server is healthy. It handles different health status responses:
452    /// - `Healthy`: Server is ready, returns success
453    /// - `Unhealthy`: Server not ready, retries with exponential backoff
454    /// - `Uncheckable`: Falls back to TCP connection test
455    /// - Error: Returns failure immediately
456    ///
457    /// # Arguments
458    ///
459    /// * `test_server` - The server implementation to check
460    /// * `client` - ApiClient configured for the server
461    /// * `local_addr` - Server address for TCP connection fallback
462    /// * `min_backoff_delay` - Minimum delay for exponential backoff
463    /// * `max_backoff_delay` - Maximum delay for exponential backoff
464    /// * `backoff_jitter` - Whether to add jitter to backoff delays
465    /// * `max_retry_attempts` - Maximum number of retry attempts before giving up
466    ///
467    /// # Returns
468    ///
469    /// * `true` - Server is healthy and ready
470    /// * `false` - Server failed health checks or encountered errors
471    async fn wait_for_health(
472        test_server: &Arc<T>,
473        client: &ApiClient,
474        local_addr: SocketAddr,
475        min_backoff_delay: Duration,
476        max_backoff_delay: Duration,
477        backoff_jitter: bool,
478        max_retry_attempts: usize,
479    ) -> bool {
480        // Configure exponential backoff with provided settings
481        let mut backoff_builder = ExponentialBuilder::default()
482            .with_min_delay(min_backoff_delay)
483            .with_max_delay(max_backoff_delay)
484            .with_max_times(max_retry_attempts); // Limit total retry attempts to prevent infinite loops
485
486        if backoff_jitter {
487            backoff_builder = backoff_builder.with_jitter();
488        }
489
490        let backoff = backoff_builder;
491
492        let health_check = || {
493            let mut client = client.clone();
494            let server = Arc::clone(test_server);
495            async move {
496                let result = server.is_healthy(&mut client).await;
497                match result {
498                    Ok(HealthStatus::Healthy) => {
499                        debug!("🟒 server healthy");
500                        Ok(true)
501                    }
502                    Ok(HealthStatus::Unhealthy) => {
503                        debug!("🟠 server not yet healthy, retrying with exponential backoff");
504                        Err(std::io::Error::new(
505                            std::io::ErrorKind::ConnectionRefused,
506                            "Server not healthy yet",
507                        ))
508                    }
509                    Ok(HealthStatus::Uncheckable) => {
510                        debug!("❓wait until a connection can be establish with the server");
511                        let connection = tokio::net::TcpStream::connect(local_addr).await;
512                        if let Err(err) = &connection {
513                            error!(?err, %local_addr, "Oops, fail to establish connection");
514                        }
515                        Ok(connection.is_ok())
516                    }
517                    Err(error) => {
518                        error!(?error, "Health check error");
519                        Ok(false)
520                    }
521                }
522            }
523        };
524
525        health_check.retry(&backoff).await.unwrap_or(false)
526    }
527
528    /// Write the collected OpenAPI specification to a file.
529    ///
530    /// This method generates an OpenAPI specification from all the API calls made
531    /// through this TestClient and writes it to the specified file. The format
532    /// (JSON or YAML) is determined by the file extension.
533    ///
534    /// # Arguments
535    ///
536    /// * `path` - The file path where the OpenAPI specification should be written.
537    ///   File extension determines format:
538    ///   - `.yml` or `.yaml` β†’ YAML format
539    ///   - All others β†’ JSON format
540    ///
541    /// # Returns
542    ///
543    /// * `Ok(())` - File was written successfully
544    /// * `Err(TestAppError)` - If file operations or serialization fails
545    ///
546    /// # Errors
547    ///
548    /// This method can fail if:
549    /// - Parent directories don't exist and can't be created
550    /// - File can't be written (permissions, disk space, etc.)
551    /// - OpenAPI serialization fails (YAML or JSON)
552    ///
553    /// # File Format Detection
554    ///
555    /// The output format is automatically determined by file extension:
556    /// - `openapi.yml` β†’ YAML format
557    /// - `openapi.yaml` β†’ YAML format  
558    /// - `openapi.json` β†’ JSON format
559    /// - `spec.txt` β†’ JSON format (default for unknown extensions)
560    ///
561    /// # Examples
562    ///
563    /// ## Basic Usage
564    ///
565    /// ```rust,no_run
566    /// use clawspec_core::test_client::{TestClient, TestServer};
567    /// use std::net::TcpListener;
568    ///
569    /// #[derive(Debug)]
570    /// struct MyServer;
571    ///
572    /// impl TestServer for MyServer {
573    ///     type Error = std::io::Error;
574    ///
575    ///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
576    ///         listener.set_nonblocking(true)?;
577    ///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
578    ///         // Server implementation
579    ///         Ok(())
580    ///     }
581    /// }
582    ///
583    /// #[tokio::test]
584    /// async fn generate_openapi() -> Result<(), Box<dyn std::error::Error>> {
585    ///     let mut client = TestClient::start(MyServer).await?;
586    ///     
587    ///     // Make some API calls
588    ///     client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
589    ///     client.post("/users")?.json(&serde_json::json!({"name": "John"}))?.await?.as_json::<serde_json::Value>().await?;
590    ///     
591    ///     // Generate YAML specification
592    ///     client.write_openapi("openapi.yml").await?;
593    ///     
594    ///     Ok(())
595    /// }
596    /// ```
597    ///
598    /// ## Different Formats
599    ///
600    /// ```rust,no_run
601    /// # use clawspec_core::test_client::{TestClient, TestServer};
602    /// # use std::net::TcpListener;
603    /// # #[derive(Debug)] struct MyServer;
604    /// # impl TestServer for MyServer {
605    /// #   type Error = std::io::Error;
606    /// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
607    /// #       listener.set_nonblocking(true)?;
608    /// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
609    /// #       Ok(())
610    /// #   }
611    /// # }
612    /// # #[tokio::test]
613    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
614    /// let mut client = TestClient::start(MyServer).await?;
615    ///
616    /// // Make API calls
617    /// client.get("/api/health")?.await?.as_json::<serde_json::Value>().await?;
618    ///
619    /// // Write in different formats
620    /// client.write_openapi("docs/openapi.yaml").await?;  // YAML format
621    /// client.write_openapi("docs/openapi.json").await?;  // JSON format
622    /// client.write_openapi("docs/spec.txt").await?;      // JSON format (default)
623    /// # Ok(())
624    /// # }
625    /// ```
626    ///
627    /// ## Creating Parent Directories
628    ///
629    /// The method automatically creates parent directories if they don't exist:
630    ///
631    /// ```rust,no_run
632    /// # use clawspec_core::test_client::{TestClient, TestServer};
633    /// # use std::net::TcpListener;
634    /// # #[derive(Debug)] struct MyServer;
635    /// # impl TestServer for MyServer {
636    /// #   type Error = std::io::Error;
637    /// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
638    /// #       listener.set_nonblocking(true)?;
639    /// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
640    /// #       Ok(())
641    /// #   }
642    /// # }
643    /// # #[tokio::test]
644    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
645    /// let client = TestClient::start(MyServer).await?;
646    ///
647    /// // This will create the docs/api/v1/ directory structure if it doesn't exist
648    /// client.write_openapi("docs/api/v1/openapi.yml").await?;
649    /// # Ok(())
650    /// # }
651    /// ```
652    ///
653    /// # Generated OpenAPI Structure
654    ///
655    /// The generated OpenAPI specification includes:
656    /// - All API endpoints called through the client
657    /// - Request and response schemas for structured data
658    /// - Parameter definitions (path, query, headers)
659    /// - Status codes and error responses
660    /// - Server information and metadata
661    ///
662    /// The specification follows OpenAPI 3.1 format and can be used with various
663    /// tools for documentation generation, client generation, and API validation.
664    pub async fn write_openapi(mut self, path: impl AsRef<Path>) -> Result<(), TestAppError> {
665        let path = path.as_ref();
666        if let Some(parent) = path.parent() {
667            fs::create_dir_all(parent)?;
668        }
669
670        let openapi = self.client.collected_openapi().await;
671
672        let ext = path.extension().unwrap_or_default();
673        let contents = if ext == "yml" || ext == "yaml" {
674            openapi.to_yaml().map_err(|err| TestAppError::YamlError {
675                error: format!("{err:#?}"),
676            })?
677        } else {
678            serde_json::to_string_pretty(&openapi)?
679        };
680
681        fs::write(path, contents)?;
682
683        Ok(())
684    }
685}
686
687/// Automatic cleanup when TestClient is dropped.
688///
689/// This implementation ensures that the background server task is properly
690/// terminated when the TestClient goes out of scope, preventing resource leaks.
691impl<T> Drop for TestClient<T> {
692    /// Abort the background server task when the TestClient is dropped.
693    ///
694    /// This method is called automatically when the TestClient goes out of scope.
695    /// It ensures that the server task is cleanly terminated, preventing the
696    /// server from continuing to run after the test is complete.
697    ///
698    /// # Example
699    ///
700    /// ```rust,no_run
701    /// # use clawspec_core::test_client::{TestClient, TestServer};
702    /// # use std::net::TcpListener;
703    /// # #[derive(Debug)] struct MyServer;
704    /// # impl TestServer for MyServer {
705    /// #   type Error = std::io::Error;
706    /// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
707    /// #       listener.set_nonblocking(true)?;
708    /// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
709    /// #       Ok(())
710    /// #   }
711    /// # }
712    /// # #[tokio::test]
713    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
714    /// {
715    ///     let client = TestClient::start(MyServer).await?;
716    ///     // Use the client for testing
717    ///     client.get("/api/test")?.await?.as_json::<serde_json::Value>().await?;
718    /// } // <- TestClient is dropped here, server task is automatically aborted
719    ///   
720    /// // Server is no longer running
721    /// # Ok(())
722    /// # }
723    /// ```
724    fn drop(&mut self) {
725        if let Some(handle) = self.handle.take() {
726            handle.abort();
727        }
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use crate::ApiClient;
735    use std::net::{Ipv4Addr, TcpListener};
736    use std::sync::Arc;
737    use std::sync::atomic::{AtomicBool, Ordering};
738    use std::time::Duration;
739    use tokio::net::TcpListener as TokioTcpListener;
740
741    /// Mock server for testing TestClient functionality
742    #[derive(Debug)]
743    struct MockTestServer {
744        should_be_healthy: Arc<AtomicBool>,
745        startup_delay: Duration,
746        custom_config: Option<TestServerConfig>,
747    }
748
749    impl MockTestServer {
750        fn new() -> Self {
751            Self {
752                should_be_healthy: Arc::new(AtomicBool::new(true)),
753                startup_delay: Duration::from_millis(10),
754                custom_config: None,
755            }
756        }
757
758        fn with_health_status(self, healthy: bool) -> Self {
759            self.should_be_healthy.store(healthy, Ordering::Relaxed);
760            self
761        }
762
763        fn with_startup_delay(mut self, delay: Duration) -> Self {
764            self.startup_delay = delay;
765            self
766        }
767
768        fn with_config(mut self, config: TestServerConfig) -> Self {
769            self.custom_config = Some(config);
770            self
771        }
772    }
773
774    impl TestServer for MockTestServer {
775        type Error = std::io::Error;
776
777        async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
778            // Simulate startup delay
779            if !self.startup_delay.is_zero() {
780                tokio::time::sleep(self.startup_delay).await;
781            }
782
783            // Convert to non-blocking for tokio compatibility
784            listener.set_nonblocking(true)?;
785            let tokio_listener = TokioTcpListener::from_std(listener)?;
786
787            // Simple HTTP server that responds to health checks
788            loop {
789                if let Ok((mut stream, _)) = tokio_listener.accept().await {
790                    tokio::spawn(async move {
791                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
792                        let _ =
793                            tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes())
794                                .await;
795                        let _ = tokio::io::AsyncWriteExt::shutdown(&mut stream).await;
796                    });
797                }
798            }
799        }
800
801        async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
802            Ok(if self.should_be_healthy.load(Ordering::Relaxed) {
803                HealthStatus::Healthy
804            } else {
805                HealthStatus::Unhealthy
806            })
807        }
808
809        fn config(&self) -> TestServerConfig {
810            self.custom_config.clone().unwrap_or_default()
811        }
812    }
813
814    #[tokio::test]
815    async fn test_test_client_start_success() {
816        let server = MockTestServer::new();
817
818        let result = TestClient::start(server).await;
819        assert!(result.is_ok());
820
821        let test_client = result.unwrap();
822        assert!(test_client.handle.is_some());
823
824        // Test that the server is running by checking the local address
825        let addr = test_client.local_addr;
826        assert_eq!(addr.ip(), Ipv4Addr::LOCALHOST);
827        assert_ne!(addr.port(), 0); // Should have been assigned a port
828    }
829
830    #[tokio::test]
831    async fn test_test_client_start_with_custom_config() {
832        let min_delay = Duration::from_millis(5);
833        let max_delay = Duration::from_millis(100);
834        let client_builder = ApiClient::builder()
835            .with_host("test.example.com")
836            .with_port(8080);
837
838        let config = TestServerConfig {
839            api_client: Some(client_builder),
840            min_backoff_delay: min_delay,
841            max_backoff_delay: max_delay,
842            backoff_jitter: false,
843            max_retry_attempts: 5,
844        };
845
846        let server = MockTestServer::new().with_config(config);
847        let result = TestClient::start(server).await;
848
849        assert!(result.is_ok());
850        let test_client = result.unwrap();
851        assert!(test_client.handle.is_some());
852    }
853
854    #[tokio::test]
855    async fn test_test_client_start_unhealthy_server() {
856        let expected_max_delay = Duration::from_millis(50);
857        let config = TestServerConfig {
858            api_client: None,
859            min_backoff_delay: Duration::from_millis(5),
860            max_backoff_delay: expected_max_delay,
861            backoff_jitter: false,
862            max_retry_attempts: 3,
863        };
864        let server = MockTestServer::new()
865            .with_health_status(false)
866            .with_config(config);
867
868        let result = TestClient::start(server).await;
869        assert!(result.is_err());
870
871        match result.unwrap_err() {
872            TestAppError::UnhealthyServer {
873                timeout: actual_timeout,
874            } => {
875                assert_eq!(actual_timeout, expected_max_delay);
876            }
877            other => panic!("Expected UnhealthyServer error, got: {other:?}"),
878        }
879    }
880
881    #[tokio::test]
882    async fn test_test_client_start_slow_server() {
883        let server = MockTestServer::new().with_startup_delay(Duration::from_millis(50));
884
885        let result = TestClient::start(server).await;
886        assert!(result.is_ok());
887    }
888
889    #[tokio::test]
890    async fn test_test_client_deref_to_api_client() {
891        let server = MockTestServer::new();
892        let mut test_client = TestClient::start(server)
893            .await
894            .expect("client should start");
895
896        // Test that we can access ApiClient methods through deref
897        let openapi = test_client.collected_openapi().await;
898        assert_eq!(openapi.info.title, ""); // Default title
899    }
900
901    #[tokio::test]
902    async fn test_test_client_deref_mut_to_api_client() {
903        let server = MockTestServer::new();
904        let test_client = TestClient::start(server)
905            .await
906            .expect("client should start");
907
908        // Test that we can mutably access ApiClient methods through deref_mut
909        let result = test_client.get("/test");
910        assert!(result.is_ok());
911    }
912
913    #[tokio::test]
914    async fn test_test_client_write_openapi_json() {
915        let server = MockTestServer::new();
916        let test_client = TestClient::start(server)
917            .await
918            .expect("client should start");
919
920        let temp_file = "/tmp/test_openapi.json";
921        let result = test_client.write_openapi(temp_file).await;
922
923        assert!(result.is_ok());
924
925        // Verify file was created and contains valid JSON
926        let content = std::fs::read_to_string(temp_file).expect("file should exist");
927        let json: serde_json::Value = serde_json::from_str(&content).expect("should be valid JSON");
928
929        assert!(json.get("openapi").is_some());
930        assert!(json.get("info").is_some());
931
932        // Cleanup
933        let _ = std::fs::remove_file(temp_file);
934    }
935
936    #[tokio::test]
937    async fn test_test_client_write_openapi_yaml() {
938        let server = MockTestServer::new();
939        let test_client = TestClient::start(server)
940            .await
941            .expect("client should start");
942
943        let temp_file = "/tmp/test_openapi.yml";
944        let result = test_client.write_openapi(temp_file).await;
945
946        assert!(result.is_ok());
947
948        // Verify file was created and contains valid YAML
949        let content = std::fs::read_to_string(temp_file).expect("file should exist");
950        let yaml: serde_json::Value =
951            serde_saphyr::from_str(&content).expect("should be valid YAML");
952
953        assert!(yaml.get("openapi").is_some());
954        assert!(yaml.get("info").is_some());
955
956        // Cleanup
957        let _ = std::fs::remove_file(temp_file);
958    }
959
960    #[tokio::test]
961    async fn test_test_client_write_openapi_creates_parent_dirs() {
962        let server = MockTestServer::new();
963        let test_client = TestClient::start(server)
964            .await
965            .expect("client should start");
966
967        let temp_dir = "/tmp/test_clawspec_dir/subdir";
968        let temp_file = format!("{temp_dir}/openapi.json");
969
970        let result = test_client.write_openapi(&temp_file).await;
971        assert!(result.is_ok());
972
973        // Verify file and directories were created
974        assert!(std::fs::metadata(&temp_file).is_ok());
975
976        // Cleanup
977        let _ = std::fs::remove_dir_all("/tmp/test_clawspec_dir");
978    }
979
980    #[tokio::test]
981    async fn test_test_client_drop_aborts_handle() {
982        let server = MockTestServer::new();
983        let test_client = TestClient::start(server)
984            .await
985            .expect("client should start");
986
987        let handle = test_client.handle.as_ref().unwrap();
988        assert!(!handle.is_finished());
989
990        // Drop the test client
991        drop(test_client);
992
993        // Give a moment for the handle to be aborted
994        tokio::time::sleep(Duration::from_millis(10)).await;
995        // Note: We can't easily test that the handle was aborted since we dropped test_client
996        // But the Drop implementation should call abort()
997    }
998
999    #[test]
1000    fn test_test_client_debug_trait() {
1001        // Test that TestClient implements Debug (compile-time check)
1002        let server = MockTestServer::new();
1003        // We can't easily create a TestClient in a sync test, but we can verify the trait bounds
1004        fn assert_debug<T: std::fmt::Debug>(_: &T) {}
1005        assert_debug(&server);
1006    }
1007
1008    #[test]
1009    fn test_test_client_trait_bounds() {
1010        // Verify that TestClient has the expected trait bounds
1011        #[allow(dead_code)]
1012        fn assert_bounds<T>(_: TestClient<T>)
1013        where
1014            T: TestServer + Send + Sync + 'static,
1015        {
1016            // TestClient should implement Deref and DerefMut to ApiClient
1017        }
1018
1019        // This is a compile-time check
1020    }
1021
1022    /// Mock server that simulates different error conditions
1023    #[derive(Debug)]
1024    struct ErrorTestServer {
1025        error_type: ErrorType,
1026    }
1027
1028    #[derive(Debug)]
1029    enum ErrorType {
1030        #[allow(dead_code)]
1031        BindFailure,
1032        HealthTimeout,
1033    }
1034
1035    impl ErrorTestServer {
1036        #[allow(dead_code)]
1037        fn bind_failure() -> Self {
1038            Self {
1039                error_type: ErrorType::BindFailure,
1040            }
1041        }
1042
1043        fn health_timeout() -> Self {
1044            Self {
1045                error_type: ErrorType::HealthTimeout,
1046            }
1047        }
1048    }
1049
1050    impl TestServer for ErrorTestServer {
1051        type Error = std::io::Error;
1052
1053        async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
1054            match self.error_type {
1055                ErrorType::BindFailure => {
1056                    // Simulate bind failure by returning an error
1057                    Err(std::io::Error::new(
1058                        std::io::ErrorKind::AddrInUse,
1059                        "Simulated bind failure",
1060                    ))
1061                }
1062                ErrorType::HealthTimeout => {
1063                    // Start normally but never become healthy
1064                    listener.set_nonblocking(true)?;
1065                    let _tokio_listener = TokioTcpListener::from_std(listener)?;
1066
1067                    // Just keep running without accepting connections
1068                    loop {
1069                        tokio::time::sleep(Duration::from_secs(1)).await;
1070                    }
1071                }
1072            }
1073        }
1074
1075        async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
1076            match self.error_type {
1077                ErrorType::BindFailure => Ok(HealthStatus::Unhealthy),
1078                ErrorType::HealthTimeout => {
1079                    // Always return unhealthy, which will cause the exponential backoff to timeout
1080                    Ok(HealthStatus::Unhealthy)
1081                }
1082            }
1083        }
1084
1085        fn config(&self) -> TestServerConfig {
1086            TestServerConfig {
1087                api_client: None,
1088                min_backoff_delay: Duration::from_millis(1), // Very fast for testing
1089                max_backoff_delay: Duration::from_millis(10), // Short max delay for testing
1090                backoff_jitter: false,                       // Predictable timing for tests
1091                max_retry_attempts: 3,                       // Quick timeout for tests
1092            }
1093        }
1094    }
1095
1096    #[tokio::test]
1097    async fn test_test_client_start_health_timeout() {
1098        let server = ErrorTestServer::health_timeout();
1099
1100        let result = TestClient::start(server).await;
1101        assert!(result.is_err());
1102
1103        match result.unwrap_err() {
1104            TestAppError::UnhealthyServer { timeout } => {
1105                // The timeout should be the max_backoff_delay from the ErrorTestServer config
1106                assert_eq!(timeout, Duration::from_millis(10));
1107            }
1108            other => panic!("Expected UnhealthyServer error, got: {other:?}"),
1109        }
1110    }
1111}