1#![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#[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}