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)]
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}