1use std::fmt;
2use std::future::IntoFuture;
3use std::pin::Pin;
4
5use bytes::Bytes;
6use http::header::{ACCEPT, CONTENT_TYPE, COOKIE, SET_COOKIE};
7use http::request::Builder;
8use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode};
9use http_body_util::{BodyExt, Full};
10use hyper_util::client::legacy::Client;
11use hyper_util::client::legacy::connect::HttpConnector;
12use serde_json::Value;
13use sofie_core::encoding::percent;
14use sofie_http::CookieNames;
15
16pub(crate) type HttpClient = Client<HttpConnector, Full<Bytes>>;
17
18const JSON: &str = "application/json";
19
20#[must_use = "a request is sent only when awaited"]
21pub struct TestRequest {
22 client: HttpClient,
23 request: Builder,
24 body: Bytes,
25 cookies: CookieNames,
26}
27
28#[derive(Clone, PartialEq, Eq)]
30pub struct TestSession {
31 cookie: String,
32}
33
34impl TestSession {
35 pub(crate) fn new(cookie: String) -> Self {
36 Self { cookie }
37 }
38
39 pub(crate) fn cookie(&self) -> &str {
40 &self.cookie
41 }
42}
43
44impl fmt::Debug for TestSession {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 formatter.write_str("TestSession(…)")
47 }
48}
49
50pub struct TestResponse {
51 pub status: StatusCode,
52 pub headers: HeaderMap,
53 pub body: String,
54 cookies: CookieNames,
55}
56
57impl TestRequest {
58 pub(crate) fn new(
59 client: HttpClient,
60 method: Method,
61 url: String,
62 cookies: CookieNames,
63 ) -> Self {
64 Self {
65 client,
66 request: Request::builder().method(method).uri(url),
67 body: Bytes::new(),
68 cookies,
69 }
70 }
71
72 pub fn header(mut self, name: &str, value: &str) -> Self {
73 let name = HeaderName::try_from(name).expect("a valid header name");
74 let value = HeaderValue::try_from(value).expect("a valid header value");
75 if let Some(headers) = self.request.headers_mut() {
76 headers.insert(name, value);
77 }
78 self
79 }
80
81 pub fn accept_json(self) -> Self {
82 self.header(ACCEPT.as_str(), JSON)
83 }
84
85 pub fn json(self, body: &Value) -> Self {
86 Self {
87 body: Bytes::from(body.to_string()),
88 ..self.header(CONTENT_TYPE.as_str(), JSON).accept_json()
89 }
90 }
91
92 pub fn form(self, fields: &[(&str, &str)]) -> Self {
93 Self {
94 body: Bytes::from(percent::form(fields.iter().copied())),
95 ..self.header(CONTENT_TYPE.as_str(), "application/x-www-form-urlencoded")
96 }
97 }
98
99 pub fn body(self, body: impl Into<Bytes>) -> Self {
100 Self {
101 body: body.into(),
102 ..self
103 }
104 }
105
106 pub fn session(self, session: &TestSession) -> Self {
107 self.cookie(session).same_origin()
108 }
109
110 pub fn cookie(self, session: &TestSession) -> Self {
111 self.header(COOKIE.as_str(), &session.cookie)
112 }
113
114 pub fn htmx(self, target: &str) -> Self {
115 self.header("hx-request", "true")
116 .header("hx-target", target)
117 .same_origin()
118 }
119
120 fn same_origin(self) -> Self {
121 self.header("sec-fetch-site", "same-origin")
122 }
123
124 pub fn trigger(self, id: &str) -> Self {
125 self.header("hx-trigger", id)
126 }
127
128 async fn send(self) -> TestResponse {
129 let request = self
130 .request
131 .body(Full::new(self.body))
132 .expect("building the test request");
133 let response = self
134 .client
135 .request(request)
136 .await
137 .expect("sending the test request");
138 let (parts, body) = response.into_parts();
139 let body = body
140 .collect()
141 .await
142 .expect("reading the test response")
143 .to_bytes();
144 let response = TestResponse {
145 status: parts.status,
146 headers: parts.headers,
147 body: String::from_utf8_lossy(&body).into_owned(),
148 cookies: self.cookies,
149 };
150 if let Some(error) = response.internal_error() {
151 eprintln!("the request failed with {}: {error}", response.status);
152 }
153 response
154 }
155}
156
157impl IntoFuture for TestRequest {
158 type Output = TestResponse;
159 type IntoFuture = Pin<Box<dyn Future<Output = TestResponse> + Send>>;
160
161 fn into_future(self) -> Self::IntoFuture {
162 Box::pin(self.send())
163 }
164}
165
166const TEST_ERROR: &str = "x-sofie-test-error";
167
168impl fmt::Debug for TestResponse {
170 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
171 let names: Vec<&str> = self.headers.keys().map(http::HeaderName::as_str).collect();
172 formatter
173 .debug_struct("TestResponse")
174 .field("status", &self.status)
175 .field("headers", &names)
176 .field("body", &self.body)
177 .finish_non_exhaustive()
178 }
179}
180
181impl TestResponse {
182 #[must_use]
183 pub fn json(&self) -> Value {
184 serde_json::from_str(&self.body)
185 .unwrap_or_else(|error| panic!("the body is not JSON ({error}): {}", self.body))
186 }
187
188 #[must_use]
189 pub fn session(&self) -> Option<TestSession> {
190 self.headers
191 .get_all(SET_COOKIE)
192 .iter()
193 .filter_map(|value| value.to_str().ok())
194 .filter_map(|value| value.split(';').next())
195 .find(|pair| {
196 pair.strip_prefix(self.cookies.session())
197 .and_then(|rest| rest.strip_prefix('='))
198 .is_some_and(|value| !value.is_empty())
199 })
200 .map(|pair| TestSession {
201 cookie: pair.to_owned(),
202 })
203 }
204
205 pub fn assert_status(&self, status: u16) {
206 assert_eq!(
207 self.status.as_u16(),
208 status,
209 "{}{}",
210 self.body,
211 self.cause()
212 );
213 }
214
215 pub fn assert_ok(&self) {
216 let (status, body, cause) = (self.status, &self.body, self.cause());
217 assert!(status.is_success(), "{status}: {body}{cause}");
218 }
219
220 #[must_use]
222 pub fn internal_error(&self) -> Option<&str> {
223 self.headers.get(TEST_ERROR)?.to_str().ok()
224 }
225
226 fn cause(&self) -> String {
227 self.internal_error()
228 .map(|error| format!("\ncaused by: {error}"))
229 .unwrap_or_default()
230 }
231
232 #[must_use]
233 pub fn record_id(&self) -> sofie_core::Uuid {
234 self.assert_ok();
235 match self.json()["id"].as_str().map(str::parse) {
236 Some(Ok(id)) => id,
237 _ => panic!("the answer names no record by its id: {}", self.body),
238 }
239 }
240
241 #[must_use]
242 pub fn header(&self, name: &str) -> &str {
243 self.headers
244 .get(name)
245 .and_then(|value| value.to_str().ok())
246 .unwrap_or_default()
247 }
248}