Skip to main content

siderite_testkit/
lib.rs

1//! In-process testing helpers for siderite applications.
2#![forbid(unsafe_code)]
3
4mod builder;
5mod database;
6
7pub use builder::TestClientBuilder;
8pub use database::{TestDatabase, TestDatabaseError};
9/// Re-exported so tests can build custom [`Request`]s for [`TestClient::send`].
10pub use http;
11
12use http::{HeaderMap, Method, Request, StatusCode, header};
13use serde::{Serialize, de::DeserializeOwned};
14use siderite_core::lifespan::ManagedLifespan;
15use siderite_core::{App, Body, BodyError, RouterService, ServerError};
16use std::sync::{Arc, Mutex, PoisonError};
17use thiserror::Error;
18use tower::ServiceExt;
19
20/// Failure while building or executing a test request.
21#[derive(Debug, Error)]
22#[non_exhaustive]
23pub enum TestClientError {
24    /// The request could not be constructed.
25    #[error("invalid request: {0}")]
26    Request(#[from] http::Error),
27    /// The JSON payload could not be serialized.
28    #[error("cannot serialize request body: {0}")]
29    Serialize(#[from] serde_json::Error),
30    /// The response body could not be read.
31    #[error(transparent)]
32    Body(#[from] BodyError),
33}
34
35/// A fully buffered response.
36#[derive(Debug, Clone)]
37pub struct TestResponse {
38    /// Status code.
39    pub status: StatusCode,
40    /// Response headers.
41    pub headers: HeaderMap,
42    /// Raw body bytes.
43    pub body: Vec<u8>,
44}
45
46impl TestResponse {
47    /// Deserialize the body as JSON.
48    ///
49    /// # Errors
50    /// Returns an error if the body is not valid JSON for `T`.
51    pub fn json<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
52        serde_json::from_slice(&self.body)
53    }
54
55    /// The body as text (invalid UTF-8 is replaced).
56    pub fn text(&self) -> String {
57        String::from_utf8_lossy(&self.body).into_owned()
58    }
59
60    /// The `content-type` header, if present and valid UTF-8.
61    pub fn content_type(&self) -> Option<&str> {
62        self.headers.get(header::CONTENT_TYPE)?.to_str().ok()
63    }
64}
65
66/// Drives an [`App`] in-process, without sockets.
67#[derive(Debug, Clone)]
68pub struct TestClient {
69    service: RouterService,
70    lifespan: Arc<Mutex<Option<ManagedLifespan>>>,
71}
72
73impl TestClient {
74    /// Start a [`TestClientBuilder`] to apply dependency overrides and
75    /// databases to `app` before it is built.
76    pub fn builder(app: App) -> TestClientBuilder {
77        TestClientBuilder::new(app)
78    }
79
80    /// Wrap `app`.
81    ///
82    /// # Errors
83    /// Returns the app's configuration error (duplicate routes, invalid
84    /// paths, OpenAPI generation failures).
85    pub fn try_new(app: App) -> Result<Self, siderite_core::ServerError> {
86        Ok(Self {
87            service: app.into_router_service()?,
88            lifespan: Arc::default(),
89        })
90    }
91
92    /// Wrap `app`, for use in tests.
93    ///
94    /// # Panics
95    /// Panics if the app is misconfigured; use [`TestClient::try_new`] to
96    /// assert on configuration errors.
97    pub fn new(app: App) -> Self {
98        match Self::try_new(app) {
99            Ok(client) => client,
100            Err(err) => panic!("invalid app configuration: {err}"),
101        }
102    }
103
104    /// Wrap `app` and run its startup hooks (mounted children included).
105    ///
106    /// Unlike [`TestClient::try_new`], which never runs hooks, this mirrors
107    /// `App::run` without binding a socket. Call [`TestClient::shutdown`] to
108    /// run the shutdown hooks.
109    ///
110    /// # Errors
111    /// Returns the configuration error, or [`ServerError::Lifespan`] if a
112    /// startup hook fails.
113    pub async fn start(app: App) -> Result<Self, ServerError> {
114        let (service, lifespan) = app.into_service_with_lifespan()?;
115        let mut lifespan = lifespan.supervise()?;
116        lifespan.ready().await?;
117        Ok(Self {
118            service,
119            lifespan: Arc::new(Mutex::new(Some(lifespan))),
120        })
121    }
122
123    /// Run the shutdown hooks (reverse order). A no-op if already run or if
124    /// the client was not created with [`TestClient::start`].
125    ///
126    /// # Errors
127    /// Returns [`ServerError::Lifespan`] with the first failing hook.
128    pub async fn shutdown(&self) -> Result<(), ServerError> {
129        let taken = self
130            .lifespan
131            .lock()
132            .unwrap_or_else(PoisonError::into_inner)
133            .take();
134        match taken {
135            Some(lifespan) => lifespan.shutdown().await,
136            None => Ok(()),
137        }
138    }
139
140    /// Send a GET request.
141    ///
142    /// # Errors
143    /// Returns [`TestClientError`] on a malformed request or unreadable body.
144    pub async fn get(&self, path: &str) -> Result<TestResponse, TestClientError> {
145        let req = Request::builder()
146            .method(Method::GET)
147            .uri(path)
148            .body(Body::empty())?;
149        self.send(req).await
150    }
151
152    /// Send a DELETE request.
153    ///
154    /// # Errors
155    /// Returns [`TestClientError`] on a malformed request or unreadable body.
156    pub async fn delete(&self, path: &str) -> Result<TestResponse, TestClientError> {
157        let req = Request::builder()
158            .method(Method::DELETE)
159            .uri(path)
160            .body(Body::empty())?;
161        self.send(req).await
162    }
163
164    /// Send a POST request with a JSON body.
165    ///
166    /// # Errors
167    /// Returns [`TestClientError`] on serialization, request or body failure.
168    pub async fn post_json<T: Serialize>(
169        &self,
170        path: &str,
171        payload: &T,
172    ) -> Result<TestResponse, TestClientError> {
173        self.post_raw(path, "application/json", serde_json::to_vec(payload)?)
174            .await
175    }
176
177    /// Send a POST request with an arbitrary body and content type.
178    ///
179    /// # Errors
180    /// Returns [`TestClientError`] on a malformed request or unreadable body.
181    pub async fn post_raw(
182        &self,
183        path: &str,
184        content_type: &str,
185        body: Vec<u8>,
186    ) -> Result<TestResponse, TestClientError> {
187        let req = Request::builder()
188            .method(Method::POST)
189            .uri(path)
190            .header(header::CONTENT_TYPE, content_type)
191            .body(Body::from(body))?;
192        self.send(req).await
193    }
194
195    /// Send any request and buffer the response.
196    ///
197    /// # Errors
198    /// Returns [`TestClientError::Body`] if the response body cannot be read.
199    pub async fn send(&self, req: Request<Body>) -> Result<TestResponse, TestClientError> {
200        let Ok(response) = self.service.clone().oneshot(req).await;
201        let (parts, body) = response.into_parts();
202        Ok(TestResponse {
203            status: parts.status,
204            headers: parts.headers,
205            body: body.into_bytes_limited(usize::MAX).await?,
206        })
207    }
208}