Skip to main content

fraiseql_webhooks/
testing.rs

1#![allow(clippy::unwrap_used)] // Reason: test/bench code, panics are acceptable
2//! Mock implementations for testing.
3
4/// In-memory mock implementations of all webhook traits for use in unit and integration tests.
5pub mod mocks {
6    use std::{
7        collections::HashMap,
8        sync::{
9            Mutex,
10            atomic::{AtomicU64, Ordering},
11        },
12    };
13
14    use crate::{
15        Clock, IdempotencyStore, Result, SecretProvider, SignatureVerifier, WebhookError,
16        signature::SignatureError,
17    };
18
19    /// Mock signature verifier that always succeeds or fails based on configuration.
20    ///
21    /// Constructed with [`MockSignatureVerifier::succeeding`] or
22    /// [`MockSignatureVerifier::failing`]. All calls to `verify` are recorded and can be
23    /// retrieved with [`MockSignatureVerifier::get_calls`].
24    pub struct MockSignatureVerifier {
25        /// Whether `verify` should return `Ok(true)` or `Ok(false)`.
26        pub should_succeed: bool,
27        /// Ordered record of every `verify` invocation made against this mock.
28        pub calls:          Mutex<Vec<MockVerifyCall>>,
29    }
30
31    /// A single recorded invocation of [`MockSignatureVerifier::verify`].
32    #[derive(Debug, Clone)]
33    pub struct MockVerifyCall {
34        /// The raw request body passed to `verify`.
35        pub payload:   Vec<u8>,
36        /// The signature string passed to `verify`.
37        pub signature: String,
38    }
39
40    impl MockSignatureVerifier {
41        /// Create a verifier that returns `Ok(true)` for every call to `verify`.
42        #[must_use]
43        pub fn succeeding() -> Self {
44            Self {
45                should_succeed: true,
46                calls:          Mutex::new(Vec::new()),
47            }
48        }
49
50        /// Create a verifier that returns `Ok(false)` for every call to `verify`.
51        #[must_use]
52        pub fn failing() -> Self {
53            Self {
54                should_succeed: false,
55                calls:          Mutex::new(Vec::new()),
56            }
57        }
58
59        /// Return a snapshot of all `verify` calls recorded so far.
60        ///
61        /// # Panics
62        ///
63        /// Panics if the internal mutex is poisoned (a prior panic occurred
64        /// while the lock was held).
65        #[must_use]
66        pub fn get_calls(&self) -> Vec<MockVerifyCall> {
67            self.calls.lock().unwrap().clone()
68        }
69    }
70
71    impl SignatureVerifier for MockSignatureVerifier {
72        fn name(&self) -> &'static str {
73            "mock"
74        }
75
76        fn signature_header(&self) -> &'static str {
77            "X-Mock-Signature"
78        }
79
80        fn verify(
81            &self,
82            payload: &[u8],
83            signature: &str,
84            _secret: &str,
85            _timestamp: Option<&str>,
86            _url: Option<&str>,
87        ) -> std::result::Result<bool, SignatureError> {
88            self.calls.lock().unwrap().push(MockVerifyCall {
89                payload:   payload.to_vec(),
90                signature: signature.to_string(),
91            });
92            Ok(self.should_succeed)
93        }
94    }
95
96    /// Mock idempotency store with in-memory storage
97    pub struct MockIdempotencyStore {
98        events: Mutex<HashMap<(String, String), IdempotencyRecord>>,
99    }
100
101    /// A record stored by [`MockIdempotencyStore`] representing a previously seen webhook event.
102    #[derive(Debug, Clone)]
103    pub struct IdempotencyRecord {
104        /// Unique identifier assigned when the event was first recorded.
105        pub id:         uuid::Uuid,
106        /// The event type string (e.g., `"payment_intent.succeeded"`).
107        pub event_type: String,
108        /// Processing status, e.g. `"success"` or `"failed"`.
109        pub status:     String,
110        /// Optional error message set when processing failed.
111        pub error:      Option<String>,
112    }
113
114    impl MockIdempotencyStore {
115        /// Create an empty idempotency store with no pre-existing events.
116        #[must_use]
117        pub fn new() -> Self {
118            Self {
119                events: Mutex::new(HashMap::new()),
120            }
121        }
122
123        /// Pre-populate with existing events for testing duplicates.
124        ///
125        /// # Panics
126        ///
127        /// Panics if the internal mutex is poisoned (a prior panic occurred
128        /// while the lock was held).
129        #[must_use]
130        pub fn with_existing_events(events: Vec<(&str, &str)>) -> Self {
131            let store = Self::new();
132            let mut map = store.events.lock().unwrap();
133            for (provider, event_id) in events {
134                map.insert(
135                    (provider.to_string(), event_id.to_string()),
136                    IdempotencyRecord {
137                        id:         uuid::Uuid::new_v4(),
138                        event_type: "test".to_string(),
139                        status:     "success".to_string(),
140                        error:      None,
141                    },
142                );
143            }
144            drop(map);
145            store
146        }
147
148        /// Retrieve the stored record for a `(provider, event_id)` pair, if one exists.
149        ///
150        /// # Panics
151        ///
152        /// Panics if the internal mutex is poisoned (a prior panic occurred
153        /// while the lock was held).
154        #[must_use]
155        pub fn get_record(&self, provider: &str, event_id: &str) -> Option<IdempotencyRecord> {
156            self.events
157                .lock()
158                .unwrap()
159                .get(&(provider.to_string(), event_id.to_string()))
160                .cloned()
161        }
162    }
163
164    impl Default for MockIdempotencyStore {
165        fn default() -> Self {
166            Self::new()
167        }
168    }
169
170    impl IdempotencyStore for MockIdempotencyStore {
171        async fn check(&self, provider: &str, event_id: &str) -> Result<bool> {
172            Ok(self
173                .events
174                .lock()
175                .unwrap()
176                .contains_key(&(provider.to_string(), event_id.to_string())))
177        }
178
179        async fn record(
180            &self,
181            provider: &str,
182            event_id: &str,
183            event_type: &str,
184            status: &str,
185        ) -> Result<uuid::Uuid> {
186            let id = uuid::Uuid::new_v4();
187            self.events.lock().unwrap().insert(
188                (provider.to_string(), event_id.to_string()),
189                IdempotencyRecord {
190                    id,
191                    event_type: event_type.to_string(),
192                    status: status.to_string(),
193                    error: None,
194                },
195            );
196            Ok(id)
197        }
198
199        async fn update_status(
200            &self,
201            provider: &str,
202            event_id: &str,
203            status: &str,
204            error: Option<&str>,
205        ) -> Result<()> {
206            if let Some(record) = self
207                .events
208                .lock()
209                .unwrap()
210                .get_mut(&(provider.to_string(), event_id.to_string()))
211            {
212                record.status = status.to_string();
213                record.error = error.map(std::string::ToString::to_string);
214            }
215            Ok(())
216        }
217    }
218
219    /// Mock secret provider with configurable secrets
220    pub struct MockSecretProvider {
221        secrets: HashMap<String, String>,
222    }
223
224    impl MockSecretProvider {
225        /// Create a secret provider with no pre-configured secrets.
226        #[must_use]
227        pub fn new() -> Self {
228            Self {
229                secrets: HashMap::new(),
230            }
231        }
232
233        /// Register a named secret value, returning `self` to enable builder-style chaining.
234        #[must_use]
235        pub fn with_secret(mut self, name: &str, value: &str) -> Self {
236            self.secrets.insert(name.to_string(), value.to_string());
237            self
238        }
239    }
240
241    impl Default for MockSecretProvider {
242        fn default() -> Self {
243            Self::new()
244        }
245    }
246
247    impl SecretProvider for MockSecretProvider {
248        async fn get_secret(&self, name: &str) -> Result<String> {
249            self.secrets
250                .get(name)
251                .cloned()
252                .ok_or_else(|| WebhookError::MissingSecret(name.to_string()))
253        }
254    }
255
256    /// Mock clock for testing timestamp validation
257    pub struct MockClock {
258        current_time: AtomicU64,
259    }
260
261    impl MockClock {
262        /// Create a clock frozen at the given Unix timestamp (seconds since epoch).
263        #[must_use]
264        pub fn new(timestamp: u64) -> Self {
265            Self {
266                current_time: AtomicU64::new(timestamp),
267            }
268        }
269
270        /// Advance the clock forward by `seconds`, simulating elapsed time.
271        pub fn advance(&self, seconds: u64) {
272            self.current_time.fetch_add(seconds, Ordering::SeqCst);
273        }
274
275        /// Overwrite the current timestamp with the given Unix timestamp value.
276        pub fn set(&self, timestamp: u64) {
277            self.current_time.store(timestamp, Ordering::SeqCst);
278        }
279    }
280
281    impl Clock for MockClock {
282        fn now(&self) -> i64 {
283            self.current_time.load(Ordering::SeqCst) as i64
284        }
285    }
286}