Skip to main content

rhood_core/client/
transport.rs

1//! Authenticated HTTP verb helpers for [`RobinhoodClient`].
2//!
3//! Wraps reqwest with the auth header, futures contract header, pagination
4//! follow-through, and uniform 4xx/5xx/429 handling so endpoint methods stay
5//! free of transport boilerplate.
6
7use super::{
8    DEFAULT_RETRY_AFTER_SECS, FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE,
9    RobinhoodClient,
10};
11use crate::pagination::{CursorPaginatedResponse, PaginatedResponse};
12use crate::{Result, RhoodError};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15
16impl RobinhoodClient {
17    /// Sends an authenticated GET request and deserializes the JSON response.
18    ///
19    /// # Errors
20    ///
21    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
22    /// or a transport/API error on failure.
23    pub async fn get<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
24        let auth = self.require_auth().await?;
25        let res = self
26            .http
27            .get(url)
28            .header("Authorization", &auth)
29            .send()
30            .await?;
31        handle_response(res).await
32    }
33
34    /// Sends an authenticated GET request with query parameters and deserializes
35    /// the JSON response.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
40    /// or a transport/API error on failure.
41    pub async fn get_with_params<T: DeserializeOwned>(
42        &self,
43        url: &str,
44        params: &[(&str, &str)],
45    ) -> Result<T> {
46        let auth = self.require_auth().await?;
47        let res = self
48            .http
49            .get(url)
50            .header("Authorization", &auth)
51            .query(params)
52            .send()
53            .await?;
54        handle_response(res).await
55    }
56
57    /// Sends an authenticated GET request and follows pagination links to collect
58    /// all results into a single `Vec`.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
63    /// or a transport/API error on failure.
64    pub async fn get_paginated<T: DeserializeOwned>(
65        &self,
66        url: &str,
67        params: &[(&str, &str)],
68    ) -> Result<Vec<T>> {
69        let auth = self.require_auth().await?;
70        let res = self
71            .http
72            .get(url)
73            .header("Authorization", &auth)
74            .query(params)
75            .send()
76            .await?;
77        let mut page: PaginatedResponse<T> = handle_response(res).await?;
78        let mut all_results = page.results;
79        while let Some(next_url) = page.next {
80            let res = self
81                .http
82                .get(&next_url)
83                .header("Authorization", &auth)
84                .send()
85                .await?;
86            page = handle_response(res).await?;
87            all_results.extend(page.results);
88        }
89        Ok(all_results)
90    }
91
92    /// Sends an authenticated GET request with the futures contract header.
93    ///
94    /// All Robinhood futures endpoints require `Rh-Contract-Protected: true`.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
99    /// or a transport/API error on failure.
100    pub(crate) async fn get_futures<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
101        let auth = self.require_auth().await?;
102        let res = self
103            .http
104            .get(url)
105            .header("Authorization", &auth)
106            .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
107            .send()
108            .await?;
109        handle_response(res).await
110    }
111
112    /// Sends an authenticated GET request with query params and the futures
113    /// contract header.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
118    /// or a transport/API error on failure.
119    pub(crate) async fn get_futures_with_params<T: DeserializeOwned>(
120        &self,
121        url: &str,
122        params: &[(&str, &str)],
123    ) -> Result<T> {
124        let auth = self.require_auth().await?;
125        let res = self
126            .http
127            .get(url)
128            .header("Authorization", &auth)
129            .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
130            .query(params)
131            .send()
132            .await?;
133        handle_response(res).await
134    }
135
136    /// Sends authenticated GET requests with the futures contract header,
137    /// following cursor-based pagination to collect all results.
138    ///
139    /// Unlike [`get_paginated`](Self::get_paginated), this re-requests the same
140    /// base URL with `cursor=<token>` appended to the original params.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
145    /// or a transport/API error on failure.
146    pub(crate) async fn get_futures_cursor_paginated<T: DeserializeOwned>(
147        &self,
148        url: &str,
149        params: &[(&str, &str)],
150    ) -> Result<Vec<T>> {
151        let auth = self.require_auth().await?;
152        let res = self
153            .http
154            .get(url)
155            .header("Authorization", &auth)
156            .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
157            .query(params)
158            .send()
159            .await?;
160        let mut page: CursorPaginatedResponse<T> = handle_response(res).await?;
161        let mut all_results = page.results;
162        while let Some(cursor) = page.next {
163            let mut next_params: Vec<(&str, &str)> = params.to_vec();
164            let cursor_owned = cursor;
165            next_params.push(("cursor", &cursor_owned));
166            let res = self
167                .http
168                .get(url)
169                .header("Authorization", &auth)
170                .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
171                .query(&next_params)
172                .send()
173                .await?;
174            page = handle_response(res).await?;
175            all_results.extend(page.results);
176        }
177        Ok(all_results)
178    }
179
180    /// Sends an authenticated POST request with a form-encoded body and
181    /// deserializes the JSON response.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
186    /// or a transport/API error on failure.
187    pub async fn post_form<T: DeserializeOwned, P: Serialize + ?Sized>(
188        &self,
189        url: &str,
190        payload: &P,
191    ) -> Result<T> {
192        let auth = self.require_auth().await?;
193        let res = self
194            .http
195            .post(url)
196            .header("Authorization", &auth)
197            .form(payload)
198            .send()
199            .await?;
200        handle_response(res).await
201    }
202
203    /// Sends an authenticated POST request with a JSON body and deserializes
204    /// the JSON response.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
209    /// or a transport/API error on failure.
210    pub async fn post_json<T: DeserializeOwned, P: Serialize + ?Sized>(
211        &self,
212        url: &str,
213        payload: &P,
214    ) -> Result<T> {
215        let auth = self.require_auth().await?;
216        let res = self
217            .http
218            .post(url)
219            .header("Authorization", &auth)
220            .json(payload)
221            .send()
222            .await?;
223        handle_response(res).await
224    }
225
226    /// Sends an authenticated POST request with an empty body.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
231    /// or a transport/API error on failure.
232    pub async fn post_empty(&self, url: &str) -> Result<()> {
233        let auth = self.require_auth().await?;
234        let res = self
235            .http
236            .post(url)
237            .header("Authorization", &auth)
238            .send()
239            .await?;
240        let status = res.status();
241        let body = res.text().await.unwrap_or_default();
242        tracing::debug!(
243            status = status.as_u16(),
244            url = %url,
245            body_len = body.len(),
246            "API response"
247        );
248        if status.is_success() {
249            Ok(())
250        } else {
251            Err(RhoodError::Api {
252                status: status.as_u16(),
253                message: redacted_response_body_message(&body),
254            })
255        }
256    }
257
258    /// Sends an authenticated DELETE request.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
263    /// or a transport/API error on failure.
264    pub async fn delete(&self, url: &str) -> Result<()> {
265        let auth = self.require_auth().await?;
266        let res = self
267            .http
268            .delete(url)
269            .header("Authorization", &auth)
270            .send()
271            .await?;
272        let status = res.status();
273        let body = res.text().await.unwrap_or_default();
274        tracing::debug!(
275            status = status.as_u16(),
276            url = %url,
277            body_len = body.len(),
278            "API response"
279        );
280        if status.is_success() {
281            Ok(())
282        } else {
283            Err(RhoodError::Api {
284                status: status.as_u16(),
285                message: redacted_response_body_message(&body),
286            })
287        }
288    }
289
290    /// Sends an authenticated PATCH request with a JSON body and deserializes
291    /// the JSON response.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`RhoodError::NotAuthenticated`] if the client is not logged in,
296    /// or a transport/API error on failure.
297    pub async fn patch_json<T: DeserializeOwned, P: Serialize + ?Sized>(
298        &self,
299        url: &str,
300        payload: &P,
301    ) -> Result<T> {
302        let auth = self.require_auth().await?;
303        let res = self
304            .http
305            .patch(url)
306            .header("Authorization", &auth)
307            .json(payload)
308            .send()
309            .await?;
310        handle_response(res).await
311    }
312}
313
314async fn handle_response<T: DeserializeOwned>(res: reqwest::Response) -> Result<T> {
315    let status = res.status();
316    if status.as_u16() == 429 {
317        let retry_after = res
318            .headers()
319            .get("retry-after")
320            .and_then(|header| header.to_str().ok())
321            .and_then(|text| text.parse().ok())
322            .unwrap_or(DEFAULT_RETRY_AFTER_SECS);
323        return Err(RhoodError::RateLimited {
324            retry_after_secs: retry_after,
325        });
326    }
327    let url = res.url().clone();
328    let body = res.text().await.unwrap_or_default();
329    tracing::debug!(
330        status = status.as_u16(),
331        url = %url,
332        body_len = body.len(),
333        "API response"
334    );
335    if !status.is_success() {
336        return Err(RhoodError::Api {
337            status: status.as_u16(),
338            message: redacted_response_body_message(&body),
339        });
340    }
341    serde_json::from_str::<T>(&body).map_err(|e| RhoodError::Api {
342        status: status.as_u16(),
343        message: format!("Failed to parse response from {url}: {e}"),
344    })
345}
346
347pub(super) fn redacted_response_body_message(body: &str) -> String {
348    let Ok(serde_json::Value::Object(fields)) = serde_json::from_str(body) else {
349        return format!("Response body omitted ({} bytes)", body.len());
350    };
351
352    let mut diagnostic = serde_json::Map::new();
353    if let Some(instruments) = fields
354        .get("missing_instruments")
355        .and_then(serde_json::Value::as_array)
356    {
357        diagnostic.insert(
358            "missing_instruments".to_string(),
359            serde_json::Value::Array(
360                instruments
361                    .iter()
362                    .filter_map(serde_json::Value::as_str)
363                    .map(|instrument| serde_json::Value::String(instrument.to_string()))
364                    .collect(),
365            ),
366        );
367    }
368    for key in ["detail", "message"] {
369        if let Some(value) = fields
370            .get(key)
371            .and_then(serde_json::Value::as_str)
372            .map(|value| serde_json::Value::String(value.to_string()))
373        {
374            diagnostic.insert(key.to_string(), value);
375        }
376    }
377    if let Some(message) = fields
378        .get("error")
379        .and_then(serde_json::Value::as_object)
380        .and_then(|error| error.get("message"))
381        .and_then(serde_json::Value::as_str)
382    {
383        diagnostic.insert(
384            "error".to_string(),
385            serde_json::json!({ "message": message }),
386        );
387    }
388
389    if diagnostic.is_empty() {
390        format!("Response body omitted ({} bytes)", body.len())
391    } else {
392        serde_json::Value::Object(diagnostic).to_string()
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::super::test_config_with_tempdir;
399    use super::*;
400    use crate::auth::AuthState;
401    use secrecy::SecretString;
402    use serde::Deserialize;
403    use wiremock::matchers::{
404        body_string_contains, header, method, path, query_param, query_param_is_missing,
405    };
406    use wiremock::{Mock, MockServer, ResponseTemplate};
407
408    #[derive(Debug, Deserialize, PartialEq, Eq)]
409    struct TestBody {
410        value: String,
411    }
412
413    async fn authenticated_client(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
414        let dir = tempfile::tempdir().unwrap();
415        let mut config = test_config_with_tempdir(&dir);
416        config.api.base_url = base_url.to_string();
417        let client = RobinhoodClient::with_config(config).unwrap();
418        *client.auth_state.write().await = AuthState::Authenticated {
419            access_token: SecretString::from("access-token"),
420            token_type: "Bearer".to_string(),
421            refresh_token: SecretString::from("refresh-token"),
422        };
423        (dir, client)
424    }
425
426    #[test]
427    fn futures_header_constant_is_correct() {
428        assert_eq!(FUTURES_CONTRACT_HEADER, "Rh-Contract-Protected");
429        assert_eq!(FUTURES_CONTRACT_HEADER_VALUE, "true");
430    }
431
432    #[tokio::test]
433    async fn get_sends_auth_header_and_deserializes_json() {
434        let server = MockServer::start().await;
435        Mock::given(method("GET"))
436            .and(path("/plain"))
437            .and(header("Authorization", "Bearer access-token"))
438            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
439                "value": "ok"
440            })))
441            .mount(&server)
442            .await;
443        let (_dir, client) = authenticated_client(&server.uri()).await;
444
445        let body: TestBody = client
446            .get(&format!("{}/plain", server.uri()))
447            .await
448            .unwrap();
449
450        assert_eq!(body, TestBody { value: "ok".into() });
451    }
452
453    #[tokio::test]
454    async fn get_with_params_sends_query_params() {
455        let server = MockServer::start().await;
456        Mock::given(method("GET"))
457            .and(path("/search"))
458            .and(query_param("symbol", "HOOD"))
459            .and(query_param("active", "true"))
460            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
461                "value": "found"
462            })))
463            .mount(&server)
464            .await;
465        let (_dir, client) = authenticated_client(&server.uri()).await;
466
467        let body: TestBody = client
468            .get_with_params(
469                &format!("{}/search", server.uri()),
470                &[("symbol", "HOOD"), ("active", "true")],
471            )
472            .await
473            .unwrap();
474
475        assert_eq!(body.value, "found");
476    }
477
478    #[tokio::test]
479    async fn get_paginated_follows_next_links() {
480        let server = MockServer::start().await;
481        let next_url = format!("{}/page-2", server.uri());
482        Mock::given(method("GET"))
483            .and(path("/page-1"))
484            .and(query_param("nonzero", "true"))
485            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
486                "results": [1, 2],
487                "next": next_url,
488                "previous": null
489            })))
490            .mount(&server)
491            .await;
492        Mock::given(method("GET"))
493            .and(path("/page-2"))
494            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
495                "results": [3],
496                "next": null,
497                "previous": null
498            })))
499            .mount(&server)
500            .await;
501        let (_dir, client) = authenticated_client(&server.uri()).await;
502
503        let values: Vec<i32> = client
504            .get_paginated(&format!("{}/page-1", server.uri()), &[("nonzero", "true")])
505            .await
506            .unwrap();
507
508        assert_eq!(values, vec![1, 2, 3]);
509    }
510
511    #[tokio::test]
512    async fn futures_gets_include_contract_header_and_cursor_pagination() {
513        let server = MockServer::start().await;
514        Mock::given(method("GET"))
515            .and(path("/futures"))
516            .and(header(
517                FUTURES_CONTRACT_HEADER,
518                FUTURES_CONTRACT_HEADER_VALUE,
519            ))
520            .and(query_param("symbol", "ES"))
521            .and(query_param_is_missing("cursor"))
522            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
523                "results": [10],
524                "next": "cursor-2"
525            })))
526            .mount(&server)
527            .await;
528        Mock::given(method("GET"))
529            .and(path("/futures"))
530            .and(header(
531                FUTURES_CONTRACT_HEADER,
532                FUTURES_CONTRACT_HEADER_VALUE,
533            ))
534            .and(query_param("symbol", "ES"))
535            .and(query_param("cursor", "cursor-2"))
536            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
537                "results": [20],
538                "next": null
539            })))
540            .mount(&server)
541            .await;
542        let (_dir, client) = authenticated_client(&server.uri()).await;
543
544        let values: Vec<i32> = client
545            .get_futures_cursor_paginated(&format!("{}/futures", server.uri()), &[("symbol", "ES")])
546            .await
547            .unwrap();
548
549        assert_eq!(values, vec![10, 20]);
550    }
551
552    #[tokio::test]
553    async fn get_futures_with_params_includes_header_and_query() {
554        let server = MockServer::start().await;
555        Mock::given(method("GET"))
556            .and(path("/futures/quote"))
557            .and(header(
558                FUTURES_CONTRACT_HEADER,
559                FUTURES_CONTRACT_HEADER_VALUE,
560            ))
561            .and(query_param("symbol", "ES"))
562            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
563                "value": "quote"
564            })))
565            .mount(&server)
566            .await;
567        let (_dir, client) = authenticated_client(&server.uri()).await;
568
569        let body: TestBody = client
570            .get_futures_with_params(
571                &format!("{}/futures/quote", server.uri()),
572                &[("symbol", "ES")],
573            )
574            .await
575            .unwrap();
576
577        assert_eq!(body.value, "quote");
578    }
579
580    #[tokio::test]
581    async fn post_helpers_send_expected_body_shapes() {
582        let server = MockServer::start().await;
583        Mock::given(method("POST"))
584            .and(path("/form"))
585            .and(body_string_contains("name=rhood"))
586            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
587                "value": "form-ok"
588            })))
589            .mount(&server)
590            .await;
591        Mock::given(method("POST"))
592            .and(path("/json"))
593            .and(body_string_contains(r#""name":"rhood""#))
594            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
595                "value": "json-ok"
596            })))
597            .mount(&server)
598            .await;
599        Mock::given(method("POST"))
600            .and(path("/empty"))
601            .respond_with(ResponseTemplate::new(204))
602            .mount(&server)
603            .await;
604        let (_dir, client) = authenticated_client(&server.uri()).await;
605
606        let form: TestBody = client
607            .post_form(&format!("{}/form", server.uri()), &[("name", "rhood")])
608            .await
609            .unwrap();
610        let json: TestBody = client
611            .post_json(
612                &format!("{}/json", server.uri()),
613                &serde_json::json!({ "name": "rhood" }),
614            )
615            .await
616            .unwrap();
617        client
618            .post_empty(&format!("{}/empty", server.uri()))
619            .await
620            .unwrap();
621
622        assert_eq!(form.value, "form-ok");
623        assert_eq!(json.value, "json-ok");
624    }
625
626    #[tokio::test]
627    async fn delete_and_patch_json_handle_successful_responses() {
628        let server = MockServer::start().await;
629        Mock::given(method("DELETE"))
630            .and(path("/resource"))
631            .respond_with(ResponseTemplate::new(200).set_body_string("deleted"))
632            .mount(&server)
633            .await;
634        Mock::given(method("PATCH"))
635            .and(path("/resource"))
636            .and(body_string_contains(r#""enabled":true"#))
637            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
638                "value": "patched"
639            })))
640            .mount(&server)
641            .await;
642        let (_dir, client) = authenticated_client(&server.uri()).await;
643
644        client
645            .delete(&format!("{}/resource", server.uri()))
646            .await
647            .unwrap();
648        let body: TestBody = client
649            .patch_json(
650                &format!("{}/resource", server.uri()),
651                &serde_json::json!({ "enabled": true }),
652            )
653            .await
654            .unwrap();
655
656        assert_eq!(body.value, "patched");
657    }
658
659    #[tokio::test]
660    async fn handle_response_maps_rate_limit_with_header() {
661        let server = MockServer::start().await;
662        Mock::given(method("GET"))
663            .and(path("/limited"))
664            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "17"))
665            .mount(&server)
666            .await;
667        let (_dir, client) = authenticated_client(&server.uri()).await;
668
669        let err = client
670            .get::<serde_json::Value>(&format!("{}/limited", server.uri()))
671            .await
672            .unwrap_err();
673
674        assert!(matches!(
675            err,
676            RhoodError::RateLimited {
677                retry_after_secs: 17
678            }
679        ));
680    }
681
682    #[tokio::test]
683    async fn handle_response_uses_default_rate_limit_when_header_is_invalid() {
684        let server = MockServer::start().await;
685        Mock::given(method("GET"))
686            .and(path("/limited"))
687            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "soon"))
688            .mount(&server)
689            .await;
690        let (_dir, client) = authenticated_client(&server.uri()).await;
691
692        let err = client
693            .get::<serde_json::Value>(&format!("{}/limited", server.uri()))
694            .await
695            .unwrap_err();
696
697        assert!(matches!(
698            err,
699            RhoodError::RateLimited {
700                retry_after_secs: DEFAULT_RETRY_AFTER_SECS
701            }
702        ));
703    }
704
705    #[tokio::test]
706    async fn handle_response_maps_api_and_parse_errors() {
707        let server = MockServer::start().await;
708        let token = "access-token-should-not-appear";
709        Mock::given(method("GET"))
710            .and(path("/api-error"))
711            .respond_with(
712                ResponseTemplate::new(503)
713                    .set_body_string(format!(r#"{{"access_token":"{token}"}}"#)),
714            )
715            .mount(&server)
716            .await;
717        Mock::given(method("GET"))
718            .and(path("/bad-json"))
719            .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
720            .mount(&server)
721            .await;
722        let (_dir, client) = authenticated_client(&server.uri()).await;
723
724        let api_err = client
725            .get::<serde_json::Value>(&format!("{}/api-error", server.uri()))
726            .await
727            .unwrap_err();
728        let parse_err = client
729            .get::<serde_json::Value>(&format!("{}/bad-json", server.uri()))
730            .await
731            .unwrap_err();
732
733        assert!(matches!(
734            api_err,
735            RhoodError::Api {
736                status: 503,
737                message
738            } if message.starts_with("Response body omitted (") && !message.contains(token)
739        ));
740        assert!(matches!(
741            parse_err,
742            RhoodError::Api {
743                status: 200,
744                message
745            } if message.contains("Failed to parse response")
746        ));
747    }
748
749    #[tokio::test]
750    async fn transport_error_preserves_missing_instruments_for_display() {
751        let server = MockServer::start().await;
752        Mock::given(method("GET"))
753            .and(path("/missing-instrument"))
754            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
755                "missing_instruments": ["NOTAREALSYM"]
756            })))
757            .mount(&server)
758            .await;
759        let (_dir, client) = authenticated_client(&server.uri()).await;
760
761        let err = client
762            .get::<serde_json::Value>(&format!("{}/missing-instrument", server.uri()))
763            .await
764            .unwrap_err();
765
766        assert!(format!("{err}").contains("NOTAREALSYM"));
767    }
768
769    #[tokio::test]
770    async fn transport_error_preserves_allowlisted_diagnostic_without_token() {
771        let server = MockServer::start().await;
772        let token = "access-token-should-not-appear";
773        Mock::given(method("GET"))
774            .and(path("/insufficient-buying-power"))
775            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
776                "detail": "insufficient buying power",
777                "access_token": token
778            })))
779            .mount(&server)
780            .await;
781        let (_dir, client) = authenticated_client(&server.uri()).await;
782
783        let err = client
784            .get::<serde_json::Value>(&format!("{}/insufficient-buying-power", server.uri()))
785            .await
786            .unwrap_err();
787        let display = format!("{err}");
788
789        assert!(display.contains("insufficient buying power"));
790        assert!(!display.contains(token));
791    }
792}