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, PartialEq, Eq, Serialize)]
12pub struct FixtureIdentity {
13    pub namespace: String,
14    pub kind: String,
15    pub ordinal: u64,
16    pub stable_id: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct FixtureError {
21    message: String,
22}
23
24impl std::fmt::Display for FixtureError {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        formatter.write_str(&self.message)
27    }
28}
29
30impl std::error::Error for FixtureError {}
31
32/// Produces deterministic identities without coupling fixtures to an ORM,
33/// database, wall clock, random-number generator, or global process state.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FixtureSequence {
36    namespace: String,
37    next_ordinal: u64,
38}
39
40impl FixtureSequence {
41    pub fn new(namespace: impl Into<String>) -> Result<Self, FixtureError> {
42        let namespace = namespace.into();
43        validate_fixture_part(&namespace, "fixture namespace")?;
44        Ok(Self {
45            namespace,
46            next_ordinal: 1,
47        })
48    }
49
50    pub fn next(&mut self, kind: &str) -> Result<FixtureIdentity, FixtureError> {
51        validate_fixture_part(kind, "fixture kind")?;
52        let ordinal = self.next_ordinal;
53        let next_ordinal = ordinal.checked_add(1).ok_or_else(|| FixtureError {
54            message: "fixture sequence exhausted its ordinal range".into(),
55        })?;
56        let identity = FixtureIdentity {
57            namespace: self.namespace.clone(),
58            kind: kind.to_owned(),
59            ordinal,
60            stable_id: format!("{}:{kind}:{ordinal:08}", self.namespace),
61        };
62        self.next_ordinal = next_ordinal;
63        Ok(identity)
64    }
65
66    pub fn build<T>(
67        &mut self,
68        kind: &str,
69        builder: impl FnOnce(FixtureIdentity) -> T,
70    ) -> Result<T, FixtureError> {
71        self.next(kind).map(builder)
72    }
73}
74
75fn validate_fixture_part(value: &str, label: &str) -> Result<(), FixtureError> {
76    let valid = !value.is_empty()
77        && value.len() <= 64
78        && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
79        && value
80            .as_bytes()
81            .last()
82            .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
83        && value
84            .bytes()
85            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
86        && !value.contains("--");
87    if valid {
88        Ok(())
89    } else {
90        Err(FixtureError {
91            message: format!(
92                "{label} must be a lowercase kebab-case identifier of at most 64 bytes"
93            ),
94        })
95    }
96}
97
98#[derive(Debug, Clone)]
99pub struct TestClient {
100    router: Router,
101    default_headers: HeaderMap,
102}
103
104impl TestClient {
105    #[must_use]
106    pub fn new(router: Router) -> Self {
107        Self {
108            router,
109            default_headers: HeaderMap::new(),
110        }
111    }
112
113    #[must_use]
114    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
115        self.default_headers.insert(name, value);
116        self
117    }
118
119    pub async fn get(&self, uri: &str) -> TestResponse {
120        self.request(Method::GET, uri, HeaderMap::new(), Body::empty())
121            .await
122    }
123
124    pub async fn json<T: Serialize>(&self, method: Method, uri: &str, body: &T) -> TestResponse {
125        let body = serde_json::to_vec(body).expect("test request serialization must succeed");
126        let mut headers = HeaderMap::new();
127        headers.insert(
128            header::CONTENT_TYPE,
129            HeaderValue::from_static("application/json"),
130        );
131        self.request(method, uri, headers, Body::from(body)).await
132    }
133
134    pub async fn request(
135        &self,
136        method: Method,
137        uri: &str,
138        request_headers: HeaderMap,
139        body: Body,
140    ) -> TestResponse {
141        let mut request = Request::builder()
142            .method(method)
143            .uri(uri)
144            .body(body)
145            .expect("valid test request");
146        let mut headers = self.default_headers.clone();
147        headers.extend(request_headers);
148        *request.headers_mut() = headers;
149        let response = self
150            .router
151            .clone()
152            .oneshot(request)
153            .await
154            .expect("Axum Router uses an infallible service error");
155        let status = response.status();
156        let headers = response.headers().clone();
157        let body = response
158            .into_body()
159            .collect()
160            .await
161            .expect("response body collection must succeed")
162            .to_bytes()
163            .to_vec();
164        TestResponse {
165            status,
166            headers,
167            body,
168        }
169    }
170}
171
172#[derive(Debug, Clone)]
173pub struct TestResponse {
174    pub status: StatusCode,
175    pub headers: HeaderMap,
176    pub body: Vec<u8>,
177}
178
179impl TestResponse {
180    pub fn json<T: DeserializeOwned>(&self) -> serde_json::Result<T> {
181        serde_json::from_slice(&self.body)
182    }
183
184    #[must_use]
185    pub fn text(&self) -> String {
186        String::from_utf8_lossy(&self.body).into_owned()
187    }
188
189    pub fn assert_status(&self, expected: StatusCode) {
190        assert_eq!(self.status, expected, "response body: {}", self.text());
191    }
192}
193
194#[derive(Debug, Clone, Serialize)]
195pub struct CommandEvidence {
196    pub command: Vec<String>,
197    pub success: bool,
198    pub exit_code: Option<i32>,
199    pub stdout: String,
200    pub stderr: String,
201    pub environment: BTreeMap<String, String>,
202}
203
204impl CommandEvidence {
205    #[must_use]
206    pub fn from_output(command: Vec<String>, output: &Output) -> Self {
207        Self {
208            command,
209            success: output.status.success(),
210            exit_code: output.status.code(),
211            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
212            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
213            environment: BTreeMap::new(),
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use axum::{
222        extract::Json,
223        routing::{get, post},
224    };
225
226    #[tokio::test]
227    async fn client_exercises_router_without_a_socket() {
228        let client = TestClient::new(Router::new().route("/health", get(|| async { "ok" })));
229        let response = client.get("/health").await;
230        response.assert_status(StatusCode::OK);
231        assert_eq!(response.text(), "ok");
232    }
233
234    #[tokio::test]
235    async fn json_requests_set_the_content_type() {
236        let router = Router::new().route(
237            "/echo",
238            post(|Json(value): Json<serde_json::Value>| async move { Json(value) }),
239        );
240        let response = TestClient::new(router)
241            .json(Method::POST, "/echo", &serde_json::json!({"ok": true}))
242            .await;
243        response.assert_status(StatusCode::OK);
244        assert_eq!(response.json::<serde_json::Value>().unwrap()["ok"], true);
245    }
246}