crux_http/testing/rejection.rs
1use crate::{RawResponse, Response, Result, protocol::HttpResponse};
2
3/// Build the [`crux_http::Result`](crate::Result) a feature receives when the server
4/// rejects a request.
5///
6/// A 4xx or 5xx response never reaches an app as `Ok(Response)` — `crux_http` converts it
7/// to an [`HttpError::Http`](crate::HttpError::Http) on the `Err` side, keeping the body,
8/// before the event is sent. So this, and not a
9/// [`ResponseBuilder`](super::ResponseBuilder) with an error status, is the value to assert
10/// against (or feed to an update function) when testing how a feature handles a rejection:
11///
12/// ```
13/// # use crux_http::testing::rejection;
14/// # #[derive(Debug, PartialEq)] enum Event { Saved(crux_http::Result<crux_http::Response<Vec<u8>>>) }
15/// let event = Event::Saved(rejection(409, r#"{"error":"that overlaps a booked day"}"#));
16///
17/// let Event::Saved(Err(error)) = event else {
18/// panic!("a 409 is delivered as an error, not a response")
19/// };
20/// assert_eq!(error.code(), Some(409));
21/// assert_eq!(error.to_string(), "HTTP error 409: 409 Conflict");
22/// ```
23///
24/// The `Body` type parameter is the one the app's event carries (`Vec<u8>`, `String`, or
25/// whatever [`expect_json`](crate::command::RequestBuilder::expect_json) decodes to); it
26/// is usually inferred, and never appears in the value, because body decoding is skipped
27/// for an error status.
28///
29/// The status and reason phrase are produced by the same code path a real response takes,
30/// so the value is byte-for-byte what the shell's response would have produced.
31///
32/// # Errors
33///
34/// Always `Err` — a rejection has no other form. The `Result` is the return type so that
35/// the value can be used exactly where the app receives one.
36///
37/// # Panics
38///
39/// Panics if `status` is outside the valid HTTP range (100–999), or if it is not a client
40/// (4xx) or server (5xx) error — for any other status a feature receives
41/// `Ok(Response)`, which is what [`ResponseBuilder`](super::ResponseBuilder) builds.
42pub fn rejection<Body>(status: u16, body: impl AsRef<[u8]>) -> Result<Response<Body>> {
43 build_rejection(
44 "rejection",
45 HttpResponse::status(status)
46 .body(body.as_ref().to_vec())
47 .build(),
48 )
49}
50
51/// Build the [`crux_http::Result`](crate::Result) a feature receives for a rejection, from
52/// a full protocol response.
53///
54/// Use this over [`rejection`] when the rejection's *headers* are what the feature acts on
55/// — `Retry-After`, `WWW-Authenticate`, `Content-Type` — since those are readable from the
56/// error via [`HttpError::header`](crate::HttpError::header) and
57/// [`HttpError::content_type`](crate::HttpError::content_type):
58///
59/// ```
60/// # use crux_http::{protocol::HttpResponse, testing::rejection_from};
61/// let result = rejection_from::<Vec<u8>>(
62/// HttpResponse::status(503)
63/// .header("retry-after", "120")
64/// .body(b"maintenance".to_vec())
65/// .build(),
66/// );
67///
68/// let error = result.expect_err("a 503 is never Ok");
69/// assert_eq!(error.header("retry-after").unwrap(), "120");
70/// assert_eq!(error.body(), Some(&b"maintenance"[..]));
71/// ```
72///
73/// It takes the same [`HttpResponse`] you would resolve a request with in an end-to-end
74/// test, so the two styles of test describe a rejection the same way.
75///
76/// # Errors
77///
78/// Always `Err`, for the reason given on [`rejection`].
79///
80/// # Panics
81///
82/// Panics if the response's status is outside the valid HTTP range (100–999), or if it is
83/// not a client (4xx) or server (5xx) error.
84pub fn rejection_from<Body>(response: HttpResponse) -> Result<Response<Body>> {
85 build_rejection("rejection_from", response)
86}
87
88/// The shared body of [`rejection`] and [`rejection_from`]. `caller` names whichever of
89/// them the test actually called, so a panic points at the right function.
90fn build_rejection<Body>(caller: &str, response: HttpResponse) -> Result<Response<Body>> {
91 let status = response.status;
92 let raw = RawResponse::try_from(response).unwrap_or_else(|_| {
93 panic!("{caller} called with an out-of-range status code ({status}, must be 100–999)")
94 });
95
96 match Response::<Vec<u8>>::new(raw) {
97 Err(error) => Err(error),
98 Ok(_) => panic!(
99 "{caller} called with status {status}, which is not a client (4xx) or server (5xx) \
100 error — a feature receives that as Ok(Response), which ResponseBuilder builds"
101 ),
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use crate::{HttpError, protocol::HttpResponse};
108
109 use super::{rejection, rejection_from};
110
111 #[test]
112 fn carries_code_reason_and_body() {
113 let error = rejection::<Vec<u8>>(409, r#"{"error":"management cycle"}"#)
114 .expect_err("4xx is always an error");
115
116 let HttpError::Http {
117 code,
118 message,
119 headers,
120 body,
121 } = error
122 else {
123 panic!("expected HttpError::Http, got {error:?}")
124 };
125 assert_eq!(code, 409);
126 assert_eq!(message, "409 Conflict");
127 assert!(headers.is_empty(), "rejection() sets no headers");
128 assert_eq!(body, br#"{"error":"management cycle"}"#);
129 }
130
131 /// `rejection_from` is the header-carrying form, and must agree with `rejection` when
132 /// there are no headers to carry.
133 #[test]
134 fn rejection_is_the_header_less_case_of_rejection_from() {
135 let sugar = rejection::<Vec<u8>>(409, "nope");
136 let explicit =
137 rejection_from::<Vec<u8>>(HttpResponse::status(409).body(b"nope".to_vec()).build());
138
139 assert_eq!(sugar, explicit);
140 }
141
142 #[test]
143 fn rejection_from_keeps_the_headers() {
144 let error = rejection_from::<Vec<u8>>(
145 HttpResponse::status(401)
146 .header("www-authenticate", r#"Bearer error="invalid_token""#)
147 .header("content-type", "application/problem+json")
148 .build(),
149 )
150 .expect_err("a 401 is never Ok");
151
152 assert_eq!(
153 error.header("www-authenticate").unwrap(),
154 r#"Bearer error="invalid_token""#
155 );
156 assert_eq!(
157 error.content_type().map(|mime| mime.to_string()),
158 Some("application/problem+json".to_string())
159 );
160 }
161
162 /// The whole point of the helper: what it produces must be indistinguishable from
163 /// what the real shell → `Response::new` path produces, or tests written against it
164 /// would once again be asserting a shape features never see.
165 #[test]
166 fn matches_the_real_conversion() {
167 let from_helper =
168 rejection::<String>(422, "name is required").expect_err("4xx is always an error");
169
170 let raw = crate::RawResponse::try_from(
171 HttpResponse::status(422)
172 .body(b"name is required".to_vec())
173 .build(),
174 )
175 .expect("422 is a valid status");
176 let from_shell = crate::Response::<Vec<u8>>::new(raw).expect_err("4xx is always an error");
177
178 assert_eq!(from_helper, from_shell);
179 }
180
181 #[test]
182 fn body_is_optional() {
183 let error = rejection::<Vec<u8>>(404, "").expect_err("4xx is always an error");
184 assert_eq!(error.code(), Some(404));
185 assert_eq!(error.body(), None);
186 }
187
188 #[test]
189 #[should_panic(expected = "rejection called with status 200, which is not a client")]
190 fn refuses_a_success_status() {
191 let _ = rejection::<Vec<u8>>(200, "");
192 }
193
194 /// A panic must name the function the test called, not the one it delegates to.
195 #[test]
196 #[should_panic(expected = "rejection_from called with status 200, which is not a client")]
197 fn rejection_from_refuses_a_success_status() {
198 let _ = rejection_from::<Vec<u8>>(HttpResponse::status(200).build());
199 }
200
201 #[test]
202 #[should_panic(expected = "rejection called with an out-of-range status code (99")]
203 fn refuses_an_out_of_range_status() {
204 let _ = rejection::<Vec<u8>>(99, "");
205 }
206}