Skip to main content

minco_test/
lib.rs

1//! In-process HTTP test utilities and deterministic command evidence.
2#![forbid(unsafe_code)]
3
4use axum::{Router, body::Body};
5use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, header};
6use http_body_util::BodyExt;
7use serde::{Serialize, de::DeserializeOwned};
8use std::{collections::BTreeMap, process::Output};
9use tower::ServiceExt;
10
11#[derive(Debug, Clone)]
12pub struct TestClient {
13    router: Router,
14    default_headers: HeaderMap,
15}
16
17impl TestClient {
18    #[must_use]
19    pub fn new(router: Router) -> Self {
20        Self {
21            router,
22            default_headers: HeaderMap::new(),
23        }
24    }
25
26    #[must_use]
27    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
28        self.default_headers.insert(name, value);
29        self
30    }
31
32    pub async fn get(&self, uri: &str) -> TestResponse {
33        self.request(Method::GET, uri, HeaderMap::new(), Body::empty())
34            .await
35    }
36
37    pub async fn json<T: Serialize>(&self, method: Method, uri: &str, body: &T) -> TestResponse {
38        let body = serde_json::to_vec(body).expect("test request serialization must succeed");
39        let mut headers = HeaderMap::new();
40        headers.insert(
41            header::CONTENT_TYPE,
42            HeaderValue::from_static("application/json"),
43        );
44        self.request(method, uri, headers, Body::from(body)).await
45    }
46
47    pub async fn request(
48        &self,
49        method: Method,
50        uri: &str,
51        request_headers: HeaderMap,
52        body: Body,
53    ) -> TestResponse {
54        let mut request = Request::builder()
55            .method(method)
56            .uri(uri)
57            .body(body)
58            .expect("valid test request");
59        let mut headers = self.default_headers.clone();
60        headers.extend(request_headers);
61        *request.headers_mut() = headers;
62        let response = self
63            .router
64            .clone()
65            .oneshot(request)
66            .await
67            .expect("Axum Router uses an infallible service error");
68        let status = response.status();
69        let headers = response.headers().clone();
70        let body = response
71            .into_body()
72            .collect()
73            .await
74            .expect("response body collection must succeed")
75            .to_bytes()
76            .to_vec();
77        TestResponse {
78            status,
79            headers,
80            body,
81        }
82    }
83}
84
85#[derive(Debug, Clone)]
86pub struct TestResponse {
87    pub status: StatusCode,
88    pub headers: HeaderMap,
89    pub body: Vec<u8>,
90}
91
92impl TestResponse {
93    pub fn json<T: DeserializeOwned>(&self) -> serde_json::Result<T> {
94        serde_json::from_slice(&self.body)
95    }
96
97    #[must_use]
98    pub fn text(&self) -> String {
99        String::from_utf8_lossy(&self.body).into_owned()
100    }
101
102    pub fn assert_status(&self, expected: StatusCode) {
103        assert_eq!(self.status, expected, "response body: {}", self.text());
104    }
105}
106
107#[derive(Debug, Clone, Serialize)]
108pub struct CommandEvidence {
109    pub command: Vec<String>,
110    pub success: bool,
111    pub exit_code: Option<i32>,
112    pub stdout: String,
113    pub stderr: String,
114    pub environment: BTreeMap<String, String>,
115}
116
117impl CommandEvidence {
118    #[must_use]
119    pub fn from_output(command: Vec<String>, output: &Output) -> Self {
120        Self {
121            command,
122            success: output.status.success(),
123            exit_code: output.status.code(),
124            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
125            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
126            environment: BTreeMap::new(),
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use axum::{
135        extract::Json,
136        routing::{get, post},
137    };
138
139    #[tokio::test]
140    async fn client_exercises_router_without_a_socket() {
141        let client = TestClient::new(Router::new().route("/health", get(|| async { "ok" })));
142        let response = client.get("/health").await;
143        response.assert_status(StatusCode::OK);
144        assert_eq!(response.text(), "ok");
145    }
146
147    #[tokio::test]
148    async fn json_requests_set_the_content_type() {
149        let router = Router::new().route(
150            "/echo",
151            post(|Json(value): Json<serde_json::Value>| async move { Json(value) }),
152        );
153        let response = TestClient::new(router)
154            .json(Method::POST, "/echo", &serde_json::json!({"ok": true}))
155            .await;
156        response.assert_status(StatusCode::OK);
157        assert_eq!(response.json::<serde_json::Value>().unwrap()["ok"], true);
158    }
159}