Skip to main content

cratefield_testing/
fakes.rs

1//! Fake ports for module tests (issue #9). All fakes are `Clone` handles
2//! over shared interiors so they can be wired into `Ports` and still be
3//! asserted on from the test.
4//!
5//! Interior mutability here records test observations; it is not request
6//! state (ADR 0007) — the scoped `Mutex` allow follows the policy in the
7//! workspace `clippy.toml`.
8
9#![allow(clippy::disallowed_types)]
10// Every accessor locks an unpoisoned fixture mutex; per-method `# Panics`
11// sections would add noise without information.
12#![allow(clippy::missing_panics_doc)]
13
14use async_trait::async_trait;
15use bytes::Bytes;
16use cratefield_core::{
17    Captcha, CaptchaError, Clock, Database, DbError, Decision, Defer, HttpClient, HttpError,
18    KeyValue, KvError, MailError, Mailer, Message, RateLimitError, RateLimiter, Row, Rows,
19    SendOutcome, Statement, Verdict,
20};
21use futures_core::future::BoxFuture;
22use http::{Request, Response};
23use std::collections::{HashMap, VecDeque};
24use std::sync::Arc;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::time::Duration;
27
28// Recording fixtures, not request state (see module docs).
29#[allow(clippy::disallowed_types)]
30use std::sync::Mutex;
31
32// ---------------------------------------------------------------------------
33// FakeMailer
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum MailerMode {
37    SendOk,
38    NotConfigured,
39    Fail,
40}
41
42#[derive(Clone)]
43pub struct FakeMailer {
44    inner: Arc<FakeMailerInner>,
45}
46
47struct FakeMailerInner {
48    mode: Mutex<MailerMode>,
49    sent: Mutex<Vec<Message>>,
50}
51
52impl FakeMailer {
53    #[must_use]
54    pub fn new(mode: MailerMode) -> Self {
55        Self {
56            inner: Arc::new(FakeMailerInner {
57                mode: Mutex::new(mode),
58                sent: Mutex::new(Vec::new()),
59            }),
60        }
61    }
62
63    /// Every message recorded so far.
64    #[must_use]
65    pub fn sent(&self) -> Vec<Message> {
66        self.inner.sent.lock().expect("mailer lock").clone()
67    }
68
69    /// The most recent message.
70    #[must_use]
71    pub fn last_message(&self) -> Option<Message> {
72        self.inner.sent.lock().expect("mailer lock").last().cloned()
73    }
74
75    /// Switches the mode (e.g. degrade to `NotConfigured` mid-test).
76    pub fn set_mode(&self, mode: MailerMode) {
77        *self.inner.mode.lock().expect("mailer lock") = mode;
78    }
79}
80
81#[async_trait]
82impl Mailer for FakeMailer {
83    async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
84        let mode = *self.inner.mode.lock().expect("mailer lock");
85        match mode {
86            MailerMode::SendOk => {
87                let id = format!(
88                    "fake-{}",
89                    self.inner.sent.lock().expect("mailer lock").len()
90                );
91                self.inner.sent.lock().expect("mailer lock").push(message);
92                Ok(SendOutcome::Sent { id })
93            }
94            MailerMode::NotConfigured => Ok(SendOutcome::NotConfigured),
95            MailerMode::Fail => Err(MailError::Upstream("fake mailer failure".to_string())),
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// FakeCaptcha
102
103#[derive(Clone)]
104pub struct FakeCaptcha {
105    allow_all: bool,
106    allowed_tokens: Arc<Vec<String>>,
107}
108
109impl FakeCaptcha {
110    /// Every token verifies.
111    #[must_use]
112    pub fn allow_all() -> Self {
113        Self {
114            allow_all: true,
115            allowed_tokens: Arc::new(Vec::new()),
116        }
117    }
118
119    /// Only the listed tokens verify.
120    #[must_use]
121    pub fn with_tokens(tokens: impl IntoIterator<Item = impl Into<String>>) -> Self {
122        Self {
123            allow_all: false,
124            allowed_tokens: Arc::new(tokens.into_iter().map(Into::into).collect()),
125        }
126    }
127}
128
129#[async_trait]
130impl Captcha for FakeCaptcha {
131    async fn verify(&self, token: &str, _remote_ip: Option<&str>) -> Result<Verdict, CaptchaError> {
132        let ok = self.allow_all || self.allowed_tokens.iter().any(|t| t == token);
133        Ok(Verdict {
134            ok,
135            reason: (!ok).then(|| "token not allowed".to_string()),
136        })
137    }
138}
139
140// ---------------------------------------------------------------------------
141// FakeRateLimiter (scripted)
142
143#[derive(Clone)]
144pub struct FakeRateLimiter {
145    inner: Arc<FakeRateLimiterInner>,
146}
147
148struct FakeRateLimiterInner {
149    scripted: Mutex<VecDeque<Decision>>,
150    default: Decision,
151    calls: AtomicUsize,
152}
153
154impl FakeRateLimiter {
155    /// Falls through to `default` once the script is exhausted.
156    #[must_use]
157    pub fn scripted(decisions: Vec<Decision>, default: Decision) -> Self {
158        Self {
159            inner: Arc::new(FakeRateLimiterInner {
160                scripted: Mutex::new(decisions.into_iter().collect()),
161                default,
162                calls: AtomicUsize::new(0),
163            }),
164        }
165    }
166
167    /// Always allows.
168    #[must_use]
169    pub fn always_allow() -> Self {
170        Self::scripted(
171            Vec::new(),
172            Decision {
173                ok: true,
174                retry_after: None,
175            },
176        )
177    }
178
179    #[must_use]
180    pub fn calls(&self) -> usize {
181        self.inner.calls.load(Ordering::SeqCst)
182    }
183}
184
185#[async_trait]
186impl RateLimiter for FakeRateLimiter {
187    async fn limit(&self, _key: &str) -> Result<Decision, RateLimitError> {
188        self.inner.calls.fetch_add(1, Ordering::SeqCst);
189        let scripted = self
190            .inner
191            .scripted
192            .lock()
193            .expect("limiter lock")
194            .pop_front();
195        Ok(scripted.unwrap_or_else(|| self.inner.default.clone()))
196    }
197}
198
199// ---------------------------------------------------------------------------
200// FixedClock
201
202#[derive(Debug, Clone)]
203pub struct FixedClock(pub time::OffsetDateTime);
204
205#[async_trait]
206impl Clock for FixedClock {
207    fn now(&self) -> time::OffsetDateTime {
208        self.0
209    }
210}
211
212// ---------------------------------------------------------------------------
213// MemoryKeyValue
214
215#[derive(Clone, Default)]
216pub struct MemoryKeyValue {
217    inner: Arc<MemoryKeyValueInner>,
218}
219
220#[derive(Default)]
221struct MemoryKeyValueInner {
222    entries: Mutex<HashMap<String, String>>,
223}
224
225impl MemoryKeyValue {
226    #[must_use]
227    pub fn new() -> Self {
228        Self::default()
229    }
230}
231
232#[async_trait]
233impl KeyValue for MemoryKeyValue {
234    async fn get(&self, key: &str) -> Result<Option<String>, KvError> {
235        Ok(self
236            .inner
237            .entries
238            .lock()
239            .expect("kv lock")
240            .get(key)
241            .cloned())
242    }
243
244    async fn put(&self, key: &str, value: &str, _ttl: Option<Duration>) -> Result<(), KvError> {
245        self.inner
246            .entries
247            .lock()
248            .expect("kv lock")
249            .insert(key.to_string(), value.to_string());
250        Ok(())
251    }
252
253    async fn delete(&self, key: &str) -> Result<(), KvError> {
254        self.inner.entries.lock().expect("kv lock").remove(key);
255        Ok(())
256    }
257}
258
259// ---------------------------------------------------------------------------
260// FakeHttpClient (scripted responses, captures requests)
261
262#[derive(Clone)]
263pub struct FakeHttpClient {
264    inner: Arc<FakeHttpInner>,
265}
266
267struct FakeHttpInner {
268    responses: Mutex<VecDeque<Result<Response<Bytes>, HttpError>>>,
269    captured: Mutex<Vec<(String, String, String)>>, // method, uri, body
270}
271
272impl FakeHttpClient {
273    /// Responds with `responses` in order, then always with a 500.
274    #[must_use]
275    pub fn scripted(responses: Vec<Result<Response<Bytes>, HttpError>>) -> Self {
276        Self {
277            inner: Arc::new(FakeHttpInner {
278                responses: Mutex::new(responses.into_iter().collect()),
279                captured: Mutex::new(Vec::new()),
280            }),
281        }
282    }
283
284    #[must_use]
285    pub fn ok_json(body: &'static str) -> Self {
286        Self::scripted(vec![
287            Response::builder()
288                .status(200)
289                .body(Bytes::from(body))
290                .map_err(|err| HttpError::Transport(err.to_string())),
291        ])
292    }
293
294    /// Every captured request as `(method, uri, body)`.
295    #[must_use]
296    pub fn captured(&self) -> Vec<(String, String, String)> {
297        self.inner.captured.lock().expect("http lock").clone()
298    }
299}
300
301#[async_trait]
302impl HttpClient for FakeHttpClient {
303    async fn send(&self, request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
304        let (parts, body) = request.into_parts();
305        self.inner.captured.lock().expect("http lock").push((
306            parts.method.to_string(),
307            parts.uri.to_string(),
308            String::from_utf8_lossy(&body).to_string(),
309        ));
310        let next = self.inner.responses.lock().expect("http lock").pop_front();
311        next.unwrap_or_else(|| Err(HttpError::Transport("fake http exhausted".to_string())))
312    }
313}
314
315// ---------------------------------------------------------------------------
316// FakeDefer (collects futures; drain runs them)
317
318#[derive(Clone, Default)]
319pub struct FakeDefer {
320    inner: Arc<FakeDeferInner>,
321}
322
323#[derive(Default)]
324struct FakeDeferInner {
325    pending: Mutex<Vec<BoxFuture<'static, ()>>>,
326    deferred: AtomicUsize,
327}
328
329impl FakeDefer {
330    #[must_use]
331    pub fn new() -> Self {
332        Self::default()
333    }
334
335    /// Runs every deferred future to completion, in deferral order.
336    // Async for API symmetry with `drain().await` call sites (the futures
337    // run on a sync block-on inside).
338    #[allow(clippy::unused_async, clippy::unused_async_trait_impl)]
339    pub async fn drain(&self) {
340        while !self.inner.pending.lock().expect("defer lock").is_empty() {
341            let next = self.inner.pending.lock().expect("defer lock").remove(0);
342            pollster::block_on(next);
343        }
344    }
345
346    #[must_use]
347    pub fn deferred_count(&self) -> usize {
348        self.inner.deferred.load(Ordering::SeqCst)
349    }
350}
351
352impl Defer for FakeDefer {
353    fn wait_until(&self, fut: BoxFuture<'static, ()>) {
354        self.inner.deferred.fetch_add(1, Ordering::SeqCst);
355        self.inner.pending.lock().expect("defer lock").push(fut);
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Transparent Database passthrough (re-exported for tests that need a
361// trivial Database without SQLite): an always-empty database.
362
363#[derive(Debug, Clone, Copy, Default)]
364pub struct EmptyDatabase;
365
366#[async_trait]
367impl Database for EmptyDatabase {
368    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
369        Err(DbError::Execute(format!("empty database: {}", stmt.sql)))
370    }
371
372    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
373        if stmt.sql.trim() == "SELECT 1" {
374            Ok(Rows::new(vec![Row::new(vec![(
375                "1".to_string(),
376                sea_query::Value::Int(Some(1)),
377            )])]))
378        } else {
379            Err(DbError::Query(format!("empty database: {}", stmt.sql)))
380        }
381    }
382
383    async fn batch(&self, _stmts: &[Statement]) -> Result<(), DbError> {
384        Err(DbError::Batch("empty database".to_string()))
385    }
386}
387
388/// An in-process [`Dispatcher`](cratefield_core::Dispatcher) that answers from an
389/// axum [`Router`](axum::Router), so a
390/// module can be exercised through a sidecar mount without a network or a
391/// second Worker (ADR 0009). The conformance kit uses it to run the same
392/// assertions against both mounts (#64).
393///
394/// Also the failure fixture: [`unbound`](FakeDispatcher::unbound) has no
395/// binding at all, and [`failing`](FakeDispatcher::failing) accepts the
396/// binding and then refuses to answer.
397#[derive(Clone)]
398pub struct FakeDispatcher {
399    binding: String,
400    behaviour: FakeDispatch,
401    calls: Arc<AtomicUsize>,
402}
403
404#[derive(Clone)]
405enum FakeDispatch {
406    Serve(Arc<Mutex<axum::Router>>),
407    Unbound,
408    Failing(String),
409}
410
411impl FakeDispatcher {
412    /// Serves `router` on `binding`.
413    #[must_use]
414    pub fn serving(binding: impl Into<String>, router: axum::Router) -> Self {
415        Self {
416            binding: binding.into(),
417            behaviour: FakeDispatch::Serve(Arc::new(Mutex::new(router))),
418            calls: Arc::new(AtomicUsize::new(0)),
419        }
420    }
421
422    /// Has no bindings, so `has()` is always false: the "mounted but this
423    /// deployment has no such binding" case.
424    #[must_use]
425    pub fn unbound() -> Self {
426        Self {
427            binding: String::new(),
428            behaviour: FakeDispatch::Unbound,
429            calls: Arc::new(AtomicUsize::new(0)),
430        }
431    }
432
433    /// Accepts `binding` and then fails to answer.
434    #[must_use]
435    pub fn failing(binding: impl Into<String>, reason: impl Into<String>) -> Self {
436        Self {
437            binding: binding.into(),
438            behaviour: FakeDispatch::Failing(reason.into()),
439            calls: Arc::new(AtomicUsize::new(0)),
440        }
441    }
442
443    /// How many dispatches were attempted. A forwarder must not retry, so a
444    /// single request must leave this at one.
445    #[must_use]
446    pub fn calls(&self) -> usize {
447        self.calls.load(Ordering::SeqCst)
448    }
449}
450
451#[async_trait]
452impl cratefield_core::Dispatcher for FakeDispatcher {
453    fn has(&self, binding: &str) -> bool {
454        !matches!(self.behaviour, FakeDispatch::Unbound) && binding == self.binding
455    }
456
457    async fn dispatch(
458        &self,
459        binding: &str,
460        request: Request<Bytes>,
461    ) -> Result<Response<Bytes>, cratefield_core::DispatchError> {
462        self.calls.fetch_add(1, Ordering::SeqCst);
463        match &self.behaviour {
464            FakeDispatch::Unbound => {
465                Err(cratefield_core::DispatchError::NotBound(binding.to_owned()))
466            }
467            FakeDispatch::Failing(reason) => Err(cratefield_core::DispatchError::Unavailable {
468                binding: binding.to_owned(),
469                reason: reason.clone(),
470            }),
471            FakeDispatch::Serve(router) => {
472                let router = router.lock().unwrap().clone();
473                let (parts, body) = request.into_parts();
474                let request = Request::from_parts(parts, axum::body::Body::from(body));
475                let response =
476                    tower::ServiceExt::oneshot(router, request)
477                        .await
478                        .map_err(|err| cratefield_core::DispatchError::Unavailable {
479                            binding: binding.to_owned(),
480                            reason: err.to_string(),
481                        })?;
482                let (parts, body) = response.into_parts();
483                let bytes = axum::body::to_bytes(body, usize::MAX)
484                    .await
485                    .map_err(|err| cratefield_core::DispatchError::Unavailable {
486                        binding: binding.to_owned(),
487                        reason: err.to_string(),
488                    })?;
489                Ok(Response::from_parts(parts, bytes))
490            }
491        }
492    }
493}
494
495/// An in-memory [`cratefield_core::Blob`] store for module tests: keeps objects
496/// in a map, and has no presigned URLs (so `signed_url` reports `Unsupported`,
497/// as a directory store does).
498#[derive(Clone, Default)]
499pub struct MemoryBlob {
500    objects: Arc<std::sync::Mutex<std::collections::HashMap<String, cratefield_core::BlobObject>>>,
501}
502
503impl MemoryBlob {
504    #[must_use]
505    pub fn new() -> Self {
506        Self::default()
507    }
508
509    /// How many objects are stored, for assertions.
510    #[must_use]
511    pub fn len(&self) -> usize {
512        self.objects.lock().unwrap().len()
513    }
514
515    /// Whether the store is empty, for assertions.
516    #[must_use]
517    pub fn is_empty(&self) -> bool {
518        self.len() == 0
519    }
520}
521
522#[async_trait]
523impl cratefield_core::Blob for MemoryBlob {
524    async fn put(
525        &self,
526        key: &str,
527        bytes: &[u8],
528        content_type: &str,
529    ) -> Result<(), cratefield_core::BlobError> {
530        self.objects.lock().unwrap().insert(
531            key.to_owned(),
532            cratefield_core::BlobObject {
533                bytes: bytes.to_vec(),
534                content_type: content_type.to_owned(),
535            },
536        );
537        Ok(())
538    }
539    async fn get(
540        &self,
541        key: &str,
542    ) -> Result<Option<cratefield_core::BlobObject>, cratefield_core::BlobError> {
543        Ok(self.objects.lock().unwrap().get(key).cloned())
544    }
545    async fn delete(&self, key: &str) -> Result<(), cratefield_core::BlobError> {
546        self.objects.lock().unwrap().remove(key);
547        Ok(())
548    }
549    async fn signed_url(
550        &self,
551        _key: &str,
552        _ttl: std::time::Duration,
553    ) -> Result<String, cratefield_core::BlobError> {
554        Err(cratefield_core::BlobError::Unsupported(
555            "in-memory store has no presigned URLs".to_owned(),
556        ))
557    }
558}
559
560// ---------------------------------------------------------------------------
561// FakePush
562
563/// How a [`FakePush`] responds, mirroring [`MailerMode`] for the push port.
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565pub enum PushMode {
566    /// Accept and record the notification.
567    DeliverOk,
568    /// Report the adapter is not configured (no key).
569    NotConfigured,
570    /// Report the device token is dead (APNs `410`): the caller prunes it.
571    Unregistered,
572    /// A retryable failure.
573    Transient,
574}
575
576/// An in-memory [`cratefield_core::Push`] for module tests: records every
577/// `(token, notification)` and answers according to its [`PushMode`].
578#[derive(Clone)]
579pub struct FakePush {
580    inner: Arc<FakePushInner>,
581}
582
583struct FakePushInner {
584    mode: Mutex<PushMode>,
585    sent: Mutex<Vec<(String, cratefield_core::Notification)>>,
586}
587
588impl FakePush {
589    #[must_use]
590    pub fn new(mode: PushMode) -> Self {
591        Self {
592            inner: Arc::new(FakePushInner {
593                mode: Mutex::new(mode),
594                sent: Mutex::new(Vec::new()),
595            }),
596        }
597    }
598
599    /// Every `(token, notification)` recorded so far.
600    #[must_use]
601    pub fn sent(&self) -> Vec<(String, cratefield_core::Notification)> {
602        self.inner.sent.lock().expect("push lock").clone()
603    }
604
605    /// The most recent `(token, notification)`.
606    #[must_use]
607    pub fn last(&self) -> Option<(String, cratefield_core::Notification)> {
608        self.inner.sent.lock().expect("push lock").last().cloned()
609    }
610
611    /// Switches the mode (e.g. flip to `Unregistered` mid-test).
612    pub fn set_mode(&self, mode: PushMode) {
613        *self.inner.mode.lock().expect("push lock") = mode;
614    }
615}
616
617impl Default for FakePush {
618    fn default() -> Self {
619        Self::new(PushMode::DeliverOk)
620    }
621}
622
623#[async_trait]
624impl cratefield_core::Push for FakePush {
625    async fn send(
626        &self,
627        device_token: &str,
628        notification: &cratefield_core::Notification,
629    ) -> Result<cratefield_core::PushOutcome, cratefield_core::PushError> {
630        let mode = *self.inner.mode.lock().expect("push lock");
631        match mode {
632            PushMode::DeliverOk => {
633                let id = format!(
634                    "fake-apns-{}",
635                    self.inner.sent.lock().expect("push lock").len()
636                );
637                self.inner
638                    .sent
639                    .lock()
640                    .expect("push lock")
641                    .push((device_token.to_owned(), notification.clone()));
642                Ok(cratefield_core::PushOutcome::Delivered { id: Some(id) })
643            }
644            PushMode::NotConfigured => Ok(cratefield_core::PushOutcome::NotConfigured),
645            PushMode::Unregistered => Err(cratefield_core::PushError::Unregistered),
646            PushMode::Transient => Err(cratefield_core::PushError::Transient(
647                "fake push failure".to_owned(),
648            )),
649        }
650    }
651}
652
653// ---------------------------------------------------------------------------
654// FakePayments
655
656/// How a [`FakePayments`] responds.
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658pub enum PaymentsMode {
659    /// Succeed and record the call.
660    Ok,
661    /// Report `NotConfigured` (no Stripe key).
662    NotConfigured,
663    /// A retryable failure.
664    Transient,
665}
666
667/// What a [`FakePayments`] recorded, for assertions.
668#[derive(Debug, Clone, PartialEq, Eq)]
669pub enum PaymentsCall {
670    Checkout,
671    SubscriptionCheckout,
672    ConnectAccountLink,
673    ChargeWithTransfer,
674    Refund,
675    VerifyWebhook,
676}
677
678/// An in-memory [`cratefield_core::Payments`] for module tests: records which
679/// calls were made and answers per its [`PaymentsMode`]. `verify_webhook`
680/// treats a signature header of `"invalid"` as a tampered event.
681#[derive(Clone)]
682pub struct FakePayments {
683    inner: Arc<FakePaymentsInner>,
684}
685
686struct FakePaymentsInner {
687    mode: Mutex<PaymentsMode>,
688    calls: Mutex<Vec<PaymentsCall>>,
689}
690
691impl FakePayments {
692    #[must_use]
693    pub fn new(mode: PaymentsMode) -> Self {
694        Self {
695            inner: Arc::new(FakePaymentsInner {
696                mode: Mutex::new(mode),
697                calls: Mutex::new(Vec::new()),
698            }),
699        }
700    }
701
702    /// The calls recorded so far.
703    #[must_use]
704    pub fn calls(&self) -> Vec<PaymentsCall> {
705        self.inner.calls.lock().expect("payments lock").clone()
706    }
707
708    pub fn set_mode(&self, mode: PaymentsMode) {
709        *self.inner.mode.lock().expect("payments lock") = mode;
710    }
711
712    fn record(&self, call: PaymentsCall) {
713        self.inner.calls.lock().expect("payments lock").push(call);
714    }
715
716    fn guard(&self) -> Result<(), cratefield_core::PaymentsError> {
717        match *self.inner.mode.lock().expect("payments lock") {
718            PaymentsMode::Ok => Ok(()),
719            PaymentsMode::NotConfigured => Err(cratefield_core::PaymentsError::NotConfigured),
720            PaymentsMode::Transient => Err(cratefield_core::PaymentsError::Transient(
721                "fake payments failure".to_owned(),
722            )),
723        }
724    }
725}
726
727impl Default for FakePayments {
728    fn default() -> Self {
729        Self::new(PaymentsMode::Ok)
730    }
731}
732
733#[async_trait]
734impl cratefield_core::Payments for FakePayments {
735    async fn create_checkout(
736        &self,
737        _request: &cratefield_core::CheckoutRequest,
738    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
739        self.guard()?;
740        self.record(PaymentsCall::Checkout);
741        Ok(cratefield_core::CheckoutSession {
742            id: "cs_fake".to_owned(),
743            url: "https://checkout.stripe.test/cs_fake".to_owned(),
744        })
745    }
746
747    async fn create_subscription_checkout(
748        &self,
749        _request: &cratefield_core::SubscriptionCheckoutRequest,
750    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
751        self.guard()?;
752        self.record(PaymentsCall::SubscriptionCheckout);
753        Ok(cratefield_core::CheckoutSession {
754            id: "cs_sub_fake".to_owned(),
755            url: "https://checkout.stripe.test/cs_sub_fake".to_owned(),
756        })
757    }
758
759    async fn create_connect_account_link(
760        &self,
761        _request: &cratefield_core::ConnectAccountLinkRequest,
762    ) -> Result<cratefield_core::ConnectAccountLink, cratefield_core::PaymentsError> {
763        self.guard()?;
764        self.record(PaymentsCall::ConnectAccountLink);
765        Ok(cratefield_core::ConnectAccountLink {
766            account_id: "acct_fake".to_owned(),
767            url: "https://connect.stripe.test/acct_fake".to_owned(),
768        })
769    }
770
771    async fn charge_with_transfer(
772        &self,
773        _request: &cratefield_core::TransferCharge,
774    ) -> Result<cratefield_core::Charge, cratefield_core::PaymentsError> {
775        self.guard()?;
776        self.record(PaymentsCall::ChargeWithTransfer);
777        Ok(cratefield_core::Charge {
778            id: "pi_fake".to_owned(),
779            status: "succeeded".to_owned(),
780        })
781    }
782
783    async fn refund(
784        &self,
785        _request: &cratefield_core::RefundRequest,
786    ) -> Result<cratefield_core::Refund, cratefield_core::PaymentsError> {
787        self.guard()?;
788        self.record(PaymentsCall::Refund);
789        Ok(cratefield_core::Refund {
790            id: "re_fake".to_owned(),
791        })
792    }
793
794    async fn verify_webhook(
795        &self,
796        signature_header: &str,
797        _body: &[u8],
798    ) -> Result<cratefield_core::WebhookEvent, cratefield_core::PaymentsError> {
799        self.record(PaymentsCall::VerifyWebhook);
800        if signature_header == "invalid" {
801            return Err(cratefield_core::PaymentsError::SignatureInvalid(
802                "fake tampered signature".to_owned(),
803            ));
804        }
805        self.guard()?;
806        Ok(cratefield_core::WebhookEvent {
807            id: "evt_fake".to_owned(),
808            kind: "checkout.session.completed".to_owned(),
809            data: serde_json::json!({ "object": "checkout.session" }),
810        })
811    }
812}