Skip to main content

gluonscan_testing/
lib.rs

1//! # gluonscan-testing
2//!
3//! Contract-based mock transports. A **contract** is a recorded pair: a [`Match`] (a filter over
4//! the outgoing request) and the response to reply with. You save the contracts (in code or as
5//! JSON files), declare the filters, and get deterministic, offline, testable results — the same
6//! model for HTTP/GraphQL ([`MockHttp`]), on-chain RPC ([`MockChainProvider`]), and, later, gRPC.
7//!
8//! A request that matches no contract is an **error**, so tests fail loudly on unexpected calls.
9//!
10//! ```
11//! use gluonscan_testing::{MockHttp, Match};
12//! let http = MockHttp::new()
13//!     .on(Match::body_contains("userSupplies"), r#"{"data":{"userSupplies":[]}}"#);
14//! ```
15
16use std::sync::Mutex;
17
18use async_trait::async_trait;
19use gluonscan_core::{Chain, ChainProvider, Clock, Error, Http, Timestamp};
20use serde::Deserialize;
21
22/// A filter over an outgoing request. `primary` is the URL (HTTP) or the method (RPC); `body` is
23/// the request body (HTTP) or the params (RPC). Compose with [`Match::all`] / [`Match::any_of`].
24#[derive(Debug, Clone)]
25pub enum Match {
26    /// Matches anything.
27    Any,
28    /// `primary` contains this substring (URL substring, or partial method).
29    PrimaryContains(String),
30    /// `primary` equals this exactly (e.g. an RPC method name).
31    PrimaryIs(String),
32    /// The body contains this substring (e.g. a GraphQL field name).
33    BodyContains(String),
34    /// The body parses as JSON and the value at `pointer` equals `value`.
35    JsonEq {
36        /// A JSON Pointer (RFC 6901), e.g. `/variables/user`.
37        pointer: String,
38        /// The expected value at that pointer.
39        value: serde_json::Value,
40    },
41    /// All of the inner matches hold.
42    All(Vec<Match>),
43    /// At least one of the inner matches holds.
44    AnyOf(Vec<Match>),
45}
46
47impl Match {
48    /// `primary` contains `s`.
49    pub fn primary_contains(s: impl Into<String>) -> Match {
50        Match::PrimaryContains(s.into())
51    }
52    /// `primary` equals `s` (e.g. an RPC method).
53    pub fn method(s: impl Into<String>) -> Match {
54        Match::PrimaryIs(s.into())
55    }
56    /// Body contains `s`.
57    pub fn body_contains(s: impl Into<String>) -> Match {
58        Match::BodyContains(s.into())
59    }
60    /// Body JSON at `pointer` equals `value`.
61    pub fn json_eq(pointer: impl Into<String>, value: serde_json::Value) -> Match {
62        Match::JsonEq {
63            pointer: pointer.into(),
64            value,
65        }
66    }
67    /// All must hold.
68    pub fn all(m: impl IntoIterator<Item = Match>) -> Match {
69        Match::All(m.into_iter().collect())
70    }
71    /// Any may hold.
72    pub fn any_of(m: impl IntoIterator<Item = Match>) -> Match {
73        Match::AnyOf(m.into_iter().collect())
74    }
75
76    /// Whether this filter matches the given request.
77    pub fn matches(&self, primary: &str, body: &str) -> bool {
78        match self {
79            Match::Any => true,
80            Match::PrimaryContains(s) => primary.contains(s.as_str()),
81            Match::PrimaryIs(s) => primary == s,
82            Match::BodyContains(s) => body.contains(s.as_str()),
83            Match::JsonEq { pointer, value } => serde_json::from_str::<serde_json::Value>(body)
84                .ok()
85                .and_then(|v| v.pointer(pointer).cloned())
86                .is_some_and(|found| &found == value),
87            Match::All(ms) => ms.iter().all(|m| m.matches(primary, body)),
88            Match::AnyOf(ms) => ms.iter().any(|m| m.matches(primary, body)),
89        }
90    }
91}
92
93/// A single recorded contract: when the filter matches, reply with `reply`.
94#[derive(Debug, Clone)]
95pub struct Contract {
96    /// The request filter.
97    pub when: Match,
98    /// The recorded response body.
99    pub reply: String,
100}
101
102/// A JSON-authorable contract file (`{ "body_contains": "...", "reply": "..." }`). Present fields
103/// are combined with AND. Use `reply` inline or `reply_path` to point at a sibling response file.
104#[derive(Debug, Clone, Deserialize)]
105pub struct ContractSpec {
106    /// `primary` (URL/method) contains this.
107    #[serde(default)]
108    pub primary_contains: Option<String>,
109    /// `primary` equals this.
110    #[serde(default)]
111    pub method: Option<String>,
112    /// Body contains this.
113    #[serde(default)]
114    pub body_contains: Option<String>,
115    /// Inline recorded response.
116    #[serde(default)]
117    pub reply: Option<String>,
118    /// Path (relative to the spec file) of the recorded response.
119    #[serde(default)]
120    pub reply_path: Option<String>,
121}
122
123impl ContractSpec {
124    /// Resolve into a [`Contract`], reading `reply_path` relative to `base_dir` if used.
125    pub fn into_contract(self, base_dir: &std::path::Path) -> std::io::Result<Contract> {
126        let mut ms = Vec::new();
127        if let Some(s) = self.primary_contains {
128            ms.push(Match::PrimaryContains(s));
129        }
130        if let Some(s) = self.method {
131            ms.push(Match::PrimaryIs(s));
132        }
133        if let Some(s) = self.body_contains {
134            ms.push(Match::BodyContains(s));
135        }
136        let when = if ms.is_empty() {
137            Match::Any
138        } else {
139            Match::All(ms)
140        };
141        let reply = match (self.reply, self.reply_path) {
142            (Some(r), _) => r,
143            (None, Some(p)) => std::fs::read_to_string(base_dir.join(p))?,
144            (None, None) => String::new(),
145        };
146        Ok(Contract { when, reply })
147    }
148}
149
150/// Load every `*.json` contract spec in a directory into contracts.
151pub fn load_contracts(dir: impl AsRef<std::path::Path>) -> std::io::Result<Vec<Contract>> {
152    let dir = dir.as_ref();
153    let mut out = Vec::new();
154    for entry in std::fs::read_dir(dir)? {
155        let path = entry?.path();
156        if path.extension().and_then(|e| e.to_str()) == Some("json") {
157            let spec: ContractSpec = serde_json::from_str(&std::fs::read_to_string(&path)?)
158                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
159            out.push(spec.into_contract(dir)?);
160        }
161    }
162    Ok(out)
163}
164
165fn no_match(primary: &str, body: &str) -> Error {
166    let preview: String = body.chars().take(200).collect();
167    Error::Permanent {
168        message: format!("no contract matched request to `{primary}` with body: {preview}"),
169    }
170}
171
172/// A request filter paired with a factory that produces the [`Error`] to fail it with. `Error` is
173/// not `Clone` (it carries an opaque provider source), so the failure is built per call.
174type ErrorContract = (Match, Box<dyn Fn() -> Error + Send + Sync>);
175
176/// A [`Http`] mock that replays contracts and records the calls it received.
177#[derive(Default)]
178pub struct MockHttp {
179    contracts: Vec<Contract>,
180    errors: Vec<ErrorContract>,
181    calls: Mutex<Vec<(String, String)>>,
182}
183
184impl MockHttp {
185    /// An empty mock (every request will error until you add contracts).
186    pub fn new() -> Self {
187        MockHttp::default()
188    }
189    /// Add a contract (builder style).
190    pub fn on(mut self, when: Match, reply: impl Into<String>) -> Self {
191        self.contracts.push(Contract {
192            when,
193            reply: reply.into(),
194        });
195        self
196    }
197    /// Fail matching requests with an injected error (checked before reply contracts). Use this to
198    /// test that an adapter propagates a transport failure (e.g. a 429 → [`Error::Transient`])
199    /// instead of swallowing it into an empty reading.
200    pub fn on_err(
201        mut self,
202        when: Match,
203        error: impl Fn() -> Error + Send + Sync + 'static,
204    ) -> Self {
205        self.errors.push((when, Box::new(error)));
206        self
207    }
208    /// Fail matching requests with a retryable [`Error::Transient`] (an HTTP 429 stand-in).
209    pub fn on_transient(self, when: Match) -> Self {
210        self.on_err(when, || Error::Transient {
211            message: "mock transient (HTTP 429)".into(),
212            retry_after: None,
213        })
214    }
215    /// Build from a pre-assembled contract set.
216    pub fn from_contracts(contracts: Vec<Contract>) -> Self {
217        MockHttp {
218            contracts,
219            errors: Vec::new(),
220            calls: Mutex::new(Vec::new()),
221        }
222    }
223    /// The (url, body) pairs seen so far — for assertions.
224    pub fn calls(&self) -> Vec<(String, String)> {
225        self.calls.lock().unwrap().clone()
226    }
227
228    fn reply(&self, primary: &str, body: &str) -> Result<String, Error> {
229        if let Some((_, factory)) = self.errors.iter().find(|(w, _)| w.matches(primary, body)) {
230            return Err(factory());
231        }
232        self.contracts
233            .iter()
234            .find(|c| c.when.matches(primary, body))
235            .map(|c| c.reply.clone())
236            .ok_or_else(|| no_match(primary, body))
237    }
238}
239
240#[async_trait]
241impl Http for MockHttp {
242    async fn post(
243        &self,
244        url: &str,
245        body: String,
246        _headers: &[(&str, &str)],
247    ) -> Result<String, Error> {
248        self.calls
249            .lock()
250            .unwrap()
251            .push((url.to_string(), body.clone()));
252        self.reply(url, &body)
253    }
254
255    async fn get(&self, url: &str, _headers: &[(&str, &str)]) -> Result<String, Error> {
256        self.calls
257            .lock()
258            .unwrap()
259            .push((url.to_string(), String::new()));
260        self.reply(url, "")
261    }
262}
263
264/// A [`ChainProvider`] mock that replays contracts (matched on method + params) for on-chain reads.
265#[derive(Default)]
266pub struct MockChainProvider {
267    contracts: Vec<Contract>,
268    calls: Mutex<Vec<(String, String)>>,
269}
270
271impl MockChainProvider {
272    /// An empty mock.
273    pub fn new() -> Self {
274        MockChainProvider::default()
275    }
276    /// Add a contract; `when` matches against `(method, params)`.
277    pub fn on(mut self, when: Match, reply: impl Into<String>) -> Self {
278        self.contracts.push(Contract {
279            when,
280            reply: reply.into(),
281        });
282        self
283    }
284    /// The (method, params) pairs seen so far.
285    pub fn calls(&self) -> Vec<(String, String)> {
286        self.calls.lock().unwrap().clone()
287    }
288}
289
290#[async_trait]
291impl ChainProvider for MockChainProvider {
292    async fn call(&self, _chain: Chain, method: &str, params: String) -> Result<String, Error> {
293        self.calls
294            .lock()
295            .unwrap()
296            .push((method.to_string(), params.clone()));
297        self.contracts
298            .iter()
299            .find(|c| c.when.matches(method, &params))
300            .map(|c| c.reply.clone())
301            .ok_or_else(|| no_match(method, &params))
302    }
303}
304
305/// A fixed clock, so reads carry a deterministic timestamp in tests.
306#[derive(Debug, Clone, Copy)]
307pub struct MockClock(pub i64);
308
309impl Clock for MockClock {
310    fn now(&self) -> Timestamp {
311        Timestamp(self.0)
312    }
313}