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<tokio::task::JoinHandle<()>>,
267    #[allow(dead_code)]
268    test_server: Arc<T>,
269}
270
271impl<T> TestClient<T>
272where
273    T: TestServer + Send + Sync + 'static,
274{
275    /// Start a test server and create a TestClient.
276    ///
277    /// This method creates a new TestClient by:
278    /// 1. Binding to a random localhost port
279    /// 2. Starting the server in a background task
280    /// 3. Configuring an ApiClient with the server's address
281    /// 4. Waiting for the server to become healthy
282    ///
283    /// # Arguments
284    ///
285    /// * `test_server` - An implementation of [`TestServer`] to start
286    ///
287    /// # Returns
288    ///
289    /// * `Ok(TestClient<T>)` - A ready-to-use test client
290    /// * `Err(TestAppError)` - If server startup or health check fails
291    ///
292    /// # Errors
293    ///
294    /// This method can fail for several reasons:
295    /// - Port binding failure (system resource issues)
296    /// - Server startup failure (implementation errors)
297    /// - Health check timeout (server not becoming ready)
298    /// - ApiClient configuration errors
299    ///
300    /// # Examples
301    ///
302    /// ## Basic Usage
303    ///
304    /// ```rust,no_run
305    /// use clawspec_core::test_client::{TestClient, TestServer};
306    /// use std::net::TcpListener;
307    ///
308    /// #[derive(Debug)]
309    /// struct MyServer;
310    ///
311    /// impl TestServer for MyServer {
312    ///     type Error = std::io::Error;
313    ///
314    ///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
315    ///         listener.set_nonblocking(true)?;
316    ///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
317    ///         // Start your server
318    ///         Ok(())
319    ///     }
320    /// }
321    ///
322    /// #[tokio::test]
323    /// async fn test_server_start() -> Result<(), Box<dyn std::error::Error>> {
324    ///     let client = TestClient::start(MyServer).await?;
325    ///     // Server is now running and ready for requests
326    ///     Ok(())
327    /// }
328    /// ```
329    ///
330    /// ## With Health Check
331    ///
332    /// ```rust,no_run
333    /// use clawspec_core::{test_client::{TestClient, TestServer}, ApiClient};
334    /// use std::net::TcpListener;
335    ///
336    /// #[derive(Debug)]
337    /// struct MyServer;
338    ///
339    /// impl TestServer for MyServer {
340    ///     type Error = std::io::Error;
341    ///
342    ///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
343    ///         // Server implementation
344    ///         listener.set_nonblocking(true)?;
345    ///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
346    ///         Ok(())
347    ///     }
348    ///
349    ///     async fn is_healthy(&self, client: &mut ApiClient) -> Result<clawspec_core::test_client::HealthStatus, Self::Error> {
350    ///         // Custom health check
351    ///         match client.get("/health").unwrap().await {
352    ///             Ok(_) => Ok(clawspec_core::test_client::HealthStatus::Healthy),
353    ///             Err(_) => Ok(clawspec_core::test_client::HealthStatus::Unhealthy),
354    ///         }
355    ///     }
356    /// }
357    ///
358    /// #[tokio::test]
359    /// async fn test_with_health_check() -> Result<(), Box<dyn std::error::Error>> {
360    ///     let client = TestClient::start(MyServer).await?;
361    ///     // Server is guaranteed to be healthy
362    ///     Ok(())
363    /// }
364    /// ```
365    pub async fn start(test_server: T) -> Result<Self, TestAppError> {
366        let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
367        let listener = TcpListener::bind(addr)?;
368        let local_addr = listener.local_addr()?;
369
370        let test_server = Arc::new(test_server);
371        let handle = tokio::spawn({
372            let server = Arc::clone(&test_server);
373            async move {
374                if let Err(error) = server.launch(listener).await {
375                    error!(?error, "Server launch failed");
376                }
377            }
378        });
379
380        let TestServerConfig {
381            api_client,
382            min_backoff_delay,
383            max_backoff_delay,
384            backoff_jitter,
385            max_retry_attempts,
386        } = test_server.config();
387
388        // Build client with comprehensive OpenAPI metadata
389        let client = api_client.unwrap_or_else(ApiClient::builder);
390        let client = client.with_port(local_addr.port()).build()?;
391
392        // Wait until ready with exponential backoff
393        let healthy = Self::wait_for_health(
394            &test_server,
395            &client,
396            local_addr,
397            min_backoff_delay,
398            max_backoff_delay,
399            backoff_jitter,
400            max_retry_attempts,
401        )
402        .await;
403
404        if !healthy {
405            return Err(TestAppError::UnhealthyServer {
406                timeout: max_backoff_delay,
407            });
408        }
409
410        let result = Self {
411            local_addr,
412            client,
413            handle: Some(handle),
414            test_server,
415        };
416        Ok(result)
417    }
418
419    /// Wait for the server to become healthy using exponential backoff.
420    ///
421    /// This method implements a retry mechanism with exponential backoff to check
422    /// if the server is healthy. It handles different health status responses:
423    /// - `Healthy`: Server is ready, returns success
424    /// - `Unhealthy`: Server not ready, retries with exponential backoff
425    /// - `Uncheckable`: Falls back to TCP connection test
426    /// - Error: Returns failure immediately
427    ///
428    /// # Arguments
429    ///
430    /// * `test_server` - The server implementation to check
431    /// * `client` - ApiClient configured for the server
432    /// * `local_addr` - Server address for TCP connection fallback
433    /// * `min_backoff_delay` - Minimum delay for exponential backoff
434    /// * `max_backoff_delay` - Maximum delay for exponential backoff
435    /// * `backoff_jitter` - Whether to add jitter to backoff delays
436    /// * `max_retry_attempts` - Maximum number of retry attempts before giving up
437    ///
438    /// # Returns
439    ///
440    /// * `true` - Server is healthy and ready
441    /// * `false` - Server failed health checks or encountered errors
442    async fn wait_for_health(
443        test_server: &Arc<T>,
444        client: &ApiClient,
445        local_addr: SocketAddr,
446        min_backoff_delay: Duration,
447        max_backoff_delay: Duration,
448        backoff_jitter: bool,
449        max_retry_attempts: usize,
450    ) -> bool {
451        // Configure exponential backoff with provided settings
452        let mut backoff_builder = ExponentialBuilder::default()
453            .with_min_delay(min_backoff_delay)
454            .with_max_delay(max_backoff_delay)
455            .with_max_times(max_retry_attempts); // Limit total retry attempts to prevent infinite loops
456
457        if backoff_jitter {
458            backoff_builder = backoff_builder.with_jitter();
459        }
460
461        let backoff = backoff_builder;
462
463        let health_check = || {
464            let mut client = client.clone();
465            let server = Arc::clone(test_server);
466            async move {
467                let result = server.is_healthy(&mut client).await;
468                match result {
469                    Ok(HealthStatus::Healthy) => {
470                        debug!("🟒 server healthy");
471                        Ok(true)
472                    }
473                    Ok(HealthStatus::Unhealthy) => {
474                        debug!("🟠 server not yet healthy, retrying with exponential backoff");
475                        Err(std::io::Error::new(
476                            std::io::ErrorKind::ConnectionRefused,
477                            "Server not healthy yet",
478                        ))
479                    }
480                    Ok(HealthStatus::Uncheckable) => {
481                        debug!("❓wait until a connection can be establish with the server");
482                        let connection = tokio::net::TcpStream::connect(local_addr).await;
483                        if let Err(err) = &connection {
484                            error!(?err, %local_addr, "Oops, fail to establish connection");
485                        }
486                        Ok(connection.is_ok())
487                    }
488                    Err(error) => {
489                        error!(?error, "Health check error");
490                        Ok(false)
491                    }
492                }
493            }
494        };
495
496        health_check.retry(&backoff).await.unwrap_or(false)
497    }
498
499    /// Write the collected OpenAPI specification to a file.
500    ///
501    /// This method generates an OpenAPI specification from all the API calls made
502    /// through this TestClient and writes it to the specified file. The format
503    /// (JSON or YAML) is determined by the file extension.
504    ///
505    /// # Arguments
506    ///
507    /// * `path` - The file path where the OpenAPI specification should be written.
508    ///   File extension determines format:
509    ///   - `.yml` or `.yaml` β†’ YAML format
510    ///   - All others β†’ JSON format
511    ///
512    /// # Returns
513    ///
514    /// * `Ok(())` - File was written successfully
515    /// * `Err(TestAppError)` - If file operations or serialization fails
516    ///
517    /// # Errors
518    ///
519    /// This method can fail if:
520    /// - Parent directories don't exist and can't be created
521    /// - File can't be written (permissions, disk space, etc.)
522    /// - OpenAPI serialization fails (YAML or JSON)
523    ///
524    /// # File Format Detection
525    ///
526    /// The output format is automatically determined by file extension:
527    /// - `openapi.yml` β†’ YAML format
528    /// - `openapi.yaml` β†’ YAML format  
529    /// - `openapi.json` β†’ JSON format
530    /// - `spec.txt` β†’ JSON format (default for unknown extensions)
531    ///
532    /// # Examples
533    ///
534    /// ## Basic Usage
535    ///
536    /// ```rust,no_run
537    /// use clawspec_core::test_client::{TestClient, TestServer};
538    /// use std::net::TcpListener;
539    ///
540    /// #[derive(Debug)]
541    /// struct MyServer;
542    ///
543    /// impl TestServer for MyServer {
544    ///     type Error = std::io::Error;
545    ///
546    ///     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
547    ///         listener.set_nonblocking(true)?;
548    ///         let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
549    ///         // Server implementation
550    ///         Ok(())
551    ///     }
552    /// }
553    ///
554    /// #[tokio::test]
555    /// async fn generate_openapi() -> Result<(), Box<dyn std::error::Error>> {
556    ///     let mut client = TestClient::start(MyServer).await?;
557    ///     
558    ///     // Make some API calls
559    ///     client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
560    ///     client.post("/users")?.json(&serde_json::json!({"name": "John"}))?.await?.as_json::<serde_json::Value>().await?;
561    ///     
562    ///     // Generate YAML specification
563    ///     client.write_openapi("openapi.yml").await?;
564    ///     
565    ///     Ok(())
566    /// }
567    /// ```
568    ///
569    /// ## Different Formats
570    ///
571    /// ```rust,no_run
572    /// # use clawspec_core::test_client::{TestClient, TestServer};
573    /// # use std::net::TcpListener;
574    /// # #[derive(Debug)] struct MyServer;
575    /// # impl TestServer for MyServer {
576    /// #   type Error = std::io::Error;
577    /// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
578    /// #       listener.set_nonblocking(true)?;
579    /// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
580    /// #       Ok(())
581    /// #   }
582    /// # }
583    /// # #[tokio::test]
584    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
585    /// let mut client = TestClient::start(MyServer).await?;
586    ///
587    /// // Make API calls
588    /// client.get("/api/health")?.await?.as_json::<serde_json::Value>().await?;
589    ///
590    /// // Write in different formats
591    /// client.write_openapi("docs/openapi.yaml").await?;  // YAML format
592    /// client.write_openapi("docs/openapi.json").await?;  // JSON format
593    /// client.write_openapi("docs/spec.txt").await?;      // JSON format (default)
594    /// # Ok(())
595    /// # }
596    /// ```
597    ///
598    /// ## Creating Parent Directories
599    ///
600    /// The method automatically creates parent directories if they don't exist:
601    ///
602    /// ```rust,no_run
603    /// # use clawspec_core::test_client::{TestClient, TestServer};
604    /// # use std::net::TcpListener;
605    /// # #[derive(Debug)] struct MyServer;
606    /// # impl TestServer for MyServer {
607    /// #   type Error = std::io::Error;
608    /// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
609    /// #       listener.set_nonblocking(true)?;
610    /// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
611    /// #       Ok(())
612    /// #   }
613    /// # }
614    /// # #[tokio::test]
615    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
616    /// let client = TestClient::start(MyServer).await?;
617    ///
618    /// // This will create the docs/api/v1/ directory structure if it doesn't exist
619    /// client.write_openapi("docs/api/v1/openapi.yml").await?;
620    /// # Ok(())
621    /// # }
622    /// ```
623    ///
624    /// # Generated OpenAPI Structure
625    ///
626    /// The generated OpenAPI specification includes:
627    /// - All API endpoints called through the client
628    /// - Request and response schemas for structured data
629    /// - Parameter definitions (path, query, headers)
630    /// - Status codes and error responses
631    /// - Server information and metadata
632    ///
633    /// The specification follows OpenAPI 3.1 format and can be used with various
634    /// tools for documentation generation, client generation, and API validation.
635    pub async fn write_openapi(mut self, path: impl AsRef<Path>) -> Result<(), TestAppError> {
636        let path = path.as_ref();
637        if let Some(parent) = path.parent() {
638            fs::create_dir_all(parent)?;
639        }
640
641        let openapi = self.client.collected_openapi().await;
642
643        let ext = path.extension().unwrap_or_default();
644        let contents = if ext == "yml" || ext == "yaml" {
645            openapi.to_yaml().map_err(|err| TestAppError::YamlError {
646                error: format!("{err:#?}"),
647            })?
648        } else {
649            serde_json::to_string_pretty(&openapi)?
650        };
651
652        fs::write(path, contents)?;
653
654        Ok(())
655    }
656}
657
658/// Automatic cleanup when TestClient is dropped.
659///
660/// This implementation ensures that the background server task is properly
661/// terminated when the TestClient goes out of scope, preventing resource leaks.
662impl<T> Drop for TestClient<T> {
663    /// Abort the background server task when the TestClient is dropped.
664    ///
665    /// This method is called automatically when the TestClient goes out of scope.
666    /// It ensures that the server task is cleanly terminated, preventing the
667    /// server from continuing to run after the test is complete.
668    ///
669    /// # Example
670    ///
671    /// ```rust,no_run
672    /// # use clawspec_core::test_client::{TestClient, TestServer};
673    /// # use std::net::TcpListener;
674    /// # #[derive(Debug)] struct MyServer;
675    /// # impl TestServer for MyServer {
676    /// #   type Error = std::io::Error;
677    /// #   async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
678    /// #       listener.set_nonblocking(true)?;
679    /// #       let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
680    /// #       Ok(())
681    /// #   }
682    /// # }
683    /// # #[tokio::test]
684    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
685    /// {
686    ///     let client = TestClient::start(MyServer).await?;
687    ///     // Use the client for testing
688    ///     client.get("/api/test")?.await?.as_json::<serde_json::Value>().await?;
689    /// } // <- TestClient is dropped here, server task is automatically aborted
690    ///   
691    /// // Server is no longer running
692    /// # Ok(())
693    /// # }
694    /// ```
695    fn drop(&mut self) {
696        if let Some(handle) = self.handle.take() {
697            handle.abort();
698        }
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use crate::ApiClient;
706    use std::net::{Ipv4Addr, TcpListener};
707    use std::sync::Arc;
708    use std::sync::atomic::{AtomicBool, Ordering};
709    use std::time::Duration;
710    use tokio::net::TcpListener as TokioTcpListener;
711
712    /// Mock server for testing TestClient functionality
713    #[derive(Debug)]
714    struct MockTestServer {
715        should_be_healthy: Arc<AtomicBool>,
716        startup_delay: Duration,
717        custom_config: Option<TestServerConfig>,
718    }
719
720    impl MockTestServer {
721        fn new() -> Self {
722            Self {
723                should_be_healthy: Arc::new(AtomicBool::new(true)),
724                startup_delay: Duration::from_millis(10),
725                custom_config: None,
726            }
727        }
728
729        fn with_health_status(self, healthy: bool) -> Self {
730            self.should_be_healthy.store(healthy, Ordering::Relaxed);
731            self
732        }
733
734        fn with_startup_delay(mut self, delay: Duration) -> Self {
735            self.startup_delay = delay;
736            self
737        }
738
739        fn with_config(mut self, config: TestServerConfig) -> Self {
740            self.custom_config = Some(config);
741            self
742        }
743    }
744
745    impl TestServer for MockTestServer {
746        type Error = std::io::Error;
747
748        async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
749            // Simulate startup delay
750            if !self.startup_delay.is_zero() {
751                tokio::time::sleep(self.startup_delay).await;
752            }
753
754            // Convert to non-blocking for tokio compatibility
755            listener.set_nonblocking(true)?;
756            let tokio_listener = TokioTcpListener::from_std(listener)?;
757
758            // Simple HTTP server that responds to health checks
759            loop {
760                if let Ok((mut stream, _)) = tokio_listener.accept().await {
761                    tokio::spawn(async move {
762                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
763                        let _ =
764                            tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes())
765                                .await;
766                        let _ = tokio::io::AsyncWriteExt::shutdown(&mut stream).await;
767                    });
768                }
769            }
770        }
771
772        async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
773            Ok(if self.should_be_healthy.load(Ordering::Relaxed) {
774                HealthStatus::Healthy
775            } else {
776                HealthStatus::Unhealthy
777            })
778        }
779
780        fn config(&self) -> TestServerConfig {
781            self.custom_config.clone().unwrap_or_default()
782        }
783    }
784
785    #[tokio::test]
786    async fn test_test_client_start_success() {
787        let server = MockTestServer::new();
788
789        let result = TestClient::start(server).await;
790        assert!(result.is_ok());
791
792        let test_client = result.unwrap();
793        assert!(test_client.handle.is_some());
794
795        // Test that the server is running by checking the local address
796        let addr = test_client.local_addr;
797        assert_eq!(addr.ip(), Ipv4Addr::LOCALHOST);
798        assert_ne!(addr.port(), 0); // Should have been assigned a port
799    }
800
801    #[tokio::test]
802    async fn test_test_client_start_with_custom_config() {
803        let min_delay = Duration::from_millis(5);
804        let max_delay = Duration::from_millis(100);
805        let client_builder = ApiClient::builder()
806            .with_host("test.example.com")
807            .with_port(8080);
808
809        let config = TestServerConfig {
810            api_client: Some(client_builder),
811            min_backoff_delay: min_delay,
812            max_backoff_delay: max_delay,
813            backoff_jitter: false,
814            max_retry_attempts: 5,
815        };
816
817        let server = MockTestServer::new().with_config(config);
818        let result = TestClient::start(server).await;
819
820        assert!(result.is_ok());
821        let test_client = result.unwrap();
822        assert!(test_client.handle.is_some());
823    }
824
825    #[tokio::test]
826    async fn test_test_client_start_unhealthy_server() {
827        let expected_max_delay = Duration::from_millis(50);
828        let config = TestServerConfig {
829            api_client: None,
830            min_backoff_delay: Duration::from_millis(5),
831            max_backoff_delay: expected_max_delay,
832            backoff_jitter: false,
833            max_retry_attempts: 3,
834        };
835        let server = MockTestServer::new()
836            .with_health_status(false)
837            .with_config(config);
838
839        let result = TestClient::start(server).await;
840        assert!(result.is_err());
841
842        match result.unwrap_err() {
843            TestAppError::UnhealthyServer {
844                timeout: actual_timeout,
845            } => {
846                assert_eq!(actual_timeout, expected_max_delay);
847            }
848            other => panic!("Expected UnhealthyServer error, got: {other:?}"),
849        }
850    }
851
852    #[tokio::test]
853    async fn test_test_client_start_slow_server() {
854        let server = MockTestServer::new().with_startup_delay(Duration::from_millis(50));
855
856        let result = TestClient::start(server).await;
857        assert!(result.is_ok());
858    }
859
860    #[tokio::test]
861    async fn test_test_client_deref_to_api_client() {
862        let server = MockTestServer::new();
863        let mut test_client = TestClient::start(server)
864            .await
865            .expect("client should start");
866
867        // Test that we can access ApiClient methods through deref
868        let openapi = test_client.collected_openapi().await;
869        assert_eq!(openapi.info.title, ""); // Default title
870    }
871
872    #[tokio::test]
873    async fn test_test_client_deref_mut_to_api_client() {
874        let server = MockTestServer::new();
875        let test_client = TestClient::start(server)
876            .await
877            .expect("client should start");
878
879        // Test that we can mutably access ApiClient methods through deref_mut
880        let result = test_client.get("/test");
881        assert!(result.is_ok());
882    }
883
884    #[tokio::test]
885    async fn test_test_client_write_openapi_json() {
886        let server = MockTestServer::new();
887        let test_client = TestClient::start(server)
888            .await
889            .expect("client should start");
890
891        let temp_file = "/tmp/test_openapi.json";
892        let result = test_client.write_openapi(temp_file).await;
893
894        assert!(result.is_ok());
895
896        // Verify file was created and contains valid JSON
897        let content = std::fs::read_to_string(temp_file).expect("file should exist");
898        let json: serde_json::Value = serde_json::from_str(&content).expect("should be valid JSON");
899
900        assert!(json.get("openapi").is_some());
901        assert!(json.get("info").is_some());
902
903        // Cleanup
904        let _ = std::fs::remove_file(temp_file);
905    }
906
907    #[tokio::test]
908    async fn test_test_client_write_openapi_yaml() {
909        let server = MockTestServer::new();
910        let test_client = TestClient::start(server)
911            .await
912            .expect("client should start");
913
914        let temp_file = "/tmp/test_openapi.yml";
915        let result = test_client.write_openapi(temp_file).await;
916
917        assert!(result.is_ok());
918
919        // Verify file was created and contains valid YAML
920        let content = std::fs::read_to_string(temp_file).expect("file should exist");
921        let yaml: serde_yaml::Value = serde_yaml::from_str(&content).expect("should be valid YAML");
922
923        assert!(yaml.get("openapi").is_some());
924        assert!(yaml.get("info").is_some());
925
926        // Cleanup
927        let _ = std::fs::remove_file(temp_file);
928    }
929
930    #[tokio::test]
931    async fn test_test_client_write_openapi_creates_parent_dirs() {
932        let server = MockTestServer::new();
933        let test_client = TestClient::start(server)
934            .await
935            .expect("client should start");
936
937        let temp_dir = "/tmp/test_clawspec_dir/subdir";
938        let temp_file = format!("{temp_dir}/openapi.json");
939
940        let result = test_client.write_openapi(&temp_file).await;
941        assert!(result.is_ok());
942
943        // Verify file and directories were created
944        assert!(std::fs::metadata(&temp_file).is_ok());
945
946        // Cleanup
947        let _ = std::fs::remove_dir_all("/tmp/test_clawspec_dir");
948    }
949
950    #[tokio::test]
951    async fn test_test_client_drop_aborts_handle() {
952        let server = MockTestServer::new();
953        let test_client = TestClient::start(server)
954            .await
955            .expect("client should start");
956
957        let handle = test_client.handle.as_ref().unwrap();
958        assert!(!handle.is_finished());
959
960        // Drop the test client
961        drop(test_client);
962
963        // Give a moment for the handle to be aborted
964        tokio::time::sleep(Duration::from_millis(10)).await;
965        // Note: We can't easily test that the handle was aborted since we dropped test_client
966        // But the Drop implementation should call abort()
967    }
968
969    #[test]
970    fn test_test_client_debug_trait() {
971        // Test that TestClient implements Debug (compile-time check)
972        let server = MockTestServer::new();
973        // We can't easily create a TestClient in a sync test, but we can verify the trait bounds
974        fn assert_debug<T: std::fmt::Debug>(_: &T) {}
975        assert_debug(&server);
976    }
977
978    #[test]
979    fn test_test_client_trait_bounds() {
980        // Verify that TestClient has the expected trait bounds
981        #[allow(dead_code)]
982        fn assert_bounds<T>(_: TestClient<T>)
983        where
984            T: TestServer + Send + Sync + 'static,
985        {
986            // TestClient should implement Deref and DerefMut to ApiClient
987        }
988
989        // This is a compile-time check
990    }
991
992    /// Mock server that simulates different error conditions
993    #[derive(Debug)]
994    struct ErrorTestServer {
995        error_type: ErrorType,
996    }
997
998    #[derive(Debug)]
999    enum ErrorType {
1000        #[allow(dead_code)]
1001        BindFailure,
1002        HealthTimeout,
1003    }
1004
1005    impl ErrorTestServer {
1006        #[allow(dead_code)]
1007        fn bind_failure() -> Self {
1008            Self {
1009                error_type: ErrorType::BindFailure,
1010            }
1011        }
1012
1013        fn health_timeout() -> Self {
1014            Self {
1015                error_type: ErrorType::HealthTimeout,
1016            }
1017        }
1018    }
1019
1020    impl TestServer for ErrorTestServer {
1021        type Error = std::io::Error;
1022
1023        async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
1024            match self.error_type {
1025                ErrorType::BindFailure => {
1026                    // Simulate bind failure by returning an error
1027                    Err(std::io::Error::new(
1028                        std::io::ErrorKind::AddrInUse,
1029                        "Simulated bind failure",
1030                    ))
1031                }
1032                ErrorType::HealthTimeout => {
1033                    // Start normally but never become healthy
1034                    listener.set_nonblocking(true)?;
1035                    let _tokio_listener = TokioTcpListener::from_std(listener)?;
1036
1037                    // Just keep running without accepting connections
1038                    loop {
1039                        tokio::time::sleep(Duration::from_secs(1)).await;
1040                    }
1041                }
1042            }
1043        }
1044
1045        async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
1046            match self.error_type {
1047                ErrorType::BindFailure => Ok(HealthStatus::Unhealthy),
1048                ErrorType::HealthTimeout => {
1049                    // Always return unhealthy, which will cause the exponential backoff to timeout
1050                    Ok(HealthStatus::Unhealthy)
1051                }
1052            }
1053        }
1054
1055        fn config(&self) -> TestServerConfig {
1056            TestServerConfig {
1057                api_client: None,
1058                min_backoff_delay: Duration::from_millis(1), // Very fast for testing
1059                max_backoff_delay: Duration::from_millis(10), // Short max delay for testing
1060                backoff_jitter: false,                       // Predictable timing for tests
1061                max_retry_attempts: 3,                       // Quick timeout for tests
1062            }
1063        }
1064    }
1065
1066    #[tokio::test]
1067    async fn test_test_client_start_health_timeout() {
1068        let server = ErrorTestServer::health_timeout();
1069
1070        let result = TestClient::start(server).await;
1071        assert!(result.is_err());
1072
1073        match result.unwrap_err() {
1074            TestAppError::UnhealthyServer { timeout } => {
1075                // The timeout should be the max_backoff_delay from the ErrorTestServer config
1076                assert_eq!(timeout, Duration::from_millis(10));
1077            }
1078            other => panic!("Expected UnhealthyServer error, got: {other:?}"),
1079        }
1080    }
1081}