Skip to main content

karbon_framework/testing/
mod.rs

1use std::net::SocketAddr;
2use tokio::net::TcpListener;
3
4use crate::config::Config;
5use crate::db::{Database, DbPool};
6use crate::http::AppState;
7use crate::mail::Mailer;
8
9
10/// Test application helper. Boots the app on a random port for integration tests.
11///
12/// ```ignore
13/// #[tokio::test]
14/// async fn test_health() {
15///     let app = TestApp::spawn(build_router()).await;
16///
17///     let res = app.get("/health").await;
18///     assert_eq!(res.status(), 200);
19///
20///     let body: serde_json::Value = res.json().await;
21///     assert_eq!(body["status"], "ok");
22/// }
23/// ```
24pub struct TestApp {
25    pub addr: SocketAddr,
26    pub pool: DbPool,
27    pub client: reqwest::Client,
28}
29
30impl TestApp {
31    /// Spawn the app on a random port. Requires DATABASE_URL or DB_* env vars.
32    pub async fn spawn(router: axum::Router<AppState>) -> Self {
33        dotenvy::dotenv().ok();
34        let config = Config::from_env();
35        let db = Database::connect(&config)
36            .await
37            .expect("Failed to connect to test database");
38
39        let pool = db.pool().clone();
40
41        let mailer = if !config.smtp_host.is_empty() && !config.smtp_user.is_empty() {
42            Mailer::new(&config).ok()
43        } else {
44            None
45        };
46
47        let state = AppState {
48            db,
49            config,
50            mailer,
51            role_hierarchy: crate::security::default_hierarchy(),
52        };
53
54        let router = router.with_state(state);
55
56        let listener = TcpListener::bind("127.0.0.1:0")
57            .await
58            .expect("Failed to bind test listener");
59        let addr = listener.local_addr().unwrap();
60
61        tokio::spawn(async move {
62            if let Err(e) = axum::serve(
63                listener,
64                router.into_make_service_with_connect_info::<SocketAddr>(),
65            )
66            .await
67            {
68                tracing::error!("Test server error: {e}");
69            }
70        });
71
72        let client = reqwest::Client::builder()
73            .redirect(reqwest::redirect::Policy::none())
74            .build()
75            .expect("Failed to build HTTP client");
76
77        Self { addr, pool, client }
78    }
79
80    /// Base URL for the test server
81    pub fn url(&self, path: &str) -> String {
82        format!("http://{}{}", self.addr, path)
83    }
84
85    /// GET request
86    pub async fn get(&self, path: &str) -> reqwest::Response {
87        self.client
88            .get(&self.url(path))
89            .send()
90            .await
91            .expect("Failed to send GET request")
92    }
93
94    /// POST request with JSON body
95    pub async fn post_json<T: serde::Serialize>(&self, path: &str, body: &T) -> reqwest::Response {
96        self.client
97            .post(&self.url(path))
98            .json(body)
99            .send()
100            .await
101            .expect("Failed to send POST request")
102    }
103
104    /// PUT request with JSON body
105    pub async fn put_json<T: serde::Serialize>(&self, path: &str, body: &T) -> reqwest::Response {
106        self.client
107            .put(&self.url(path))
108            .json(body)
109            .send()
110            .await
111            .expect("Failed to send PUT request")
112    }
113
114    /// DELETE request
115    pub async fn delete(&self, path: &str) -> reqwest::Response {
116        self.client
117            .delete(&self.url(path))
118            .send()
119            .await
120            .expect("Failed to send DELETE request")
121    }
122
123    /// GET request with auth header
124    pub async fn get_auth(&self, path: &str, token: &str) -> reqwest::Response {
125        self.client
126            .get(&self.url(path))
127            .bearer_auth(token)
128            .send()
129            .await
130            .expect("Failed to send authenticated GET request")
131    }
132}