Skip to main content

rustlavel_client/
fake.rs

1//! Faking outbound requests.
2//!
3//! This is `Http::fake()`. An application's tests must not depend on a third
4//! party being up, on network latency, or on a rate limit — and a test that
5//! calls a paid API for real is a test nobody runs twice.
6
7use crate::{ClientResponse, RequestBuilder};
8use rustlavel_core::{Error, Json, Result};
9use rustlavel_http::{Headers, Method, Status};
10use std::sync::Mutex;
11
12/// A scripted answer.
13#[derive(Debug, Clone)]
14pub struct FakeResponse {
15    pub status: Status,
16    pub headers: Headers,
17    pub body: Vec<u8>,
18}
19
20impl FakeResponse {
21    pub fn json(value: Json) -> Self {
22        let mut headers = Headers::new();
23        headers.set("content-type", "application/json");
24        FakeResponse { status: Status::OK, headers, body: value.to_string().into_bytes() }
25    }
26
27    pub fn text(body: impl Into<String>) -> Self {
28        FakeResponse { status: Status::OK, headers: Headers::new(), body: body.into().into_bytes() }
29    }
30
31    /// A server-sent event stream, for testing streaming responses.
32    pub fn events(chunks: &[&str]) -> Self {
33        let mut headers = Headers::new();
34        headers.set("content-type", "text/event-stream");
35        let body = chunks.iter().map(|c| format!("data: {c}\n\n")).collect::<String>();
36        FakeResponse { status: Status::OK, headers, body: body.into_bytes() }
37    }
38
39    pub fn status(mut self, status: u16) -> Self {
40        self.status = Status(status);
41        self
42    }
43}
44
45/// One request the fake saw.
46#[derive(Debug, Clone)]
47pub struct Recorded {
48    pub method: Method,
49    pub url: String,
50    pub headers: Headers,
51    pub body: Vec<u8>,
52}
53
54impl Recorded {
55    pub fn json(&self) -> Option<Json> {
56        Json::parse(&String::from_utf8_lossy(&self.body)).ok()
57    }
58
59    pub fn body_text(&self) -> String {
60        String::from_utf8_lossy(&self.body).into_owned()
61    }
62}
63
64/// A script of URL patterns to responses, plus a record of what was asked.
65#[derive(Default)]
66pub struct Fake {
67    /// Matched in order; the first pattern that matches wins.
68    routes: Vec<(String, FakeResponse)>,
69    fallback: Option<FakeResponse>,
70    recorded: Mutex<Vec<Recorded>>,
71}
72
73impl Fake {
74    pub fn new() -> Self {
75        Fake::default()
76    }
77
78    /// Answer any URL containing `pattern`, or matching it with a `*` wildcard.
79    pub fn on(mut self, pattern: &str, response: FakeResponse) -> Self {
80        self.routes.push((pattern.to_string(), response));
81        self
82    }
83
84    /// Answer anything not matched above. Without this, an unexpected request
85    /// is an error — a test should not silently pass because a call went
86    /// somewhere nobody scripted.
87    pub fn fallback(mut self, response: FakeResponse) -> Self {
88        self.fallback = Some(response);
89        self
90    }
91
92    pub(crate) fn respond(&self, request: &RequestBuilder) -> Result<ClientResponse> {
93        self.recorded.lock().expect("fake lock poisoned").push(Recorded {
94            method: request.method(),
95            url: request.url().to_string(),
96            headers: request.headers().clone(),
97            body: request.body_bytes().to_vec(),
98        });
99
100        let matched = self
101            .routes
102            .iter()
103            .find(|(pattern, _)| matches(pattern, request.url()))
104            .map(|(_, response)| response.clone())
105            .or_else(|| self.fallback.clone());
106
107        match matched {
108            Some(response) => Ok(ClientResponse {
109                status: response.status,
110                headers: response.headers,
111                body: response.body,
112            }),
113            None => Err(Error::msg(format!(
114                "no fake response is scripted for {} {}. Add `.on(\"…\", …)` or a `.fallback(…)`.",
115                request.method(),
116                request.url()
117            ))),
118        }
119    }
120
121    /// Every request the fake saw, in order.
122    pub fn recorded(&self) -> Vec<Recorded> {
123        self.recorded.lock().expect("fake lock poisoned").clone()
124    }
125
126    pub fn count(&self) -> usize {
127        self.recorded.lock().expect("fake lock poisoned").len()
128    }
129
130    /// Whether a request matching this pattern was sent.
131    pub fn sent(&self, pattern: &str) -> bool {
132        self.recorded().iter().any(|request| matches(pattern, &request.url))
133    }
134
135    #[track_caller]
136    pub fn assert_sent(&self, pattern: &str) {
137        assert!(
138            self.sent(pattern),
139            "expected a request matching {pattern:?}; saw {:?}",
140            self.recorded().iter().map(|r| r.url.clone()).collect::<Vec<_>>()
141        );
142    }
143
144    #[track_caller]
145    pub fn assert_not_sent(&self, pattern: &str) {
146        assert!(!self.sent(pattern), "did not expect a request matching {pattern:?}");
147    }
148
149    #[track_caller]
150    pub fn assert_count(&self, expected: usize) {
151        assert_eq!(self.count(), expected, "unexpected number of outbound requests");
152    }
153}
154
155/// Match a URL against a pattern.
156///
157/// `*` stands for any run of characters, and the pattern is matched as a
158/// substring rather than anchored — `"api.example.com/v1/*"` finds what a test
159/// author means by it without their having to write the scheme. Deliberately
160/// permissive: this decides which scripted answer a test gets, not who may
161/// talk to whom.
162fn matches(pattern: &str, url: &str) -> bool {
163    let mut rest = url;
164
165    for part in pattern.split('*') {
166        if part.is_empty() {
167            continue;
168        }
169        match rest.find(part) {
170            Some(at) => rest = &rest[at + part.len()..],
171            None => return false,
172        }
173    }
174
175    true
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::Client;
182
183    #[tokio::test]
184    async fn answers_from_the_script_without_touching_the_network() {
185        let client = Client::new().faking(
186            Fake::new().on("api.example.com/things", FakeResponse::json(Json::object([("id", 7.into())]))),
187        );
188
189        let response = client.get("https://api.example.com/things").send().await.unwrap();
190
191        assert!(response.is_success());
192        assert_eq!(response.json().unwrap().get("id").unwrap().as_i64(), Some(7));
193    }
194
195    #[tokio::test]
196    async fn records_what_was_sent() {
197        let client = Client::new().faking(Fake::new().fallback(FakeResponse::text("ok")));
198
199        client
200            .post("https://api.example.com/v1/messages")
201            .bearer("secret")
202            .json(Json::object([("model", "claude-sonnet-5".into())]))
203            .send()
204            .await
205            .unwrap();
206
207        let fake = client.fake().unwrap();
208        fake.assert_sent("api.example.com/v1/*");
209        fake.assert_not_sent("openai.com");
210        fake.assert_count(1);
211
212        let sent = &fake.recorded()[0];
213        assert_eq!(sent.method, Method::Post);
214        assert_eq!(sent.headers.get("authorization"), Some("Bearer secret"));
215        assert_eq!(sent.json().unwrap().get("model").unwrap().as_str(), Some("claude-sonnet-5"));
216    }
217
218    #[tokio::test]
219    async fn an_unscripted_request_fails_loudly() {
220        let client = Client::new().faking(Fake::new().on("expected.com", FakeResponse::text("ok")));
221
222        let error = client.get("https://surprise.com").send().await.unwrap_err().to_string();
223
224        assert!(error.contains("no fake response is scripted"));
225        assert!(error.contains("surprise.com"));
226    }
227
228    #[test]
229    fn patterns_match_in_order_with_wildcards() {
230        assert!(matches("example.com", "https://api.example.com/x"));
231        assert!(matches("api.example.com/v1/*", "https://api.example.com/v1/messages"));
232        assert!(matches("https://api.*/v1/*", "https://api.example.com/v1/messages"));
233        assert!(!matches("https://api.*/v2/*", "https://api.example.com/v1/messages"));
234        assert!(!matches("https://other.*", "https://api.example.com/v1"));
235        // The parts must appear in the order the pattern gives them.
236        assert!(!matches("messages*v1", "https://api.example.com/v1/messages"));
237    }
238
239    #[tokio::test]
240    async fn failure_statuses_can_be_scripted() {
241        let client = Client::new()
242            .faking(Fake::new().fallback(FakeResponse::text("slow down").status(429)));
243
244        let response = client.get("https://api.example.com").send().await.unwrap();
245
246        assert_eq!(response.status.code(), 429);
247        assert!(response.error_for_status().is_err());
248    }
249}