by_loco/testing/
request.rs

1use std::net::SocketAddr;
2
3use axum_test::{TestServer, TestServerConfig};
4use tokio::net::TcpListener;
5
6#[cfg(feature = "with-db")]
7use crate::Error;
8
9use crate::{
10    app::{AppContext, Hooks},
11    boot::{self, BootResult},
12    config::Server,
13    environment::Environment,
14    Result,
15};
16#[cfg(feature = "with-db")]
17use std::ops::Deref;
18
19#[cfg(feature = "with-db")]
20pub struct BootResultWrapper {
21    inner: BootResult,
22    test_db: Box<dyn super::db::TestSupport>,
23}
24
25#[cfg(feature = "with-db")]
26impl BootResultWrapper {
27    #[must_use]
28    pub fn new(boot: BootResult, test_db: Box<dyn super::db::TestSupport>) -> Self {
29        Self {
30            inner: boot,
31            test_db,
32        }
33    }
34}
35
36#[cfg(feature = "with-db")]
37impl Deref for BootResultWrapper {
38    type Target = BootResult;
39
40    fn deref(&self) -> &Self::Target {
41        &self.inner
42    }
43}
44
45#[cfg(feature = "with-db")]
46impl Drop for BootResultWrapper {
47    fn drop(&mut self) {
48        self.test_db.cleanup_db();
49    }
50}
51
52/// Configuration for making requests in the test server.
53pub struct RequestConfig {
54    /// Determines whether cookies should be saved for future requests.
55    pub save_cookies: bool,
56    /// The default content type for all requests.
57    pub default_content_type: Option<String>,
58    /// The default scheme to use for requests (e.g., "http" or "https").
59    pub default_scheme: String,
60}
61
62impl Default for RequestConfig {
63    fn default() -> Self {
64        RequestConfigBuilder::new().build()
65    }
66}
67
68/// Builder pattern for constructing [`RequestConfig`] instances.
69pub struct RequestConfigBuilder {
70    save_cookies: bool,
71    default_content_type: Option<String>,
72    default_scheme: String,
73}
74
75impl RequestConfigBuilder {
76    /// Creates a new [`RequestConfigBuilder`] with default values.
77    #[must_use]
78    pub fn new() -> Self {
79        Self {
80            save_cookies: false,
81            default_content_type: Some("application/json".to_string()),
82            default_scheme: "http".to_string(),
83        }
84    }
85
86    /// Sets whether cookies should be saved for future requests.
87    #[must_use]
88    pub fn save_cookies(mut self, save: bool) -> Self {
89        self.save_cookies = save;
90        self
91    }
92
93    /// Sets the default content type for requests.
94    #[must_use]
95    pub fn default_content_type<S: Into<String>>(mut self, content_type: S) -> Self {
96        self.default_content_type = Some(content_type.into());
97        self
98    }
99
100    /// Sets the default scheme to use for requests.
101    #[must_use]
102    pub fn default_scheme<S: Into<String>>(mut self, scheme: S) -> Self {
103        self.default_scheme = scheme.into();
104        self
105    }
106
107    /// Builds and returns a `RequestConfig` instance.
108    #[must_use]
109    pub fn build(self) -> RequestConfig {
110        RequestConfig {
111            save_cookies: self.save_cookies,
112            default_content_type: self.default_content_type,
113            default_scheme: self.default_scheme,
114        }
115    }
116}
117
118impl Default for RequestConfigBuilder {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124// Implement the From trait for automatic conversion
125impl From<RequestConfig> for TestServerConfig {
126    fn from(request_config: RequestConfig) -> Self {
127        Self {
128            default_content_type: request_config.default_content_type,
129            save_cookies: request_config.save_cookies,
130            ..Default::default()
131        }
132    }
133}
134
135/// The port on which the test server will run.
136pub const TEST_PORT_SERVER: i32 = 5555;
137
138/// The hostname to which the test server binds.
139pub const TEST_BINDING_SERVER: &str = "localhost";
140
141/// Constructs and returns the base URL used for the test server.
142#[must_use]
143pub fn get_base_url_port(port: i32) -> String {
144    format!("http://{TEST_BINDING_SERVER}:{port}/")
145}
146
147/// Returns a unique port number. Usually increments by 1 starting from 59126
148///
149/// # Panics
150///
151/// Will panic if binding to test server address fails or if getting the local address fails
152pub async fn get_available_port() -> i32 {
153    let addr = format!("{TEST_BINDING_SERVER}:0");
154    let listener = TcpListener::bind(addr)
155        .await
156        .expect("Failed to bind to address");
157
158    i32::from(
159        listener
160            .local_addr()
161            .expect("Failed to get local address")
162            .port(),
163    )
164}
165
166/// Bootstraps test application with test environment hard coded.
167///
168/// # Example
169///
170/// The provided example demonstrates how to boot the test case with the
171/// application context.
172///
173/// ```rust,ignore
174/// use myapp::app::App;
175/// use loco_rs::testing::prelude::*;
176/// use migration::Migrator;
177///
178/// #[tokio::test]
179/// async fn test_create_user() {
180///     let boot = boot_test::<App, Migrator>().await;
181/// }
182/// ```
183///
184/// # Errors
185/// when could not bootstrap the test environment
186pub async fn boot_test<H: Hooks>() -> Result<BootResult> {
187    let config = H::load_config(&Environment::Test).await?;
188    let boot = H::boot(boot::StartMode::ServerOnly, &Environment::Test, config).await?;
189    Ok(boot)
190}
191
192/// Bootstraps the test application with a test environment and creates a new database.
193///
194/// This function initializes the test environment and sets up a fresh database for testing.
195/// The test database will be used during the test, and it will be cleaned up once the test completes.
196///
197/// ```rust,ignore
198/// use myapp::app::App;
199/// use loco_rs::testing::prelude::*;
200/// use migration::Migrator;
201///
202/// #[tokio::test]
203/// async fn test_create_user() {
204///     let boot = boot_test_with_create_db::<App, Migrator>().await;
205/// }
206/// ```
207///
208/// # Errors
209/// when could not bootstrap the test environment
210#[cfg(feature = "with-db")]
211pub async fn boot_test_with_create_db<H: Hooks>() -> Result<BootResultWrapper> {
212    let mut config = H::load_config(&Environment::Test).await?;
213    let test_db = super::db::init_test_db_creation(&config.database.uri)?;
214    test_db.init_db().await;
215    config.database.uri = test_db.get_connection_str().to_string();
216    let boot = match H::boot(boot::StartMode::ServerOnly, &Environment::Test, config).await {
217        Ok(boot) => boot,
218        Err(err) => {
219            test_db.cleanup_db();
220            return Err(Error::string(&err.to_string()));
221        }
222    };
223
224    Ok(BootResultWrapper::new(boot, test_db))
225}
226
227/// Bootstraps test application with test environment hard coded,
228/// and with a unique port.
229///
230/// # Errors
231/// when could not bootstrap the test environment
232///
233/// # Example
234///
235/// The provided example demonstrates how to boot the test case with the
236/// application context, and a with a unique port.
237///
238/// ```rust,ignore
239/// use myapp::app::App;
240/// use loco_rs::testing::prelude::*;
241/// use migration::Migrator;
242///
243/// #[tokio::test]
244/// async fn test_create_user() {
245///     let port = get_available_port().await;
246///     let boot = boot_test_unique_port::<App, Migrator>(Some(port)).await;
247///
248///     /// .....
249///     assert!(false)
250/// }
251pub async fn boot_test_unique_port<H: Hooks>(port: Option<i32>) -> Result<BootResult> {
252    let mut config = H::load_config(&Environment::Test).await?;
253    config.server = Server {
254        port: port.unwrap_or(TEST_PORT_SERVER),
255        binding: TEST_BINDING_SERVER.to_string(),
256        ..config.server
257    };
258    H::boot(boot::StartMode::ServerOnly, &Environment::Test, config).await
259}
260
261#[allow(clippy::future_not_send)]
262async fn request_internal<F, Fut>(callback: F, boot: &BootResult, test_server_config: RequestConfig)
263where
264    F: FnOnce(TestServer, AppContext) -> Fut,
265    Fut: std::future::Future<Output = ()>,
266{
267    let routes = boot.router.clone().unwrap();
268    let server = TestServer::new_with_config(
269        routes.into_make_service_with_connect_info::<SocketAddr>(),
270        test_server_config,
271    )
272    .unwrap();
273
274    callback(server, boot.app_context.clone()).await;
275}
276
277/// Executes a test server request using the provided callback and the default boot process.
278///
279/// This function will boot the test environment without creating a new database.
280/// It takes a `callback` function that is called with the test server and application context.
281///
282/// # Panics
283/// When could not initialize the test request.this errors can be when could not
284/// initialize the test app
285///
286/// # Example
287///
288/// The provided example demonstrates how to create a test that check
289/// application HTTP endpoints
290///
291/// ```rust,ignore
292/// use myapp::app::App;
293/// use loco_rs::testing::prelude::*;
294///
295/// #[tokio::test]
296/// #[serial]
297/// async fn can_register() {
298///     request::<App, _, _>(|request, ctx| async move {
299///         let response = request.post("/auth/register").json(&serde_json::json!({})).await;
300///     })
301///     .await;
302/// }
303/// ```
304#[allow(clippy::future_not_send)]
305pub async fn request<H: Hooks, F, Fut>(callback: F)
306where
307    F: FnOnce(TestServer, AppContext) -> Fut,
308    Fut: std::future::Future<Output = ()>,
309{
310    request_with_config::<H, F, Fut>(RequestConfig::default(), callback).await;
311}
312/// Executes a test server request with a created database using the provided callback.
313///
314/// This function will boot the test environment and create a new database for the test.
315/// It takes a `callback` function that is called with the test server and application context.
316///
317/// ```rust,ignore
318/// use myapp::app::App;
319///
320/// #[tokio::test]
321/// async fn can_register() {
322///     request_with_create_db::<App, _, _>(|request, ctx| async move {
323///         let response = request.post("/auth/register").json(&serde_json::json!({})).await;
324///     })
325///     .await;
326/// }
327/// ```
328///
329/// # Panics
330/// When could not initialize the test request.this errors can be when could not
331/// initialize the test app
332#[allow(clippy::future_not_send)]
333#[cfg(feature = "with-db")]
334pub async fn request_with_create_db<H: Hooks, F, Fut>(callback: F)
335where
336    F: FnOnce(TestServer, AppContext) -> Fut,
337    Fut: std::future::Future<Output = ()>,
338{
339    request_config_with_create_db::<H, F, Fut>(RequestConfig::default(), callback).await;
340}
341
342/// Executes a test server request using a custom [`RequestConfig`].
343///
344/// This function will boot the test environment without creating a new database.
345/// It takes a `config` parameter to customize request settings and a `callback`
346/// function that is called with the test server and application context.
347///
348/// # Panics
349/// When the test request cannot be initialized, such as when the test app fails to start.
350///
351/// # Example
352/// ```rust,ignore
353/// let config = RequestConfigBuilder::new().save_cookies(true).build();
354/// request_with_config::<App, _, _>(config, |request, ctx| async move {
355///     let response = request.get("/endpoint").await;
356/// });
357/// ```
358pub async fn request_with_config<H: Hooks, F, Fut>(config: RequestConfig, callback: F)
359where
360    F: FnOnce(TestServer, AppContext) -> Fut,
361    Fut: std::future::Future<Output = ()>,
362{
363    let boot: BootResult = boot_test::<H>().await.unwrap();
364    request_internal::<F, Fut>(callback, &boot, config).await;
365}
366
367/// Executes a test server request with a created database using a custom [`RequestConfig`].
368///
369/// This function initializes the test environment, sets up a fresh database, and then runs
370/// the provided callback function with the test server and application context.
371/// The test database will be cleaned up after the test completes.
372///
373/// # Panics
374/// When the test request cannot be initialized, such as when the test app fails to start.
375///
376/// # Example
377/// ```rust,ignore
378/// let config = RequestConfigBuilder::new().save_cookies(true).build();
379/// request_config_with_create_db::<App, _, _>(config, |request, ctx| async move {
380///     let response = request.get("/endpoint").await;
381/// });
382/// ```
383#[allow(clippy::future_not_send)]
384#[cfg(feature = "with-db")]
385pub async fn request_config_with_create_db<H: Hooks, F, Fut>(config: RequestConfig, callback: F)
386where
387    F: FnOnce(TestServer, AppContext) -> Fut,
388    Fut: std::future::Future<Output = ()>,
389{
390    let boot_wrapper: BootResultWrapper = boot_test_with_create_db::<H>().await.unwrap();
391    request_internal::<F, Fut>(callback, &boot_wrapper.inner, config).await;
392}