Skip to main content

sova_core/
test_client.rs

1//! In-process HTTP client with a cookie jar (feature `testing`).
2
3use crate::app::{App, Server};
4use crate::error::Result;
5use crate::request::Request;
6use crate::response::Response;
7use bytes::Bytes;
8use http::{HeaderMap, HeaderName, HeaderValue, Method};
9use std::collections::HashMap;
10use std::future::{Future, IntoFuture};
11use std::pin::Pin;
12use std::sync::{Arc, Mutex};
13
14/// Mutates a request before [`Server::handle`] (e.g. inject auth extensions).
15pub type RequestHook = Arc<dyn Fn(&mut Request) + Send + Sync>;
16
17/// Test client over a compiled [`Server`], always tracking cookies.
18pub struct TestClient {
19    server: Server,
20    jar: Mutex<HashMap<String, String>>,
21    request_hooks: Mutex<Vec<RequestHook>>,
22}
23
24impl TestClient {
25    pub fn new(app: App) -> Result<Self> {
26        Ok(Self {
27            server: app.build()?,
28            jar: Mutex::new(HashMap::new()),
29            request_hooks: Mutex::new(Vec::new()),
30        })
31    }
32
33    /// Same as [`Self::new`] (Rocket-style name).
34    pub fn tracked(app: App) -> Result<Self> {
35        Self::new(app)
36    }
37
38    pub fn server(&self) -> &Server {
39        &self.server
40    }
41
42    /// Run `hook` on every request before dispatch (stacked; call order preserved).
43    pub fn on_request<F>(&self, hook: F)
44    where
45        F: Fn(&mut Request) + Send + Sync + 'static,
46    {
47        self.request_hooks.lock().unwrap().push(Arc::new(hook));
48    }
49
50    /// Drop all [`Self::on_request`] hooks.
51    pub fn clear_request_hooks(&self) {
52        self.request_hooks.lock().unwrap().clear();
53    }
54
55    pub fn get(&self, path: impl Into<String>) -> ClientRequest<'_> {
56        ClientRequest::new(self, Method::GET, path.into())
57    }
58
59    pub fn post(&self, path: impl Into<String>) -> ClientRequest<'_> {
60        ClientRequest::new(self, Method::POST, path.into())
61    }
62
63    pub fn put(&self, path: impl Into<String>) -> ClientRequest<'_> {
64        ClientRequest::new(self, Method::PUT, path.into())
65    }
66
67    pub fn patch(&self, path: impl Into<String>) -> ClientRequest<'_> {
68        ClientRequest::new(self, Method::PATCH, path.into())
69    }
70
71    pub fn delete(&self, path: impl Into<String>) -> ClientRequest<'_> {
72        ClientRequest::new(self, Method::DELETE, path.into())
73    }
74
75    fn cookie_header(&self) -> Option<String> {
76        let jar = self.jar.lock().unwrap();
77        if jar.is_empty() {
78            return None;
79        }
80        Some(
81            jar.iter()
82                .map(|(k, v)| format!("{k}={v}"))
83                .collect::<Vec<_>>()
84                .join("; "),
85        )
86    }
87
88    fn store_set_cookie(&self, res: &Response) {
89        let mut jar = self.jar.lock().unwrap();
90        for val in res.headers().get_all(http::header::SET_COOKIE) {
91            let Ok(raw) = val.to_str() else { continue };
92            let pair = raw.split(';').next().unwrap_or(raw).trim();
93            if let Some((name, value)) = pair.split_once('=') {
94                jar.insert(name.trim().to_string(), value.trim().to_string());
95            }
96        }
97    }
98
99    fn apply_hooks(&self, req: &mut Request) {
100        let hooks = self.request_hooks.lock().unwrap().clone();
101        for hook in hooks {
102            hook(req);
103        }
104    }
105}
106
107/// Builder for a single request; `.await` sends it via [`IntoFuture`].
108pub struct ClientRequest<'a> {
109    client: &'a TestClient,
110    method: Method,
111    path: String,
112    headers: HeaderMap,
113    body: Bytes,
114}
115
116impl<'a> ClientRequest<'a> {
117    fn new(client: &'a TestClient, method: Method, path: String) -> Self {
118        Self {
119            client,
120            method,
121            path,
122            headers: HeaderMap::new(),
123            body: Bytes::new(),
124        }
125    }
126
127    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
128        if let (Ok(n), Ok(v)) = (
129            HeaderName::from_bytes(name.as_ref().as_bytes()),
130            HeaderValue::from_str(value.as_ref()),
131        ) {
132            self.headers.insert(n, v);
133        }
134        self
135    }
136
137    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
138        self.body = body.into();
139        self
140    }
141
142    pub fn form(mut self, pairs: &[(&str, &str)]) -> Self {
143        let encoded = serde_urlencoded::to_string(pairs).unwrap_or_default();
144        self.headers.insert(
145            http::header::CONTENT_TYPE,
146            HeaderValue::from_static("application/x-www-form-urlencoded"),
147        );
148        self.body = Bytes::from(encoded);
149        self
150    }
151
152    pub fn json<T: serde::Serialize>(mut self, value: &T) -> Self {
153        let bytes = serde_json::to_vec(value).unwrap_or_default();
154        self.headers.insert(
155            http::header::CONTENT_TYPE,
156            HeaderValue::from_static("application/json"),
157        );
158        self.body = Bytes::from(bytes);
159        self
160    }
161
162    async fn dispatch(self) -> Response {
163        let mut builder = Request::builder()
164            .method(self.method)
165            .path(self.path)
166            .body(self.body);
167        for (k, v) in self.headers.iter() {
168            if let Ok(s) = v.to_str() {
169                builder = builder.header(k.as_str(), s);
170            }
171        }
172        if let Some(cookie) = self.client.cookie_header() {
173            builder = builder.header("cookie", cookie);
174        }
175        let mut req = builder.build();
176        self.client.apply_hooks(&mut req);
177        let res = self.client.server.handle(req).await;
178        self.client.store_set_cookie(&res);
179        res
180    }
181}
182
183impl<'a> IntoFuture for ClientRequest<'a> {
184    type Output = Response;
185    type IntoFuture = Pin<Box<dyn Future<Output = Response> + Send + 'a>>;
186
187    fn into_future(self) -> Self::IntoFuture {
188        Box::pin(self.dispatch())
189    }
190}
191
192/// Fluent assertions for HTTP responses in tests.
193pub trait ResponseAssert {
194    /// Panic unless status matches `code`.
195    fn assert_status(&self, code: u16) -> &Self;
196
197    /// Deserialize buffered JSON body; panics on failure.
198    fn json<T: serde::de::DeserializeOwned>(&self) -> T;
199
200    /// Parse buffered body as [`serde_json::Value`].
201    fn json_value(&self) -> serde_json::Value;
202}
203
204impl ResponseAssert for Response {
205    fn assert_status(&self, code: u16) -> &Self {
206        let got = self.status_code().as_u16();
207        assert_eq!(
208            got,
209            code,
210            "unexpected status {got}, body: {:?}",
211            self.body_bytes().map(|b| String::from_utf8_lossy(b).into_owned())
212        );
213        self
214    }
215
216    fn json<T: serde::de::DeserializeOwned>(&self) -> T {
217        let bytes = self
218            .body_bytes()
219            .unwrap_or_else(|| panic!("response body is not buffered"));
220        serde_json::from_slice(bytes).unwrap_or_else(|e| {
221            panic!(
222                "json decode failed: {e}; body: {}",
223                String::from_utf8_lossy(bytes)
224            )
225        })
226    }
227
228    fn json_value(&self) -> serde_json::Value {
229        self.json()
230    }
231}