Skip to main content

sofie_testing/
request.rs

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/// Its value is never printed.
29#[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
168/// Header values are left out: a cookie's would print a session.
169impl 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    /// Why a 500 failed, as the server logged it; a debug build's test server alone sends it.
221    #[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}