1use crate::{ClientResponse, RequestBuilder};
8use rustlavel_core::{Error, Json, Result};
9use rustlavel_http::{Headers, Method, Status};
10use std::sync::Mutex;
11
12#[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 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#[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#[derive(Default)]
66pub struct Fake {
67 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 pub fn on(mut self, pattern: &str, response: FakeResponse) -> Self {
80 self.routes.push((pattern.to_string(), response));
81 self
82 }
83
84 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 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 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
155fn 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 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}