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}