Skip to main content

wabot_testing/
rest.rs

1//! Drive REST controllers in a test. Port of
2//! `wabot-ts/src/testing/restHarness.ts`.
3//!
4//! ## No port, same application
5//!
6//! TS binds an ephemeral port and makes real `fetch` calls, because
7//! Express has no other way in. axum's router *is* a `tower::Service`,
8//! so a request can be driven straight through it — no listener, no
9//! port collisions between parallel tests, no async teardown to
10//! forget.
11//!
12//! The risk that buys is testing a *different* application than the
13//! one that ships: the framework wraps the router in trailing-slash
14//! normalization and the request log context, and a bare router has
15//! neither. So the harness builds its stack with
16//! [`rest_app`](wabot_feature_rest_controller::rest_app) — the very
17//! function `run_rest_controllers` calls.
18
19use std::sync::Arc;
20
21use serde::de::DeserializeOwned;
22use serde::Serialize;
23use tower::ServiceExt;
24use wabot_feature_rest_controller::axum::body::Body;
25use wabot_feature_rest_controller::axum::http::{HeaderMap, Request, StatusCode};
26use wabot_feature_rest_controller::axum::Router;
27use wabot_feature_rest_controller::rest_app;
28
29/// Mounts a router and exercises the real pipeline: routing,
30/// extractors, middlewares and guards, validation, and error mapping.
31///
32/// ```ignore
33/// let harness = RestHarness::new(UserController::register_routes(&container, Router::new()));
34///
35/// let response = harness.get("/users/1").send().await;
36/// assert_eq!(response.status, 200);
37/// assert_eq!(response.json::<User>().name, "Ada");
38/// ```
39#[derive(Clone)]
40pub struct RestHarness {
41    router: Router,
42    /// Headers added to every request — how [`RestHarness::with_header`]
43    /// builds an authenticated client without repeating itself.
44    default_headers: Arc<Vec<(String, String)>>,
45}
46
47impl RestHarness {
48    pub fn new(router: Router) -> Self {
49        Self {
50            router,
51            default_headers: Arc::new(Vec::new()),
52        }
53    }
54
55    /// A client that sends `name: value` on every request — a bearer
56    /// token, an API key, a tenant header.
57    ///
58    /// Returns a new harness rather than mutating: a test usually
59    /// wants both the authenticated and the anonymous client, and
60    /// comparing them is the point.
61    pub fn with_header(&self, name: impl Into<String>, value: impl Into<String>) -> Self {
62        let mut headers = (*self.default_headers).clone();
63        headers.push((name.into(), value.into()));
64        Self {
65            router: self.router.clone(),
66            default_headers: Arc::new(headers),
67        }
68    }
69
70    /// A client authenticating with `Authorization: Bearer …`.
71    pub fn with_bearer(&self, token: impl std::fmt::Display) -> Self {
72        self.with_header("authorization", format!("Bearer {token}"))
73    }
74
75    /// A client sending the token in a cookie, for a guard configured
76    /// to read one.
77    pub fn with_cookie(&self, name: &str, value: impl std::fmt::Display) -> Self {
78        self.with_header("cookie", format!("{name}={value}"))
79    }
80
81    pub fn get(&self, path: &str) -> RequestBuilder {
82        self.request("GET", path)
83    }
84    pub fn post(&self, path: &str) -> RequestBuilder {
85        self.request("POST", path)
86    }
87    pub fn put(&self, path: &str) -> RequestBuilder {
88        self.request("PUT", path)
89    }
90    pub fn delete(&self, path: &str) -> RequestBuilder {
91        self.request("DELETE", path)
92    }
93
94    pub fn request(&self, method: &str, path: &str) -> RequestBuilder {
95        RequestBuilder {
96            router: self.router.clone(),
97            method: method.to_string(),
98            path: path.to_string(),
99            query: Vec::new(),
100            headers: (*self.default_headers).clone(),
101            body: None,
102        }
103    }
104}
105
106pub struct RequestBuilder {
107    router: Router,
108    method: String,
109    path: String,
110    query: Vec<(String, String)>,
111    headers: Vec<(String, String)>,
112    body: Option<String>,
113}
114
115impl RequestBuilder {
116    /// A JSON body. Sets `Content-Type` unless one was already given.
117    pub fn json<T: Serialize>(mut self, body: &T) -> Self {
118        self.body = Some(serde_json::to_string(body).expect("a serializable body"));
119        self
120    }
121
122    /// A raw body, for testing what the framework does with something
123    /// malformed.
124    pub fn body(mut self, body: impl Into<String>) -> Self {
125        self.body = Some(body.into());
126        self
127    }
128
129    pub fn query(mut self, key: &str, value: impl std::fmt::Display) -> Self {
130        self.query.push((key.into(), value.to_string()));
131        self
132    }
133
134    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
135        self.headers.push((name.into(), value.into()));
136        self
137    }
138
139    pub fn bearer(self, token: impl std::fmt::Display) -> Self {
140        self.header("authorization", format!("Bearer {token}"))
141    }
142
143    /// Run the request through the real stack.
144    ///
145    /// # Panics
146    ///
147    /// If the request could not be built or the service failed
148    /// outright — neither is a condition a test can act on, and both
149    /// mean the test itself is wrong.
150    pub async fn send(self) -> TestResponse {
151        let mut uri = self.path.clone();
152        if !self.query.is_empty() {
153            let encoded: Vec<String> = self
154                .query
155                .iter()
156                .map(|(k, v)| format!("{}={}", encode(k), encode(v)))
157                .collect();
158            uri = format!("{uri}?{}", encoded.join("&"));
159        }
160
161        let mut request = Request::builder().method(self.method.as_str()).uri(&uri);
162        let has_content_type = self
163            .headers
164            .iter()
165            .any(|(name, _)| name.eq_ignore_ascii_case("content-type"));
166        for (name, value) in &self.headers {
167            request = request.header(name, value);
168        }
169        if self.body.is_some() && !has_content_type {
170            request = request.header("content-type", "application/json");
171        }
172
173        let request = request
174            .body(self.body.map(Body::from).unwrap_or_else(Body::empty))
175            .expect("a valid request");
176
177        let response = rest_app(self.router)
178            .oneshot(request)
179            .await
180            .expect("the service should not fail outright");
181
182        let status = response.status();
183        let headers = response.headers().clone();
184        let bytes =
185            wabot_feature_rest_controller::axum::body::to_bytes(response.into_body(), usize::MAX)
186                .await
187                .expect("a readable body");
188
189        TestResponse {
190            status,
191            headers,
192            body: String::from_utf8_lossy(&bytes).into_owned(),
193        }
194    }
195}
196
197/// What came back.
198#[derive(Debug, Clone)]
199pub struct TestResponse {
200    pub status: StatusCode,
201    pub headers: HeaderMap,
202    /// The raw body. Use [`TestResponse::json`] for the typed form.
203    pub body: String,
204}
205
206impl TestResponse {
207    /// The body as `T`.
208    ///
209    /// # Panics
210    ///
211    /// With the status and body in the message when it doesn't
212    /// deserialize — the usual cause is an error response the test
213    /// didn't expect, and seeing it beats a bare parse error.
214    pub fn json<T: DeserializeOwned>(&self) -> T {
215        serde_json::from_str(&self.body).unwrap_or_else(|error| {
216            panic!(
217                "expected a {} body, got HTTP {} with {:?} ({error})",
218                std::any::type_name::<T>(),
219                self.status,
220                self.body
221            )
222        })
223    }
224
225    /// The body as untyped JSON, for poking at one field.
226    pub fn value(&self) -> serde_json::Value {
227        self.json()
228    }
229
230    pub fn header(&self, name: &str) -> Option<&str> {
231        self.headers.get(name).and_then(|v| v.to_str().ok())
232    }
233
234    pub fn is_success(&self) -> bool {
235        self.status.is_success()
236    }
237
238    /// Assert the status, showing the body when it doesn't match —
239    /// which is exactly when a test needs to see it.
240    pub fn assert_status(&self, expected: StatusCode) -> &Self {
241        assert_eq!(
242            self.status, expected,
243            "expected HTTP {expected}, got {} with body {:?}",
244            self.status, self.body
245        );
246        self
247    }
248
249    pub fn assert_ok(&self) -> &Self {
250        self.assert_status(StatusCode::OK)
251    }
252}
253
254/// Percent-encode a query parameter. Small enough not to be worth a
255/// dependency, and a test's query strings are its own.
256fn encode(value: &str) -> String {
257    let mut out = String::with_capacity(value.len());
258    for byte in value.bytes() {
259        match byte {
260            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
261                out.push(byte as char)
262            }
263            b' ' => out.push_str("%20"),
264            other => out.push_str(&format!("%{other:02X}")),
265        }
266    }
267    out
268}