Skip to main content

sova_graphql/
fake.rs

1//! Fake GraphQL transport for outbound client tests.
2
3use crate::client::GraphqlTransport;
4use crate::error::GraphqlError;
5use bytes::Bytes;
6use serde_json::{json, Value};
7use sova_core::extend::BoxFuture;
8use std::sync::{Arc, Mutex};
9
10#[derive(Debug, Clone)]
11pub struct GraphqlCall {
12    pub query: String,
13    pub operation_name: Option<String>,
14    pub variables: Option<Value>,
15    pub endpoint: String,
16}
17
18#[derive(Default)]
19struct Inner {
20    /// (query substring, data JSON)
21    stubs: Vec<(String, Value)>,
22    calls: Vec<GraphqlCall>,
23}
24
25/// Stub outbound GraphQL by query substring; records every call.
26#[derive(Clone, Default)]
27pub struct FakeGraphql {
28    inner: Arc<Mutex<Inner>>,
29}
30
31impl FakeGraphql {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// When the query contains `needle`, respond with `{ "data": body }`.
37    pub fn stub(self, needle: impl Into<String>, data: Value) -> Self {
38        self.inner.lock().unwrap().stubs.push((needle.into(), data));
39        self
40    }
41
42    pub fn calls(&self) -> Vec<GraphqlCall> {
43        self.inner.lock().unwrap().calls.clone()
44    }
45
46    pub fn clear(&self) {
47        self.inner.lock().unwrap().calls.clear();
48    }
49
50    pub fn assert_called(&self) {
51        assert!(
52            !self.inner.lock().unwrap().calls.is_empty(),
53            "FakeGraphql: expected at least one GraphQL call"
54        );
55    }
56
57    pub fn assert_called_with(&self, needle: &str) {
58        let calls = self.calls();
59        assert!(
60            calls.iter().any(|c| c.query.contains(needle)),
61            "FakeGraphql: no call matched `{needle}`; calls={calls:?}"
62        );
63    }
64}
65
66impl GraphqlTransport for FakeGraphql {
67    fn post(&self, url: &str, body: Bytes) -> BoxFuture<Result<Bytes, GraphqlError>> {
68        let this = self.clone();
69        let url = url.to_string();
70        Box::pin(async move {
71            let payload: Value =
72                serde_json::from_slice(&body).map_err(|e| GraphqlError::Decode(e.to_string()))?;
73            let query = payload
74                .get("query")
75                .and_then(|v| v.as_str())
76                .unwrap_or("")
77                .to_string();
78            let operation_name = payload
79                .get("operationName")
80                .and_then(|v| v.as_str())
81                .map(str::to_string);
82            let variables = payload.get("variables").cloned();
83
84            let mut g = this.inner.lock().unwrap();
85            g.calls.push(GraphqlCall {
86                query: query.clone(),
87                operation_name,
88                variables,
89                endpoint: url,
90            });
91
92            for (needle, data) in &g.stubs {
93                if query.contains(needle) {
94                    let resp = json!({ "data": data });
95                    return serde_json::to_vec(&resp)
96                        .map(Bytes::from)
97                        .map_err(|e| GraphqlError::Decode(e.to_string()));
98                }
99            }
100
101            Err(GraphqlError::Graphql(format!(
102                "FakeGraphql: no stub matched query ({query})"
103            )))
104        })
105    }
106}