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}