Skip to main content

sova_http/
fake.rs

1//! In-memory fake transport for tests.
2
3use crate::error::HttpError;
4use crate::transport::{OutRequest, OutResponse, Transport};
5use bytes::Bytes;
6use http::{HeaderMap, Method, StatusCode};
7use sova_core::extend::BoxFuture;
8use std::sync::Mutex;
9
10#[derive(Debug, Clone)]
11pub enum StubBody {
12    Empty,
13    Bytes(Bytes),
14    Json(serde_json::Value),
15}
16
17#[derive(Debug, Clone)]
18pub enum StubOutcome {
19    Response {
20        status: StatusCode,
21        headers: HeaderMap,
22        body: StubBody,
23    },
24    Fail(String),
25}
26
27#[derive(Debug, Clone)]
28struct Stub {
29    method: Option<Method>,
30    url_pattern: String,
31    outcome: StubOutcome,
32}
33
34#[derive(Debug, Default)]
35struct FakeInner {
36    stubs: Vec<Stub>,
37    sent: Vec<OutRequest>,
38}
39
40/// Laravel-style `Http::fake()` registry.
41#[derive(Clone, Default)]
42pub struct FakeTransport {
43    inner: std::sync::Arc<Mutex<FakeInner>>,
44}
45
46impl FakeTransport {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn get(self, url: impl Into<String>, body: impl Into<StubBody>) -> Self {
52        self.stub(Some(Method::GET), url, StubOutcome::Response {
53            status: StatusCode::OK,
54            headers: HeaderMap::new(),
55            body: body.into(),
56        })
57    }
58
59    pub fn post(self, url: impl Into<String>, status: u16) -> Self {
60        self.stub(
61            Some(Method::POST),
62            url,
63            StubOutcome::Response {
64                status: StatusCode::from_u16(status).unwrap_or(StatusCode::CREATED),
65                headers: HeaderMap::new(),
66                body: StubBody::Empty,
67            },
68        )
69    }
70
71    pub fn respond(
72        self,
73        method: Method,
74        url: impl Into<String>,
75        status: u16,
76        body: impl Into<StubBody>,
77    ) -> Self {
78        self.stub(
79            Some(method),
80            url,
81            StubOutcome::Response {
82                status: StatusCode::from_u16(status).unwrap_or(StatusCode::OK),
83                headers: HeaderMap::new(),
84                body: body.into(),
85            },
86        )
87    }
88
89    pub fn fail(self, url: impl Into<String>, msg: impl Into<String>) -> Self {
90        self.stub(None, url, StubOutcome::Fail(msg.into()))
91    }
92
93    fn stub(self, method: Option<Method>, url: impl Into<String>, outcome: StubOutcome) -> Self {
94        self.inner.lock().unwrap().stubs.push(Stub {
95            method,
96            url_pattern: url.into(),
97            outcome,
98        });
99        self
100    }
101
102    pub fn assert_sent(&self, method: Method, url: &str) {
103        let g = self.inner.lock().unwrap();
104        assert!(
105            g.sent
106                .iter()
107                .any(|r| r.method == method && url_matches(&r.url, url)),
108            "expected {method} {url}, sent: {:?}",
109            g.sent
110                .iter()
111                .map(|r| format!("{} {}", r.method, r.url))
112                .collect::<Vec<_>>()
113        );
114    }
115
116    pub fn assert_sent_count(&self, n: usize) {
117        let g = self.inner.lock().unwrap();
118        assert_eq!(g.sent.len(), n, "sent {:?}", g.sent.len());
119    }
120
121    pub fn assert_not_sent(&self, url: &str) {
122        let g = self.inner.lock().unwrap();
123        assert!(
124            !g.sent.iter().any(|r| url_matches(&r.url, url)),
125            "unexpected call to {url}"
126        );
127    }
128
129    pub fn sent(&self) -> Vec<OutRequest> {
130        self.inner.lock().unwrap().sent.clone()
131    }
132}
133
134impl From<serde_json::Value> for StubBody {
135    fn from(v: serde_json::Value) -> Self {
136        Self::Json(v)
137    }
138}
139
140impl From<&'static str> for StubBody {
141    fn from(s: &'static str) -> Self {
142        Self::Bytes(Bytes::from_static(s.as_bytes()))
143    }
144}
145
146impl From<String> for StubBody {
147    fn from(s: String) -> Self {
148        Self::Bytes(Bytes::from(s))
149    }
150}
151
152fn url_matches(actual: &str, pattern: &str) -> bool {
153    if let Some(prefix) = pattern.strip_suffix('*') {
154        actual.starts_with(prefix)
155    } else {
156        actual == pattern
157    }
158}
159
160fn stub_to_response(outcome: &StubOutcome) -> Result<OutResponse, HttpError> {
161    match outcome {
162        StubOutcome::Fail(m) => {
163            if m.to_ascii_lowercase().contains("timeout") {
164                Err(HttpError::Timeout)
165            } else {
166                Err(HttpError::Other(m.clone()))
167            }
168        }
169        StubOutcome::Response {
170            status,
171            headers,
172            body,
173        } => {
174            let bytes = match body {
175                StubBody::Empty => Bytes::new(),
176                StubBody::Bytes(b) => b.clone(),
177                StubBody::Json(v) => Bytes::from(serde_json::to_vec(v).unwrap_or_default()),
178            };
179            Ok(OutResponse::new(*status, headers.clone(), bytes))
180        }
181    }
182}
183
184impl Transport for FakeTransport {
185    fn send(&self, req: OutRequest) -> BoxFuture<Result<OutResponse, HttpError>> {
186        let this = self.clone();
187        Box::pin(async move {
188            let mut g = this.inner.lock().unwrap();
189            g.sent.push(req.clone());
190            let outcome = g
191                .stubs
192                .iter()
193                .find(|s| {
194                    s.method.as_ref().map(|m| *m == req.method).unwrap_or(true)
195                        && url_matches(&req.url, &s.url_pattern)
196                })
197                .map(|s| s.outcome.clone());
198            drop(g);
199            match outcome {
200                Some(o) => stub_to_response(&o),
201                None => Err(HttpError::Other(format!(
202                    "no fake stub for {} {}",
203                    req.method, req.url
204                ))),
205            }
206        })
207    }
208}