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