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}