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/// How a [`FakeMailer`] answers.
36///
37/// [`Error`](Self::Error) is what makes the failure arms testable at all
38/// (issue #236): before it the fake could only ever produce
39/// `MailError::Upstream("fake mailer failure")`, so `Invalid { detail }`
40/// and `DomainNotVerified { domain }` — the two variants that carry the
41/// provider's own text, and therefore the two that can carry a recipient
42/// address — could not be driven from a test. Every arm of a caller's
43/// outcome mapping is now reachable, with the text the caller chooses:
44///
45/// ```
46/// # use cratefield_testing::{FakeMailer, MailerMode};
47/// # use cratefield_core::MailError;
48/// let mailer = FakeMailer::new(MailerMode::Error(MailError::Invalid {
49///     detail: "to: alice@example.test is suppressed".to_owned(),
50/// }));
51/// ```
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum MailerMode {
54    /// Accept and record the message.
55    SendOk,
56    /// Report that the adapter has no API key or no verified domain.
57    NotConfigured,
58    /// A generic upstream failure, for a caller that does not care which.
59    Fail,
60    /// Exactly this error, text and all.
61    Error(MailError),
62}
63
64#[derive(Clone)]
65pub struct FakeMailer {
66    inner: Arc<FakeMailerInner>,
67}
68
69struct FakeMailerInner {
70    mode: Mutex<MailerMode>,
71    sent: Mutex<Vec<Message>>,
72}
73
74impl FakeMailer {
75    #[must_use]
76    pub fn new(mode: MailerMode) -> Self {
77        Self {
78            inner: Arc::new(FakeMailerInner {
79                mode: Mutex::new(mode),
80                sent: Mutex::new(Vec::new()),
81            }),
82        }
83    }
84
85    /// Every message recorded so far.
86    #[must_use]
87    pub fn sent(&self) -> Vec<Message> {
88        self.inner.sent.lock().expect("mailer lock").clone()
89    }
90
91    /// The most recent message.
92    #[must_use]
93    pub fn last_message(&self) -> Option<Message> {
94        self.inner.sent.lock().expect("mailer lock").last().cloned()
95    }
96
97    /// Switches the mode (e.g. degrade to `NotConfigured` mid-test).
98    pub fn set_mode(&self, mode: MailerMode) {
99        *self.inner.mode.lock().expect("mailer lock") = mode;
100    }
101}
102
103#[async_trait]
104impl Mailer for FakeMailer {
105    async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
106        let mode = self.inner.mode.lock().expect("mailer lock").clone();
107        match mode {
108            MailerMode::SendOk => {
109                let id = format!(
110                    "fake-{}",
111                    self.inner.sent.lock().expect("mailer lock").len()
112                );
113                self.inner.sent.lock().expect("mailer lock").push(message);
114                Ok(SendOutcome::Sent { id })
115            }
116            MailerMode::NotConfigured => Ok(SendOutcome::NotConfigured),
117            MailerMode::Fail => Err(MailError::Upstream("fake mailer failure".to_string())),
118            MailerMode::Error(error) => Err(error),
119        }
120    }
121}
122
123// ---------------------------------------------------------------------------
124// FakeCaptcha
125
126#[derive(Clone)]
127pub struct FakeCaptcha {
128    allow_all: bool,
129    allowed_tokens: Arc<Vec<String>>,
130}
131
132impl FakeCaptcha {
133    /// Every token verifies.
134    #[must_use]
135    pub fn allow_all() -> Self {
136        Self {
137            allow_all: true,
138            allowed_tokens: Arc::new(Vec::new()),
139        }
140    }
141
142    /// Only the listed tokens verify.
143    #[must_use]
144    pub fn with_tokens(tokens: impl IntoIterator<Item = impl Into<String>>) -> Self {
145        Self {
146            allow_all: false,
147            allowed_tokens: Arc::new(tokens.into_iter().map(Into::into).collect()),
148        }
149    }
150}
151
152#[async_trait]
153impl Captcha for FakeCaptcha {
154    async fn verify(&self, token: &str, _remote_ip: Option<&str>) -> Result<Verdict, CaptchaError> {
155        let ok = self.allow_all || self.allowed_tokens.iter().any(|t| t == token);
156        Ok(Verdict {
157            ok,
158            reason: (!ok).then(|| "token not allowed".to_string()),
159        })
160    }
161
162    fn binding(&self) -> Option<cratefield_core::CaptchaBinding> {
163        Some(cratefield_core::CaptchaBinding {
164            hostname_bound: true,
165            action_bound: true,
166            fail_open: false,
167        })
168    }
169}
170
171// ---------------------------------------------------------------------------
172// FakeRateLimiter (scripted)
173
174#[derive(Clone)]
175pub struct FakeRateLimiter {
176    inner: Arc<FakeRateLimiterInner>,
177}
178
179struct FakeRateLimiterInner {
180    scripted: Mutex<VecDeque<Decision>>,
181    default: Decision,
182    calls: AtomicUsize,
183}
184
185impl FakeRateLimiter {
186    /// Falls through to `default` once the script is exhausted.
187    #[must_use]
188    pub fn scripted(decisions: Vec<Decision>, default: Decision) -> Self {
189        Self {
190            inner: Arc::new(FakeRateLimiterInner {
191                scripted: Mutex::new(decisions.into_iter().collect()),
192                default,
193                calls: AtomicUsize::new(0),
194            }),
195        }
196    }
197
198    /// Always allows.
199    #[must_use]
200    pub fn always_allow() -> Self {
201        Self::scripted(
202            Vec::new(),
203            Decision {
204                ok: true,
205                retry_after: None,
206            },
207        )
208    }
209
210    #[must_use]
211    pub fn calls(&self) -> usize {
212        self.inner.calls.load(Ordering::SeqCst)
213    }
214}
215
216#[async_trait]
217impl RateLimiter for FakeRateLimiter {
218    async fn limit(&self, _key: &str) -> Result<Decision, RateLimitError> {
219        self.inner.calls.fetch_add(1, Ordering::SeqCst);
220        let scripted = self
221            .inner
222            .scripted
223            .lock()
224            .expect("limiter lock")
225            .pop_front();
226        Ok(scripted.unwrap_or_else(|| self.inner.default.clone()))
227    }
228}
229
230// ---------------------------------------------------------------------------
231// FixedClock
232
233#[derive(Debug, Clone)]
234pub struct FixedClock(pub time::OffsetDateTime);
235
236#[async_trait]
237impl Clock for FixedClock {
238    fn now(&self) -> time::OffsetDateTime {
239        self.0
240    }
241}
242
243// ---------------------------------------------------------------------------
244// MemoryKeyValue
245
246#[derive(Clone, Default)]
247pub struct MemoryKeyValue {
248    inner: Arc<MemoryKeyValueInner>,
249}
250
251#[derive(Default)]
252struct MemoryKeyValueInner {
253    entries: Mutex<HashMap<String, String>>,
254}
255
256impl MemoryKeyValue {
257    #[must_use]
258    pub fn new() -> Self {
259        Self::default()
260    }
261}
262
263#[async_trait]
264impl KeyValue for MemoryKeyValue {
265    async fn get(&self, key: &str) -> Result<Option<String>, KvError> {
266        Ok(self
267            .inner
268            .entries
269            .lock()
270            .expect("kv lock")
271            .get(key)
272            .cloned())
273    }
274
275    async fn put(&self, key: &str, value: &str, _ttl: Option<Duration>) -> Result<(), KvError> {
276        self.inner
277            .entries
278            .lock()
279            .expect("kv lock")
280            .insert(key.to_string(), value.to_string());
281        Ok(())
282    }
283
284    async fn delete(&self, key: &str) -> Result<(), KvError> {
285        self.inner.entries.lock().expect("kv lock").remove(key);
286        Ok(())
287    }
288}
289
290// ---------------------------------------------------------------------------
291// FakeHttpClient (scripted responses, captures requests)
292
293#[derive(Clone)]
294pub struct FakeHttpClient {
295    inner: Arc<FakeHttpInner>,
296}
297
298struct FakeHttpInner {
299    responses: Mutex<VecDeque<Result<Response<Bytes>, HttpError>>>,
300    captured: Mutex<Vec<(String, String, String)>>, // method, uri, body
301}
302
303impl FakeHttpClient {
304    /// Responds with `responses` in order, then always with a 500.
305    #[must_use]
306    pub fn scripted(responses: Vec<Result<Response<Bytes>, HttpError>>) -> Self {
307        Self {
308            inner: Arc::new(FakeHttpInner {
309                responses: Mutex::new(responses.into_iter().collect()),
310                captured: Mutex::new(Vec::new()),
311            }),
312        }
313    }
314
315    #[must_use]
316    pub fn ok_json(body: &'static str) -> Self {
317        Self::scripted(vec![
318            Response::builder()
319                .status(200)
320                .body(Bytes::from(body))
321                .map_err(|err| HttpError::Transport(err.to_string())),
322        ])
323    }
324
325    /// Every captured request as `(method, uri, body)`.
326    #[must_use]
327    pub fn captured(&self) -> Vec<(String, String, String)> {
328        self.inner.captured.lock().expect("http lock").clone()
329    }
330}
331
332#[async_trait]
333impl HttpClient for FakeHttpClient {
334    async fn send(&self, request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
335        let (parts, body) = request.into_parts();
336        self.inner.captured.lock().expect("http lock").push((
337            parts.method.to_string(),
338            parts.uri.to_string(),
339            String::from_utf8_lossy(&body).to_string(),
340        ));
341        let next = self.inner.responses.lock().expect("http lock").pop_front();
342        next.unwrap_or_else(|| Err(HttpError::Transport("fake http exhausted".to_string())))
343    }
344}
345
346// ---------------------------------------------------------------------------
347// FakeDefer (collects futures; drain runs them)
348
349#[derive(Clone, Default)]
350pub struct FakeDefer {
351    inner: Arc<FakeDeferInner>,
352}
353
354#[derive(Default)]
355struct FakeDeferInner {
356    pending: Mutex<Vec<BoxFuture<'static, ()>>>,
357    deferred: AtomicUsize,
358}
359
360impl FakeDefer {
361    #[must_use]
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    /// Runs every deferred future to completion, in deferral order.
367    // Async for API symmetry with `drain().await` call sites (the futures
368    // run on a sync block-on inside).
369    #[allow(clippy::unused_async, clippy::unused_async_trait_impl)]
370    pub async fn drain(&self) {
371        while !self.inner.pending.lock().expect("defer lock").is_empty() {
372            let next = self.inner.pending.lock().expect("defer lock").remove(0);
373            pollster::block_on(next);
374        }
375    }
376
377    #[must_use]
378    pub fn deferred_count(&self) -> usize {
379        self.inner.deferred.load(Ordering::SeqCst)
380    }
381}
382
383impl Defer for FakeDefer {
384    fn wait_until(&self, fut: BoxFuture<'static, ()>) {
385        self.inner.deferred.fetch_add(1, Ordering::SeqCst);
386        self.inner.pending.lock().expect("defer lock").push(fut);
387    }
388}
389
390// ---------------------------------------------------------------------------
391// Transparent Database passthrough (re-exported for tests that need a
392// trivial Database without SQLite): an always-empty database.
393
394#[derive(Debug, Clone, Copy, Default)]
395pub struct EmptyDatabase;
396
397#[async_trait]
398impl Database for EmptyDatabase {
399    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
400        Err(DbError::Execute(format!("empty database: {}", stmt.sql)))
401    }
402
403    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
404        if stmt.sql.trim() == "SELECT 1" {
405            Ok(Rows::new(vec![Row::new(vec![(
406                "1".to_string(),
407                sea_query::Value::Int(Some(1)),
408            )])]))
409        } else {
410            Err(DbError::Query(format!("empty database: {}", stmt.sql)))
411        }
412    }
413
414    async fn batch_atomic(&self, _stmts: &[Statement]) -> Result<(), DbError> {
415        Err(DbError::Batch("empty database".to_string()))
416    }
417}
418
419/// An in-process [`Dispatcher`](cratefield_core::Dispatcher) that answers from an
420/// axum [`Router`](axum::Router), so a
421/// module can be exercised through a sidecar mount without a network or a
422/// second Worker (ADR 0009). The conformance kit uses it to run the same
423/// assertions against both mounts (#64).
424///
425/// Also the failure fixture: [`unbound`](FakeDispatcher::unbound) has no
426/// binding at all, and [`failing`](FakeDispatcher::failing) accepts the
427/// binding and then refuses to answer.
428#[derive(Clone)]
429pub struct FakeDispatcher {
430    binding: String,
431    behaviour: FakeDispatch,
432    calls: Arc<AtomicUsize>,
433}
434
435#[derive(Clone)]
436enum FakeDispatch {
437    Serve(Arc<Mutex<axum::Router>>),
438    Unbound,
439    Failing(String),
440}
441
442impl FakeDispatcher {
443    /// Serves `router` on `binding`.
444    #[must_use]
445    pub fn serving(binding: impl Into<String>, router: axum::Router) -> Self {
446        Self {
447            binding: binding.into(),
448            behaviour: FakeDispatch::Serve(Arc::new(Mutex::new(router))),
449            calls: Arc::new(AtomicUsize::new(0)),
450        }
451    }
452
453    /// Has no bindings, so `has()` is always false: the "mounted but this
454    /// deployment has no such binding" case.
455    #[must_use]
456    pub fn unbound() -> Self {
457        Self {
458            binding: String::new(),
459            behaviour: FakeDispatch::Unbound,
460            calls: Arc::new(AtomicUsize::new(0)),
461        }
462    }
463
464    /// Accepts `binding` and then fails to answer.
465    #[must_use]
466    pub fn failing(binding: impl Into<String>, reason: impl Into<String>) -> Self {
467        Self {
468            binding: binding.into(),
469            behaviour: FakeDispatch::Failing(reason.into()),
470            calls: Arc::new(AtomicUsize::new(0)),
471        }
472    }
473
474    /// How many dispatches were attempted. A forwarder must not retry, so a
475    /// single request must leave this at one.
476    #[must_use]
477    pub fn calls(&self) -> usize {
478        self.calls.load(Ordering::SeqCst)
479    }
480}
481
482#[async_trait]
483impl cratefield_core::Dispatcher for FakeDispatcher {
484    fn has(&self, binding: &str) -> bool {
485        !matches!(self.behaviour, FakeDispatch::Unbound) && binding == self.binding
486    }
487
488    async fn dispatch(
489        &self,
490        binding: &str,
491        request: Request<Bytes>,
492    ) -> Result<Response<Bytes>, cratefield_core::DispatchError> {
493        self.calls.fetch_add(1, Ordering::SeqCst);
494        match &self.behaviour {
495            FakeDispatch::Unbound => {
496                Err(cratefield_core::DispatchError::NotBound(binding.to_owned()))
497            }
498            FakeDispatch::Failing(reason) => Err(cratefield_core::DispatchError::Unavailable {
499                binding: binding.to_owned(),
500                reason: reason.clone(),
501            }),
502            FakeDispatch::Serve(router) => {
503                let router = router.lock().unwrap().clone();
504                let (parts, body) = request.into_parts();
505                let request = Request::from_parts(parts, axum::body::Body::from(body));
506                let response =
507                    tower::ServiceExt::oneshot(router, request)
508                        .await
509                        .map_err(|err| cratefield_core::DispatchError::Unavailable {
510                            binding: binding.to_owned(),
511                            reason: err.to_string(),
512                        })?;
513                let (parts, body) = response.into_parts();
514                let bytes = axum::body::to_bytes(body, usize::MAX)
515                    .await
516                    .map_err(|err| cratefield_core::DispatchError::Unavailable {
517                        binding: binding.to_owned(),
518                        reason: err.to_string(),
519                    })?;
520                Ok(Response::from_parts(parts, bytes))
521            }
522        }
523    }
524}
525
526/// An in-memory [`cratefield_core::Blob`] store for module tests: keeps objects
527/// in a map, and has no presigned URLs (so `signed_url` reports `Unsupported`,
528/// as a directory store does).
529#[derive(Clone, Default)]
530pub struct MemoryBlob {
531    objects: Arc<std::sync::Mutex<std::collections::HashMap<String, cratefield_core::BlobObject>>>,
532}
533
534impl MemoryBlob {
535    #[must_use]
536    pub fn new() -> Self {
537        Self::default()
538    }
539
540    /// How many objects are stored, for assertions.
541    #[must_use]
542    pub fn len(&self) -> usize {
543        self.objects.lock().unwrap().len()
544    }
545
546    /// Whether the store is empty, for assertions.
547    #[must_use]
548    pub fn is_empty(&self) -> bool {
549        self.len() == 0
550    }
551}
552
553#[async_trait]
554impl cratefield_core::Blob for MemoryBlob {
555    async fn put(
556        &self,
557        key: &str,
558        bytes: &[u8],
559        content_type: &str,
560    ) -> Result<(), cratefield_core::BlobError> {
561        cratefield_core::check_blob_size(bytes)?;
562        self.objects.lock().unwrap().insert(
563            key.to_owned(),
564            cratefield_core::BlobObject {
565                bytes: bytes.to_vec(),
566                content_type: content_type.to_owned(),
567            },
568        );
569        Ok(())
570    }
571    async fn get(
572        &self,
573        key: &str,
574    ) -> Result<Option<cratefield_core::BlobObject>, cratefield_core::BlobError> {
575        Ok(self.objects.lock().unwrap().get(key).cloned())
576    }
577    async fn delete(&self, key: &str) -> Result<(), cratefield_core::BlobError> {
578        self.objects.lock().unwrap().remove(key);
579        Ok(())
580    }
581    async fn signed_url(
582        &self,
583        _key: &str,
584        _ttl: std::time::Duration,
585    ) -> Result<String, cratefield_core::BlobError> {
586        Err(cratefield_core::BlobError::Unsupported(
587            "in-memory store has no presigned URLs".to_owned(),
588        ))
589    }
590}
591
592// ---------------------------------------------------------------------------
593// FakePush
594
595/// How a [`FakePush`] responds, mirroring [`MailerMode`] for the push port.
596///
597/// [`Error`](Self::Error) carries the provider text a real adapter would
598/// have wrapped (issue #236). The fixed modes only ever produce clean
599/// strings, so no test using them could show a device token or a push
600/// endpoint arriving somewhere it should not.
601#[derive(Debug, Clone, PartialEq, Eq)]
602pub enum PushMode {
603    /// Accept and record the notification.
604    DeliverOk,
605    /// Report the adapter is not configured (no key).
606    NotConfigured,
607    /// Report the device token is dead (APNs `410`): the caller prunes it.
608    Unregistered,
609    /// A retryable failure.
610    Transient,
611    /// Exactly this error, text and all.
612    Error(cratefield_core::PushError),
613}
614
615/// An in-memory [`cratefield_core::Push`] for module tests: records every
616/// `(recipient, notification)` and answers according to its [`PushMode`].
617///
618/// The mode is global by default and can be overridden **per recipient**
619/// ([`FakePush::set_mode_for`]), so a fan-out test can make exactly one of
620/// five devices dead and assert that only that one is pruned — the thing a
621/// single global mode cannot express (issue #177).
622#[derive(Clone)]
623pub struct FakePush {
624    inner: Arc<FakePushInner>,
625}
626
627struct FakePushInner {
628    mode: Mutex<PushMode>,
629    per_recipient: Mutex<HashMap<cratefield_core::Recipient, PushMode>>,
630    sent: Mutex<Vec<(cratefield_core::Recipient, cratefield_core::Notification)>>,
631}
632
633impl FakePush {
634    #[must_use]
635    pub fn new(mode: PushMode) -> Self {
636        Self {
637            inner: Arc::new(FakePushInner {
638                mode: Mutex::new(mode),
639                per_recipient: Mutex::new(HashMap::new()),
640                sent: Mutex::new(Vec::new()),
641            }),
642        }
643    }
644
645    /// Every `(recipient, notification)` delivered so far. A send that
646    /// answered `NotConfigured` or failed is not recorded.
647    #[must_use]
648    pub fn sent(&self) -> Vec<(cratefield_core::Recipient, cratefield_core::Notification)> {
649        self.inner.sent.lock().expect("push lock").clone()
650    }
651
652    /// The most recent `(recipient, notification)`.
653    #[must_use]
654    pub fn last(&self) -> Option<(cratefield_core::Recipient, cratefield_core::Notification)> {
655        self.inner.sent.lock().expect("push lock").last().cloned()
656    }
657
658    /// Everything delivered to one recipient.
659    #[must_use]
660    pub fn sent_to(
661        &self,
662        recipient: &cratefield_core::Recipient,
663    ) -> Vec<cratefield_core::Notification> {
664        self.inner
665            .sent
666            .lock()
667            .expect("push lock")
668            .iter()
669            .filter(|(to, _)| to == recipient)
670            .map(|(_, notification)| notification.clone())
671            .collect()
672    }
673
674    /// Switches the mode every recipient without an override answers with
675    /// (e.g. flip to `Unregistered` mid-test).
676    pub fn set_mode(&self, mode: PushMode) {
677        *self.inner.mode.lock().expect("push lock") = mode;
678    }
679
680    /// Makes one recipient answer with `mode`, whatever the global mode is —
681    /// one dead token among live ones, say.
682    pub fn set_mode_for(&self, recipient: &cratefield_core::Recipient, mode: PushMode) {
683        self.inner
684            .per_recipient
685            .lock()
686            .expect("push lock")
687            .insert(recipient.clone(), mode);
688    }
689
690    /// Drops one recipient's override, putting it back on the global mode.
691    pub fn clear_mode_for(&self, recipient: &cratefield_core::Recipient) {
692        self.inner
693            .per_recipient
694            .lock()
695            .expect("push lock")
696            .remove(recipient);
697    }
698
699    /// The mode `recipient` will answer with.
700    #[must_use]
701    pub fn mode_for(&self, recipient: &cratefield_core::Recipient) -> PushMode {
702        self.inner
703            .per_recipient
704            .lock()
705            .expect("push lock")
706            .get(recipient)
707            .cloned()
708            .unwrap_or_else(|| self.inner.mode.lock().expect("push lock").clone())
709    }
710}
711
712impl Default for FakePush {
713    fn default() -> Self {
714        Self::new(PushMode::DeliverOk)
715    }
716}
717
718#[async_trait]
719impl cratefield_core::Push for FakePush {
720    async fn send(
721        &self,
722        to: &cratefield_core::Recipient,
723        notification: &cratefield_core::Notification,
724    ) -> Result<cratefield_core::PushOutcome, cratefield_core::PushError> {
725        match self.mode_for(to) {
726            PushMode::DeliverOk => {
727                let id = format!(
728                    "fake-push-{}",
729                    self.inner.sent.lock().expect("push lock").len()
730                );
731                self.inner
732                    .sent
733                    .lock()
734                    .expect("push lock")
735                    .push((to.clone(), notification.clone()));
736                Ok(cratefield_core::PushOutcome::Delivered { id: Some(id) })
737            }
738            PushMode::NotConfigured => Ok(cratefield_core::PushOutcome::NotConfigured),
739            PushMode::Unregistered => Err(cratefield_core::PushError::Unregistered),
740            PushMode::Transient => Err(cratefield_core::PushError::transient("fake push failure")),
741            PushMode::Error(error) => Err(error),
742        }
743    }
744}
745
746// ---------------------------------------------------------------------------
747// FakePayments
748
749/// How a [`FakePayments`] responds.
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
751pub enum PaymentsMode {
752    /// Succeed and record the call.
753    Ok,
754    /// Report `NotConfigured` (no Stripe key).
755    NotConfigured,
756    /// A retryable failure.
757    Transient,
758}
759
760/// What a [`FakePayments`] recorded, for assertions.
761#[derive(Debug, Clone, PartialEq, Eq)]
762pub enum PaymentsCall {
763    Checkout,
764    SubscriptionCheckout,
765    ConnectAccountLink,
766    ChargeWithTransfer,
767    Refund,
768    VerifyWebhook,
769}
770
771/// An in-memory [`cratefield_core::Payments`] for module tests: records which
772/// calls were made and answers per its [`PaymentsMode`]. `verify_webhook`
773/// treats a signature header of `"invalid"` as a tampered event.
774#[derive(Clone)]
775pub struct FakePayments {
776    inner: Arc<FakePaymentsInner>,
777}
778
779struct FakePaymentsInner {
780    mode: Mutex<PaymentsMode>,
781    calls: Mutex<Vec<PaymentsCall>>,
782}
783
784impl FakePayments {
785    #[must_use]
786    pub fn new(mode: PaymentsMode) -> Self {
787        Self {
788            inner: Arc::new(FakePaymentsInner {
789                mode: Mutex::new(mode),
790                calls: Mutex::new(Vec::new()),
791            }),
792        }
793    }
794
795    /// The calls recorded so far.
796    #[must_use]
797    pub fn calls(&self) -> Vec<PaymentsCall> {
798        self.inner.calls.lock().expect("payments lock").clone()
799    }
800
801    pub fn set_mode(&self, mode: PaymentsMode) {
802        *self.inner.mode.lock().expect("payments lock") = mode;
803    }
804
805    fn record(&self, call: PaymentsCall) {
806        self.inner.calls.lock().expect("payments lock").push(call);
807    }
808
809    fn guard(&self) -> Result<(), cratefield_core::PaymentsError> {
810        match *self.inner.mode.lock().expect("payments lock") {
811            PaymentsMode::Ok => Ok(()),
812            PaymentsMode::NotConfigured => Err(cratefield_core::PaymentsError::NotConfigured),
813            PaymentsMode::Transient => Err(cratefield_core::PaymentsError::Transient(
814                "fake payments failure".to_owned(),
815            )),
816        }
817    }
818}
819
820impl Default for FakePayments {
821    fn default() -> Self {
822        Self::new(PaymentsMode::Ok)
823    }
824}
825
826#[async_trait]
827impl cratefield_core::Payments for FakePayments {
828    async fn create_checkout(
829        &self,
830        _request: &cratefield_core::CheckoutRequest,
831    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
832        self.guard()?;
833        self.record(PaymentsCall::Checkout);
834        Ok(cratefield_core::CheckoutSession {
835            id: "cs_fake".to_owned(),
836            url: "https://checkout.stripe.test/cs_fake".to_owned(),
837        })
838    }
839
840    async fn create_subscription_checkout(
841        &self,
842        _request: &cratefield_core::SubscriptionCheckoutRequest,
843    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
844        self.guard()?;
845        self.record(PaymentsCall::SubscriptionCheckout);
846        Ok(cratefield_core::CheckoutSession {
847            id: "cs_sub_fake".to_owned(),
848            url: "https://checkout.stripe.test/cs_sub_fake".to_owned(),
849        })
850    }
851
852    async fn create_connect_account_link(
853        &self,
854        _request: &cratefield_core::ConnectAccountLinkRequest,
855    ) -> Result<cratefield_core::ConnectAccountLink, cratefield_core::PaymentsError> {
856        self.guard()?;
857        self.record(PaymentsCall::ConnectAccountLink);
858        Ok(cratefield_core::ConnectAccountLink {
859            account_id: "acct_fake".to_owned(),
860            url: "https://connect.stripe.test/acct_fake".to_owned(),
861        })
862    }
863
864    async fn charge_with_transfer(
865        &self,
866        _request: &cratefield_core::TransferCharge,
867    ) -> Result<cratefield_core::Charge, cratefield_core::PaymentsError> {
868        self.guard()?;
869        self.record(PaymentsCall::ChargeWithTransfer);
870        Ok(cratefield_core::Charge {
871            id: "pi_fake".to_owned(),
872            status: "succeeded".to_owned(),
873        })
874    }
875
876    async fn refund(
877        &self,
878        _request: &cratefield_core::RefundRequest,
879    ) -> Result<cratefield_core::Refund, cratefield_core::PaymentsError> {
880        self.guard()?;
881        self.record(PaymentsCall::Refund);
882        Ok(cratefield_core::Refund {
883            id: "re_fake".to_owned(),
884        })
885    }
886
887    async fn verify_webhook(
888        &self,
889        signature_header: &str,
890        _body: &[u8],
891    ) -> Result<cratefield_core::WebhookEvent, cratefield_core::PaymentsError> {
892        self.record(PaymentsCall::VerifyWebhook);
893        if signature_header == "invalid" {
894            return Err(cratefield_core::PaymentsError::SignatureInvalid(
895                "fake tampered signature".to_owned(),
896            ));
897        }
898        self.guard()?;
899        Ok(cratefield_core::WebhookEvent {
900            id: "evt_fake".to_owned(),
901            kind: "checkout.session.completed".to_owned(),
902            data: serde_json::json!({ "object": "checkout.session" }),
903        })
904    }
905}
906
907// ---------------------------------------------------------------------------
908// FakeRealtime
909
910/// Room id -> the member ids a [`FakeRealtime`] reports for it.
911type RealtimeMembers = std::collections::HashMap<String, Vec<String>>;
912/// The `(room_id, message)` broadcasts a [`FakeRealtime`] recorded.
913type RealtimeBroadcasts = Vec<(String, Vec<u8>)>;
914
915/// An in-memory [`cratefield_core::Realtime`] for module tests: records every
916/// broadcast per room and reports a fixed member list. It exercises the port a
917/// module holds (broadcast/members from outside a socket); the socket lifecycle
918/// and `RoomHandler` are the runtime adapter's job, covered by the native
919/// adapter's own tests.
920#[derive(Clone, Default)]
921pub struct FakeRealtime {
922    broadcasts: Arc<Mutex<RealtimeBroadcasts>>,
923    members: Arc<Mutex<RealtimeMembers>>,
924}
925
926impl FakeRealtime {
927    #[must_use]
928    pub fn new() -> Self {
929        Self::default()
930    }
931
932    /// Presets the members a room reports (for `members` assertions).
933    pub fn set_members(&self, room_id: &str, ids: &[&str]) {
934        self.members.lock().expect("realtime lock").insert(
935            room_id.to_owned(),
936            ids.iter().map(|id| (*id).to_owned()).collect(),
937        );
938    }
939
940    /// Every `(room_id, message)` broadcast so far.
941    #[must_use]
942    pub fn broadcasts(&self) -> Vec<(String, Vec<u8>)> {
943        self.broadcasts.lock().expect("realtime lock").clone()
944    }
945}
946
947#[async_trait]
948impl cratefield_core::Realtime for FakeRealtime {
949    async fn broadcast(
950        &self,
951        room_id: &str,
952        message: &[u8],
953    ) -> Result<(), cratefield_core::RealtimeError> {
954        self.broadcasts
955            .lock()
956            .expect("realtime lock")
957            .push((room_id.to_owned(), message.to_vec()));
958        Ok(())
959    }
960
961    async fn members(
962        &self,
963        room_id: &str,
964    ) -> Result<Vec<cratefield_core::Member>, cratefield_core::RealtimeError> {
965        Ok(self
966            .members
967            .lock()
968            .expect("realtime lock")
969            .get(room_id)
970            .map(|ids| ids.iter().map(cratefield_core::Member::new).collect())
971            .unwrap_or_default())
972    }
973}