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    Answer, AnswerValue, Calibration, Captcha, CaptchaError, Classifier, ClassifierError,
18    ClassifierProfile, Clock, Completion, Credential, Database, DbError, Decision, Defer,
19    Destination, Filed, HttpClient, HttpError, KeyValue, KvError, MailError, Mailer, Message,
20    ModelTier, Prompt, Question, RateLimitError, RateLimiter, Row, Rows, SendOutcome, Statement,
21    TextModel, TextModelError, TicketDraft, TicketState, TicketStatus, Tracker, TrackerError,
22    Verdict, validate_questions,
23};
24use futures_core::future::BoxFuture;
25use http::{Request, Response};
26use std::collections::{BTreeMap, HashMap, VecDeque};
27use std::sync::Arc;
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::time::Duration;
30
31// Recording fixtures, not request state (see module docs).
32#[allow(clippy::disallowed_types)]
33use std::sync::Mutex;
34
35// ---------------------------------------------------------------------------
36// FakeMailer
37
38/// How a [`FakeMailer`] answers.
39///
40/// [`Error`](Self::Error) is what makes the failure arms testable at all
41/// (issue #236): before it the fake could only ever produce
42/// `MailError::Upstream("fake mailer failure")`, so `Invalid { detail }`
43/// and `DomainNotVerified { domain }` — the two variants that carry the
44/// provider's own text, and therefore the two that can carry a recipient
45/// address — could not be driven from a test. Every arm of a caller's
46/// outcome mapping is now reachable, with the text the caller chooses:
47///
48/// ```
49/// # use cratefield_testing::{FakeMailer, MailerMode};
50/// # use cratefield_core::MailError;
51/// let mailer = FakeMailer::new(MailerMode::Error(MailError::Invalid {
52///     detail: "to: alice@example.test is suppressed".to_owned(),
53/// }));
54/// ```
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum MailerMode {
57    /// Accept and record the message.
58    SendOk,
59    /// Report that the adapter has no API key or no verified domain.
60    NotConfigured,
61    /// A generic upstream failure, for a caller that does not care which.
62    Fail,
63    /// Exactly this error, text and all.
64    Error(MailError),
65}
66
67#[derive(Clone)]
68pub struct FakeMailer {
69    inner: Arc<FakeMailerInner>,
70}
71
72struct FakeMailerInner {
73    mode: Mutex<MailerMode>,
74    sent: Mutex<Vec<Message>>,
75}
76
77impl FakeMailer {
78    #[must_use]
79    pub fn new(mode: MailerMode) -> Self {
80        Self {
81            inner: Arc::new(FakeMailerInner {
82                mode: Mutex::new(mode),
83                sent: Mutex::new(Vec::new()),
84            }),
85        }
86    }
87
88    /// Every message recorded so far.
89    #[must_use]
90    pub fn sent(&self) -> Vec<Message> {
91        self.inner.sent.lock().expect("mailer lock").clone()
92    }
93
94    /// The most recent message.
95    #[must_use]
96    pub fn last_message(&self) -> Option<Message> {
97        self.inner.sent.lock().expect("mailer lock").last().cloned()
98    }
99
100    /// Switches the mode (e.g. degrade to `NotConfigured` mid-test).
101    pub fn set_mode(&self, mode: MailerMode) {
102        *self.inner.mode.lock().expect("mailer lock") = mode;
103    }
104}
105
106#[async_trait]
107impl Mailer for FakeMailer {
108    async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
109        let mode = self.inner.mode.lock().expect("mailer lock").clone();
110        match mode {
111            MailerMode::SendOk => {
112                let id = format!(
113                    "fake-{}",
114                    self.inner.sent.lock().expect("mailer lock").len()
115                );
116                self.inner.sent.lock().expect("mailer lock").push(message);
117                Ok(SendOutcome::Sent { id })
118            }
119            MailerMode::NotConfigured => Ok(SendOutcome::NotConfigured),
120            MailerMode::Fail => Err(MailError::Upstream("fake mailer failure".to_string())),
121            MailerMode::Error(error) => Err(error),
122        }
123    }
124}
125
126// ---------------------------------------------------------------------------
127// FakeCaptcha
128
129#[derive(Clone)]
130pub struct FakeCaptcha {
131    allow_all: bool,
132    allowed_tokens: Arc<Vec<String>>,
133}
134
135impl FakeCaptcha {
136    /// Every token verifies.
137    #[must_use]
138    pub fn allow_all() -> Self {
139        Self {
140            allow_all: true,
141            allowed_tokens: Arc::new(Vec::new()),
142        }
143    }
144
145    /// Only the listed tokens verify.
146    #[must_use]
147    pub fn with_tokens(tokens: impl IntoIterator<Item = impl Into<String>>) -> Self {
148        Self {
149            allow_all: false,
150            allowed_tokens: Arc::new(tokens.into_iter().map(Into::into).collect()),
151        }
152    }
153}
154
155#[async_trait]
156impl Captcha for FakeCaptcha {
157    async fn verify(&self, token: &str, _remote_ip: Option<&str>) -> Result<Verdict, CaptchaError> {
158        let ok = self.allow_all || self.allowed_tokens.iter().any(|t| t == token);
159        Ok(Verdict {
160            ok,
161            reason: (!ok).then(|| "token not allowed".to_string()),
162        })
163    }
164
165    fn binding(&self) -> Option<cratefield_core::CaptchaBinding> {
166        Some(cratefield_core::CaptchaBinding {
167            hostname_bound: true,
168            action_bound: true,
169            fail_open: false,
170        })
171    }
172}
173
174// ---------------------------------------------------------------------------
175// FakeRateLimiter (scripted)
176
177#[derive(Clone)]
178pub struct FakeRateLimiter {
179    inner: Arc<FakeRateLimiterInner>,
180}
181
182struct FakeRateLimiterInner {
183    scripted: Mutex<VecDeque<Decision>>,
184    default: Decision,
185    calls: AtomicUsize,
186}
187
188impl FakeRateLimiter {
189    /// Falls through to `default` once the script is exhausted.
190    #[must_use]
191    pub fn scripted(decisions: Vec<Decision>, default: Decision) -> Self {
192        Self {
193            inner: Arc::new(FakeRateLimiterInner {
194                scripted: Mutex::new(decisions.into_iter().collect()),
195                default,
196                calls: AtomicUsize::new(0),
197            }),
198        }
199    }
200
201    /// Always allows.
202    #[must_use]
203    pub fn always_allow() -> Self {
204        Self::scripted(
205            Vec::new(),
206            Decision {
207                ok: true,
208                retry_after: None,
209                quota: None,
210            },
211        )
212    }
213
214    #[must_use]
215    pub fn calls(&self) -> usize {
216        self.inner.calls.load(Ordering::SeqCst)
217    }
218}
219
220#[async_trait]
221impl RateLimiter for FakeRateLimiter {
222    async fn limit(&self, _key: &str) -> Result<Decision, RateLimitError> {
223        self.inner.calls.fetch_add(1, Ordering::SeqCst);
224        let scripted = self
225            .inner
226            .scripted
227            .lock()
228            .expect("limiter lock")
229            .pop_front();
230        Ok(scripted.unwrap_or_else(|| self.inner.default.clone()))
231    }
232}
233
234// ---------------------------------------------------------------------------
235// FixedClock
236
237#[derive(Debug, Clone)]
238pub struct FixedClock(pub time::OffsetDateTime);
239
240#[async_trait]
241impl Clock for FixedClock {
242    fn now(&self) -> time::OffsetDateTime {
243        self.0
244    }
245}
246
247// ---------------------------------------------------------------------------
248// MemoryKeyValue
249
250#[derive(Clone, Default)]
251pub struct MemoryKeyValue {
252    inner: Arc<MemoryKeyValueInner>,
253}
254
255#[derive(Default)]
256struct MemoryKeyValueInner {
257    entries: Mutex<HashMap<String, String>>,
258}
259
260impl MemoryKeyValue {
261    #[must_use]
262    pub fn new() -> Self {
263        Self::default()
264    }
265}
266
267#[async_trait]
268impl KeyValue for MemoryKeyValue {
269    async fn get(&self, key: &str) -> Result<Option<String>, KvError> {
270        Ok(self
271            .inner
272            .entries
273            .lock()
274            .expect("kv lock")
275            .get(key)
276            .cloned())
277    }
278
279    async fn put(&self, key: &str, value: &str, _ttl: Option<Duration>) -> Result<(), KvError> {
280        self.inner
281            .entries
282            .lock()
283            .expect("kv lock")
284            .insert(key.to_string(), value.to_string());
285        Ok(())
286    }
287
288    async fn delete(&self, key: &str) -> Result<(), KvError> {
289        self.inner.entries.lock().expect("kv lock").remove(key);
290        Ok(())
291    }
292}
293
294// ---------------------------------------------------------------------------
295// FakeHttpClient (scripted responses, captures requests)
296
297#[derive(Clone)]
298pub struct FakeHttpClient {
299    inner: Arc<FakeHttpInner>,
300}
301
302struct FakeHttpInner {
303    responses: Mutex<VecDeque<Result<Response<Bytes>, HttpError>>>,
304    captured: Mutex<Vec<(String, String, String)>>, // method, uri, body
305}
306
307impl FakeHttpClient {
308    /// Responds with `responses` in order, then always with a 500.
309    #[must_use]
310    pub fn scripted(responses: Vec<Result<Response<Bytes>, HttpError>>) -> Self {
311        Self {
312            inner: Arc::new(FakeHttpInner {
313                responses: Mutex::new(responses.into_iter().collect()),
314                captured: Mutex::new(Vec::new()),
315            }),
316        }
317    }
318
319    #[must_use]
320    pub fn ok_json(body: &'static str) -> Self {
321        Self::scripted(vec![
322            Response::builder()
323                .status(200)
324                .body(Bytes::from(body))
325                .map_err(|err| HttpError::Transport(err.to_string())),
326        ])
327    }
328
329    /// Every captured request as `(method, uri, body)`.
330    #[must_use]
331    pub fn captured(&self) -> Vec<(String, String, String)> {
332        self.inner.captured.lock().expect("http lock").clone()
333    }
334}
335
336#[async_trait]
337impl HttpClient for FakeHttpClient {
338    async fn send(&self, request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
339        let (parts, body) = request.into_parts();
340        self.inner.captured.lock().expect("http lock").push((
341            parts.method.to_string(),
342            parts.uri.to_string(),
343            String::from_utf8_lossy(&body).to_string(),
344        ));
345        let next = self.inner.responses.lock().expect("http lock").pop_front();
346        next.unwrap_or_else(|| Err(HttpError::Transport("fake http exhausted".to_string())))
347    }
348}
349
350// ---------------------------------------------------------------------------
351// FakeDefer (collects futures; drain runs them)
352
353#[derive(Clone, Default)]
354pub struct FakeDefer {
355    inner: Arc<FakeDeferInner>,
356}
357
358#[derive(Default)]
359struct FakeDeferInner {
360    pending: Mutex<Vec<BoxFuture<'static, ()>>>,
361    deferred: AtomicUsize,
362}
363
364impl FakeDefer {
365    #[must_use]
366    pub fn new() -> Self {
367        Self::default()
368    }
369
370    /// Runs every deferred future to completion, in deferral order.
371    // Async for API symmetry with `drain().await` call sites (the futures
372    // run on a sync block-on inside).
373    #[allow(clippy::unused_async, clippy::unused_async_trait_impl)]
374    pub async fn drain(&self) {
375        while !self.inner.pending.lock().expect("defer lock").is_empty() {
376            let next = self.inner.pending.lock().expect("defer lock").remove(0);
377            pollster::block_on(next);
378        }
379    }
380
381    #[must_use]
382    pub fn deferred_count(&self) -> usize {
383        self.inner.deferred.load(Ordering::SeqCst)
384    }
385}
386
387impl Defer for FakeDefer {
388    fn wait_until(&self, fut: BoxFuture<'static, ()>) {
389        self.inner.deferred.fetch_add(1, Ordering::SeqCst);
390        self.inner.pending.lock().expect("defer lock").push(fut);
391    }
392}
393
394// ---------------------------------------------------------------------------
395// Transparent Database passthrough (re-exported for tests that need a
396// trivial Database without SQLite): an always-empty database.
397
398#[derive(Debug, Clone, Copy, Default)]
399pub struct EmptyDatabase;
400
401#[async_trait]
402impl Database for EmptyDatabase {
403    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
404        Err(DbError::Execute(format!("empty database: {}", stmt.sql)))
405    }
406
407    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
408        if stmt.sql.trim() == "SELECT 1" {
409            Ok(Rows::new(vec![Row::new(vec![(
410                "1".to_string(),
411                sea_query::Value::Int(Some(1)),
412            )])]))
413        } else {
414            Err(DbError::Query(format!("empty database: {}", stmt.sql)))
415        }
416    }
417
418    async fn batch_atomic(&self, _stmts: &[Statement]) -> Result<(), DbError> {
419        Err(DbError::Batch("empty database".to_string()))
420    }
421}
422
423/// An in-process [`Dispatcher`](cratefield_core::Dispatcher) that answers from an
424/// axum [`Router`](axum::Router), so a
425/// module can be exercised through a sidecar mount without a network or a
426/// second Worker (ADR 0009). The conformance kit uses it to run the same
427/// assertions against both mounts (#64).
428///
429/// Also the failure fixture: [`unbound`](FakeDispatcher::unbound) has no
430/// binding at all, and [`failing`](FakeDispatcher::failing) accepts the
431/// binding and then refuses to answer.
432#[derive(Clone)]
433pub struct FakeDispatcher {
434    binding: String,
435    behaviour: FakeDispatch,
436    calls: Arc<AtomicUsize>,
437}
438
439#[derive(Clone)]
440enum FakeDispatch {
441    Serve(Arc<Mutex<axum::Router>>),
442    Unbound,
443    Failing(String),
444}
445
446impl FakeDispatcher {
447    /// Serves `router` on `binding`.
448    #[must_use]
449    pub fn serving(binding: impl Into<String>, router: axum::Router) -> Self {
450        Self {
451            binding: binding.into(),
452            behaviour: FakeDispatch::Serve(Arc::new(Mutex::new(router))),
453            calls: Arc::new(AtomicUsize::new(0)),
454        }
455    }
456
457    /// Has no bindings, so `has()` is always false: the "mounted but this
458    /// deployment has no such binding" case.
459    #[must_use]
460    pub fn unbound() -> Self {
461        Self {
462            binding: String::new(),
463            behaviour: FakeDispatch::Unbound,
464            calls: Arc::new(AtomicUsize::new(0)),
465        }
466    }
467
468    /// Accepts `binding` and then fails to answer.
469    #[must_use]
470    pub fn failing(binding: impl Into<String>, reason: impl Into<String>) -> Self {
471        Self {
472            binding: binding.into(),
473            behaviour: FakeDispatch::Failing(reason.into()),
474            calls: Arc::new(AtomicUsize::new(0)),
475        }
476    }
477
478    /// How many dispatches were attempted. A forwarder must not retry, so a
479    /// single request must leave this at one.
480    #[must_use]
481    pub fn calls(&self) -> usize {
482        self.calls.load(Ordering::SeqCst)
483    }
484}
485
486#[async_trait]
487impl cratefield_core::Dispatcher for FakeDispatcher {
488    fn has(&self, binding: &str) -> bool {
489        !matches!(self.behaviour, FakeDispatch::Unbound) && binding == self.binding
490    }
491
492    async fn dispatch(
493        &self,
494        binding: &str,
495        request: Request<Bytes>,
496    ) -> Result<Response<Bytes>, cratefield_core::DispatchError> {
497        self.calls.fetch_add(1, Ordering::SeqCst);
498        match &self.behaviour {
499            FakeDispatch::Unbound => {
500                Err(cratefield_core::DispatchError::NotBound(binding.to_owned()))
501            }
502            FakeDispatch::Failing(reason) => Err(cratefield_core::DispatchError::Unavailable {
503                binding: binding.to_owned(),
504                reason: reason.clone(),
505            }),
506            FakeDispatch::Serve(router) => {
507                let router = router.lock().unwrap().clone();
508                let (parts, body) = request.into_parts();
509                let request = Request::from_parts(parts, axum::body::Body::from(body));
510                let response =
511                    tower::ServiceExt::oneshot(router, request)
512                        .await
513                        .map_err(|err| cratefield_core::DispatchError::Unavailable {
514                            binding: binding.to_owned(),
515                            reason: err.to_string(),
516                        })?;
517                let (parts, body) = response.into_parts();
518                let bytes = axum::body::to_bytes(body, usize::MAX)
519                    .await
520                    .map_err(|err| cratefield_core::DispatchError::Unavailable {
521                        binding: binding.to_owned(),
522                        reason: err.to_string(),
523                    })?;
524                Ok(Response::from_parts(parts, bytes))
525            }
526        }
527    }
528}
529
530/// An in-memory [`cratefield_core::Blob`] store for module tests: keeps objects
531/// in a map, and has no presigned URLs (so `signed_url` reports `Unsupported`,
532/// as a directory store does).
533#[derive(Clone, Default)]
534pub struct MemoryBlob {
535    objects: Arc<std::sync::Mutex<std::collections::HashMap<String, cratefield_core::BlobObject>>>,
536}
537
538impl MemoryBlob {
539    #[must_use]
540    pub fn new() -> Self {
541        Self::default()
542    }
543
544    /// How many objects are stored, for assertions.
545    #[must_use]
546    pub fn len(&self) -> usize {
547        self.objects.lock().unwrap().len()
548    }
549
550    /// Whether the store is empty, for assertions.
551    #[must_use]
552    pub fn is_empty(&self) -> bool {
553        self.len() == 0
554    }
555}
556
557#[async_trait]
558impl cratefield_core::Blob for MemoryBlob {
559    async fn put(
560        &self,
561        key: &str,
562        bytes: &[u8],
563        content_type: &str,
564    ) -> Result<(), cratefield_core::BlobError> {
565        cratefield_core::check_blob_size(bytes)?;
566        self.objects.lock().unwrap().insert(
567            key.to_owned(),
568            cratefield_core::BlobObject {
569                bytes: bytes.to_vec(),
570                content_type: content_type.to_owned(),
571            },
572        );
573        Ok(())
574    }
575    async fn get(
576        &self,
577        key: &str,
578    ) -> Result<Option<cratefield_core::BlobObject>, cratefield_core::BlobError> {
579        Ok(self.objects.lock().unwrap().get(key).cloned())
580    }
581    async fn delete(&self, key: &str) -> Result<(), cratefield_core::BlobError> {
582        self.objects.lock().unwrap().remove(key);
583        Ok(())
584    }
585    async fn signed_url(
586        &self,
587        _key: &str,
588        _ttl: std::time::Duration,
589    ) -> Result<String, cratefield_core::BlobError> {
590        Err(cratefield_core::BlobError::Unsupported(
591            "in-memory store has no presigned URLs".to_owned(),
592        ))
593    }
594}
595
596// ---------------------------------------------------------------------------
597// FakePush
598
599/// How a [`FakePush`] responds, mirroring [`MailerMode`] for the push port.
600///
601/// [`Error`](Self::Error) carries the provider text a real adapter would
602/// have wrapped (issue #236). The fixed modes only ever produce clean
603/// strings, so no test using them could show a device token or a push
604/// endpoint arriving somewhere it should not.
605#[derive(Debug, Clone, PartialEq, Eq)]
606pub enum PushMode {
607    /// Accept and record the notification.
608    DeliverOk,
609    /// Report the adapter is not configured (no key).
610    NotConfigured,
611    /// Report the device token is dead (APNs `410`): the caller prunes it.
612    Unregistered,
613    /// A retryable failure.
614    Transient,
615    /// Exactly this error, text and all.
616    Error(cratefield_core::PushError),
617}
618
619/// An in-memory [`cratefield_core::Push`] for module tests: records every
620/// `(recipient, notification)` and answers according to its [`PushMode`].
621///
622/// The mode is global by default and can be overridden **per recipient**
623/// ([`FakePush::set_mode_for`]), so a fan-out test can make exactly one of
624/// five devices dead and assert that only that one is pruned — the thing a
625/// single global mode cannot express (issue #177).
626#[derive(Clone)]
627pub struct FakePush {
628    inner: Arc<FakePushInner>,
629}
630
631struct FakePushInner {
632    mode: Mutex<PushMode>,
633    per_recipient: Mutex<HashMap<cratefield_core::Recipient, PushMode>>,
634    sent: Mutex<Vec<(cratefield_core::Recipient, cratefield_core::Notification)>>,
635}
636
637impl FakePush {
638    #[must_use]
639    pub fn new(mode: PushMode) -> Self {
640        Self {
641            inner: Arc::new(FakePushInner {
642                mode: Mutex::new(mode),
643                per_recipient: Mutex::new(HashMap::new()),
644                sent: Mutex::new(Vec::new()),
645            }),
646        }
647    }
648
649    /// Every `(recipient, notification)` delivered so far. A send that
650    /// answered `NotConfigured` or failed is not recorded.
651    #[must_use]
652    pub fn sent(&self) -> Vec<(cratefield_core::Recipient, cratefield_core::Notification)> {
653        self.inner.sent.lock().expect("push lock").clone()
654    }
655
656    /// The most recent `(recipient, notification)`.
657    #[must_use]
658    pub fn last(&self) -> Option<(cratefield_core::Recipient, cratefield_core::Notification)> {
659        self.inner.sent.lock().expect("push lock").last().cloned()
660    }
661
662    /// Everything delivered to one recipient.
663    #[must_use]
664    pub fn sent_to(
665        &self,
666        recipient: &cratefield_core::Recipient,
667    ) -> Vec<cratefield_core::Notification> {
668        self.inner
669            .sent
670            .lock()
671            .expect("push lock")
672            .iter()
673            .filter(|(to, _)| to == recipient)
674            .map(|(_, notification)| notification.clone())
675            .collect()
676    }
677
678    /// Switches the mode every recipient without an override answers with
679    /// (e.g. flip to `Unregistered` mid-test).
680    pub fn set_mode(&self, mode: PushMode) {
681        *self.inner.mode.lock().expect("push lock") = mode;
682    }
683
684    /// Makes one recipient answer with `mode`, whatever the global mode is —
685    /// one dead token among live ones, say.
686    pub fn set_mode_for(&self, recipient: &cratefield_core::Recipient, mode: PushMode) {
687        self.inner
688            .per_recipient
689            .lock()
690            .expect("push lock")
691            .insert(recipient.clone(), mode);
692    }
693
694    /// Drops one recipient's override, putting it back on the global mode.
695    pub fn clear_mode_for(&self, recipient: &cratefield_core::Recipient) {
696        self.inner
697            .per_recipient
698            .lock()
699            .expect("push lock")
700            .remove(recipient);
701    }
702
703    /// The mode `recipient` will answer with.
704    #[must_use]
705    pub fn mode_for(&self, recipient: &cratefield_core::Recipient) -> PushMode {
706        self.inner
707            .per_recipient
708            .lock()
709            .expect("push lock")
710            .get(recipient)
711            .cloned()
712            .unwrap_or_else(|| self.inner.mode.lock().expect("push lock").clone())
713    }
714}
715
716impl Default for FakePush {
717    fn default() -> Self {
718        Self::new(PushMode::DeliverOk)
719    }
720}
721
722#[async_trait]
723impl cratefield_core::Push for FakePush {
724    async fn send(
725        &self,
726        to: &cratefield_core::Recipient,
727        notification: &cratefield_core::Notification,
728    ) -> Result<cratefield_core::PushOutcome, cratefield_core::PushError> {
729        match self.mode_for(to) {
730            PushMode::DeliverOk => {
731                let id = format!(
732                    "fake-push-{}",
733                    self.inner.sent.lock().expect("push lock").len()
734                );
735                self.inner
736                    .sent
737                    .lock()
738                    .expect("push lock")
739                    .push((to.clone(), notification.clone()));
740                Ok(cratefield_core::PushOutcome::Delivered { id: Some(id) })
741            }
742            PushMode::NotConfigured => Ok(cratefield_core::PushOutcome::NotConfigured),
743            PushMode::Unregistered => Err(cratefield_core::PushError::Unregistered),
744            PushMode::Transient => Err(cratefield_core::PushError::transient("fake push failure")),
745            PushMode::Error(error) => Err(error),
746        }
747    }
748}
749
750// ---------------------------------------------------------------------------
751// FakeTextModel
752
753/// How a [`FakeTextModel`] answers, mirroring [`PushMode`] for the text
754/// model port (issue #429).
755///
756/// [`Error`](Self::Error) carries the exact error a test wants, and
757/// [`Rejected`](Self::Rejected)/[`Transient`](Self::Transient) are the two
758/// fixed modes the port's own contract names — a caller's retry and
759/// back-off arms stay reachable without inventing provider responses.
760#[derive(Debug, Clone, PartialEq)]
761pub enum TextModelMode {
762    /// Complete with this text, under a deterministic `fake-<tier>` model
763    /// name and plausible token counts.
764    Reply(String),
765    /// Answer exactly this completion, usage and parsed JSON included.
766    Complete(cratefield_core::Completion),
767    /// Report the tier is unwired (`TextModelError::NotConfigured`).
768    NotConfigured,
769    /// A non-retryable refusal carrying the provider text.
770    Rejected(String),
771    /// A retryable failure with the provider's back-off, where it said one.
772    Transient { retry_after: Option<Duration> },
773    /// Exactly this error, text and all.
774    Error(cratefield_core::TextModelError),
775}
776
777/// An in-memory [`TextModel`] for module tests: records every [`Prompt`]
778/// it completed and answers according to its [`TextModelMode`].
779///
780/// The mode is global by default and can be overridden **per tier**
781/// ([`FakeTextModel::set_mode_for`]), mirroring [`FakePush`]'s
782/// per-recipient override — the natural analogue, and the thing the port
783/// exists for: one test can wire the fast tier to a drafted reply and the
784/// strong tier to a refusal, and watch a module treat the two differently
785/// without either vendor being named.
786///
787/// A completion that answered `NotConfigured` or failed is **not**
788/// recorded, the same rule [`FakePush`] applies to a send — the recording
789/// means "the model answered", and a caller that retried on
790/// `Transient { .. }` then sees one recorded prompt per attempt it got an
791/// answer for.
792#[derive(Clone)]
793pub struct FakeTextModel {
794    inner: Arc<FakeTextModelInner>,
795}
796
797struct FakeTextModelInner {
798    mode: Mutex<TextModelMode>,
799    per_tier: Mutex<HashMap<ModelTier, TextModelMode>>,
800    prompts: Mutex<Vec<Prompt>>,
801}
802
803/// Plausible, deterministic token counts for a fake answer: roughly four
804/// characters per token on both sides. Stable for a given prompt and text,
805/// which is what an assertion needs — no test wants to guess a provider's
806/// tokenizer.
807fn fake_usage(prompt: &Prompt, text: &str) -> (u64, u64) {
808    let prompt_chars = prompt.system.as_deref().map_or(0, str::len)
809        + prompt
810            .messages
811            .iter()
812            .map(|turn| turn.content.len())
813            .sum::<usize>();
814    let input = (prompt_chars / 4).max(1) as u64;
815    let output = (text.len() / 4).max(1) as u64;
816    (input, output)
817}
818
819impl FakeTextModel {
820    #[must_use]
821    pub fn new(mode: TextModelMode) -> Self {
822        Self {
823            inner: Arc::new(FakeTextModelInner {
824                mode: Mutex::new(mode),
825                per_tier: Mutex::new(HashMap::new()),
826                prompts: Mutex::new(Vec::new()),
827            }),
828        }
829    }
830
831    /// Every prompt answered so far, in order. A completion that answered
832    /// `NotConfigured` or failed is not recorded.
833    #[must_use]
834    pub fn prompts(&self) -> Vec<Prompt> {
835        self.inner.prompts.lock().expect("text model lock").clone()
836    }
837
838    /// The most recent prompt answered.
839    #[must_use]
840    pub fn last(&self) -> Option<Prompt> {
841        self.inner
842            .prompts
843            .lock()
844            .expect("text model lock")
845            .last()
846            .cloned()
847    }
848
849    /// Switches the mode every tier without an override answers with
850    /// (e.g. flip to `NotConfigured` mid-test).
851    pub fn set_mode(&self, mode: TextModelMode) {
852        *self.inner.mode.lock().expect("text model lock") = mode;
853    }
854
855    /// Makes one tier answer with `mode`, whatever the global mode is —
856    /// the fast tier drafting and the strong tier refusing, say.
857    pub fn set_mode_for(&self, tier: ModelTier, mode: TextModelMode) {
858        self.inner
859            .per_tier
860            .lock()
861            .expect("text model lock")
862            .insert(tier, mode);
863    }
864
865    /// Drops one tier's override, putting it back on the global mode.
866    pub fn clear_mode_for(&self, tier: ModelTier) {
867        self.inner
868            .per_tier
869            .lock()
870            .expect("text model lock")
871            .remove(&tier);
872    }
873
874    /// The mode `tier` will answer with.
875    #[must_use]
876    pub fn mode_for(&self, tier: ModelTier) -> TextModelMode {
877        self.inner
878            .per_tier
879            .lock()
880            .expect("text model lock")
881            .get(&tier)
882            .cloned()
883            .unwrap_or_else(|| self.inner.mode.lock().expect("text model lock").clone())
884    }
885
886    fn record(&self, prompt: &Prompt) {
887        self.inner
888            .prompts
889            .lock()
890            .expect("text model lock")
891            .push(prompt.clone());
892    }
893}
894
895impl Default for FakeTextModel {
896    fn default() -> Self {
897        Self::new(TextModelMode::Reply("fake completion".to_owned()))
898    }
899}
900
901#[async_trait]
902impl TextModel for FakeTextModel {
903    async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
904        match self.mode_for(prompt.tier) {
905            TextModelMode::Reply(text) => {
906                self.record(prompt);
907                let (input_tokens, output_tokens) = fake_usage(prompt, &text);
908                Ok(
909                    Completion::new(text, format!("fake-{}", prompt.tier.name()))
910                        .usage(input_tokens, output_tokens),
911                )
912            }
913            TextModelMode::Complete(completion) => {
914                self.record(prompt);
915                Ok(completion)
916            }
917            TextModelMode::NotConfigured => Err(TextModelError::NotConfigured),
918            TextModelMode::Rejected(message) => Err(TextModelError::Rejected(message)),
919            TextModelMode::Transient { retry_after } => {
920                Err(TextModelError::Transient { retry_after })
921            }
922            TextModelMode::Error(error) => Err(error),
923        }
924    }
925}
926
927// ---------------------------------------------------------------------------
928// FakeClassifier
929
930/// How a [`FakeClassifier`] answers, mirroring [`TextModelMode`] for the
931/// classifier port (issue #456).
932///
933/// The fixed modes [`NotConfigured`](Self::NotConfigured),
934/// [`Rejected`](Self::Rejected) and [`Transient`](Self::Transient) are the
935/// three the port's own contract names, and [`Error`](Self::Error) carries
936/// any `ClassifierError` a test wants — between them every arm of a
937/// caller's error mapping is reachable, `Transport` included.
938#[derive(Debug, Clone, PartialEq)]
939pub enum ClassifierMode {
940    /// Answer exactly these, keyed by question id. A question the set asks
941    /// that the map does not name falls back to the deterministic answer,
942    /// so one test can script a subset and let the rest stay plausible.
943    Answers(BTreeMap<String, Answer>),
944    /// Answer every question deterministically from its own labels (the
945    /// default): the first label, with a plausible normalised distribution
946    /// over all of them. A module under conformance gets a usable answer
947    /// without scripting anything.
948    Deterministic,
949    /// Report the port is unwired (`ClassifierError::NotConfigured`).
950    NotConfigured,
951    /// A non-retryable refusal carrying the provider text.
952    Rejected(String),
953    /// A retryable failure with the provider's back-off, where it said one.
954    Transient { retry_after: Option<Duration> },
955    /// Exactly this error, text and all.
956    Error(ClassifierError),
957}
958
959/// One `Classifier::ask` a [`FakeClassifier`] answered, for assertions:
960/// the state as it arrived (untruncated — this fake has no provider
961/// budget to spend) and the question ids in the set, in map order.
962#[derive(Debug, Clone, PartialEq, Eq)]
963pub struct Ask {
964    /// The state the questions were asked about.
965    pub state: String,
966    /// The ids of the questions in the set, in `BTreeMap` order.
967    pub question_ids: Vec<String>,
968}
969
970/// An in-memory [`Classifier`] for module tests: records every ask it
971/// answered and answers according to its [`ClassifierMode`].
972///
973/// The mode is global by default and can be overridden **per question**
974/// ([`FakeClassifier::set_answer_for`]), mirroring [`FakeTextModel`]'s
975/// per-tier override: one test can script one question's probabilities
976/// while the rest stay deterministic.
977///
978/// An ask that failed (`NotConfigured`, `Rejected`, `Transient` or
979/// `Error`) is **not** recorded, the same rule [`FakeTextModel`] applies
980/// to a completion — the recording means "the classifier answered", so a
981/// caller that retried on `Transient { .. }` sees one recorded ask per
982/// attempt that got answers.
983#[derive(Clone)]
984pub struct FakeClassifier {
985    inner: Arc<FakeClassifierInner>,
986}
987
988struct FakeClassifierInner {
989    mode: Mutex<ClassifierMode>,
990    per_question: Mutex<BTreeMap<String, Answer>>,
991    asks: Mutex<Vec<Ask>>,
992    profile: Mutex<ClassifierProfile>,
993}
994
995/// The deterministic answer for `question`: its first label
996/// ([`Question::labels`] order — first criterion, first level, or `true`
997/// for a `Noul`) carries most of the mass, spread plausibly over all the
998/// labels. Pure: the same question always answers the same way, which is
999/// what an assertion needs.
1000fn deterministic_answer(question: &Question) -> Answer {
1001    let labels = question.labels();
1002    let chosen = labels.first().copied().unwrap_or_default();
1003    // Most of the mass on the chosen label, the rest split evenly: for a
1004    // two-label question 0.6/0.4, for five 0.6 then four shares of 0.1.
1005    let share = if labels.len() > 1 {
1006        0.4 / f32::from(u16::try_from(labels.len() - 1).unwrap_or(1))
1007    } else {
1008        0.0
1009    };
1010    let probabilities: BTreeMap<String, f32> = labels
1011        .iter()
1012        .map(|label| {
1013            let mass = if *label == chosen { 0.6 } else { share };
1014            ((*label).to_owned(), mass)
1015        })
1016        .collect();
1017    match question {
1018        Question::Choice { .. } => Answer::choice(chosen, probabilities),
1019        Question::Noul { .. } => Answer::noul(chosen == "true", probabilities),
1020        // A scale's labels are usually named for their scores ("1".."5"),
1021        // so the first level parses; when it does not, the score is 0.0
1022        // and the confidence still comes from the label itself.
1023        Question::Score { .. } => Answer::new(
1024            AnswerValue::Score(chosen.parse().unwrap_or_default()),
1025            probabilities,
1026            0.6,
1027        ),
1028    }
1029}
1030
1031impl FakeClassifier {
1032    #[must_use]
1033    pub fn new(mode: ClassifierMode) -> Self {
1034        Self {
1035            inner: Arc::new(FakeClassifierInner {
1036                mode: Mutex::new(mode),
1037                per_question: Mutex::new(BTreeMap::new()),
1038                asks: Mutex::new(Vec::new()),
1039                profile: Mutex::new(ClassifierProfile::new(
1040                    Calibration::Classifier,
1041                    cratefield_core::DEFAULT_MAX_STATE_CHARS,
1042                )),
1043            }),
1044        }
1045    }
1046
1047    /// Every ask answered so far, in order. An ask that failed is not
1048    /// recorded.
1049    #[must_use]
1050    pub fn asks(&self) -> Vec<Ask> {
1051        self.inner.asks.lock().expect("classifier lock").clone()
1052    }
1053
1054    /// The most recent ask answered.
1055    #[must_use]
1056    pub fn last(&self) -> Option<Ask> {
1057        self.inner
1058            .asks
1059            .lock()
1060            .expect("classifier lock")
1061            .last()
1062            .cloned()
1063    }
1064
1065    /// Switches the mode every question without an override answers with
1066    /// (e.g. flip to `NotConfigured` mid-test).
1067    pub fn set_mode(&self, mode: ClassifierMode) {
1068        *self.inner.mode.lock().expect("classifier lock") = mode;
1069    }
1070
1071    /// Makes one question answer with `answer`, whatever the mode is —
1072    /// scripting one question's probabilities while the rest stay
1073    /// deterministic, say.
1074    pub fn set_answer_for(&self, question_id: impl Into<String>, answer: Answer) {
1075        self.inner
1076            .per_question
1077            .lock()
1078            .expect("classifier lock")
1079            .insert(question_id.into(), answer);
1080    }
1081
1082    /// Drops one question's override, putting it back on the mode.
1083    pub fn clear_answer_for(&self, question_id: &str) {
1084        self.inner
1085            .per_question
1086            .lock()
1087            .expect("classifier lock")
1088            .remove(question_id);
1089    }
1090
1091    /// Reports `calibration` and `max_state_chars` from
1092    /// [`Classifier::profile`], so a test can exercise a module's
1093    /// calibration- or truncation-aware behaviour against either family.
1094    pub fn set_profile(&self, calibration: Calibration, max_state_chars: usize) {
1095        *self.inner.profile.lock().expect("classifier lock") =
1096            ClassifierProfile::new(calibration, max_state_chars);
1097    }
1098}
1099
1100impl Default for FakeClassifier {
1101    fn default() -> Self {
1102        Self::new(ClassifierMode::Deterministic)
1103    }
1104}
1105
1106#[async_trait]
1107impl Classifier for FakeClassifier {
1108    fn profile(&self) -> ClassifierProfile {
1109        *self.inner.profile.lock().expect("classifier lock")
1110    }
1111
1112    async fn ask(
1113        &self,
1114        state: &str,
1115        questions: &BTreeMap<String, Question>,
1116    ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
1117        // A malformed set lands on `Rejected`, never a panic — the same
1118        // way a real adapter refuses it.
1119        validate_questions(questions)?;
1120
1121        let mode = self.inner.mode.lock().expect("classifier lock").clone();
1122        match mode {
1123            ClassifierMode::NotConfigured => return Err(ClassifierError::NotConfigured),
1124            ClassifierMode::Rejected(message) => return Err(ClassifierError::Rejected(message)),
1125            ClassifierMode::Transient { retry_after } => {
1126                return Err(ClassifierError::Transient { retry_after });
1127            }
1128            ClassifierMode::Error(error) => return Err(error),
1129            ClassifierMode::Answers(_) | ClassifierMode::Deterministic => {}
1130        }
1131
1132        let overrides = self.inner.per_question.lock().expect("classifier lock");
1133        let answers: BTreeMap<String, Answer> = questions
1134            .iter()
1135            .map(|(id, question)| {
1136                let answer = overrides.get(id).cloned().unwrap_or_else(|| match &mode {
1137                    ClassifierMode::Answers(map) => map
1138                        .get(id)
1139                        .cloned()
1140                        .unwrap_or_else(|| deterministic_answer(question)),
1141                    _ => deterministic_answer(question),
1142                });
1143                (id.clone(), answer)
1144            })
1145            .collect();
1146        drop(overrides);
1147
1148        self.inner.asks.lock().expect("classifier lock").push(Ask {
1149            state: state.to_owned(),
1150            question_ids: questions.keys().cloned().collect(),
1151        });
1152        Ok(answers)
1153    }
1154}
1155
1156// ---------------------------------------------------------------------------
1157// FakePayments
1158
1159/// How a [`FakePayments`] responds.
1160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1161pub enum PaymentsMode {
1162    /// Succeed and record the call.
1163    Ok,
1164    /// Report `NotConfigured` (no Stripe key).
1165    NotConfigured,
1166    /// A retryable failure.
1167    Transient,
1168}
1169
1170/// What a [`FakePayments`] recorded, for assertions.
1171#[derive(Debug, Clone, PartialEq, Eq)]
1172pub enum PaymentsCall {
1173    Checkout,
1174    SubscriptionCheckout,
1175    ConnectAccountLink,
1176    ChargeWithTransfer,
1177    Refund,
1178    VerifyWebhook,
1179}
1180
1181/// An in-memory [`cratefield_core::Payments`] for module tests: records which
1182/// calls were made and answers per its [`PaymentsMode`]. `verify_webhook`
1183/// treats a signature header of `"invalid"` as a tampered event.
1184#[derive(Clone)]
1185pub struct FakePayments {
1186    inner: Arc<FakePaymentsInner>,
1187}
1188
1189struct FakePaymentsInner {
1190    mode: Mutex<PaymentsMode>,
1191    calls: Mutex<Vec<PaymentsCall>>,
1192}
1193
1194impl FakePayments {
1195    #[must_use]
1196    pub fn new(mode: PaymentsMode) -> Self {
1197        Self {
1198            inner: Arc::new(FakePaymentsInner {
1199                mode: Mutex::new(mode),
1200                calls: Mutex::new(Vec::new()),
1201            }),
1202        }
1203    }
1204
1205    /// The calls recorded so far.
1206    #[must_use]
1207    pub fn calls(&self) -> Vec<PaymentsCall> {
1208        self.inner.calls.lock().expect("payments lock").clone()
1209    }
1210
1211    pub fn set_mode(&self, mode: PaymentsMode) {
1212        *self.inner.mode.lock().expect("payments lock") = mode;
1213    }
1214
1215    fn record(&self, call: PaymentsCall) {
1216        self.inner.calls.lock().expect("payments lock").push(call);
1217    }
1218
1219    fn guard(&self) -> Result<(), cratefield_core::PaymentsError> {
1220        match *self.inner.mode.lock().expect("payments lock") {
1221            PaymentsMode::Ok => Ok(()),
1222            PaymentsMode::NotConfigured => Err(cratefield_core::PaymentsError::NotConfigured),
1223            PaymentsMode::Transient => Err(cratefield_core::PaymentsError::Transient(
1224                "fake payments failure".to_owned(),
1225            )),
1226        }
1227    }
1228}
1229
1230impl Default for FakePayments {
1231    fn default() -> Self {
1232        Self::new(PaymentsMode::Ok)
1233    }
1234}
1235
1236#[async_trait]
1237impl cratefield_core::Payments for FakePayments {
1238    async fn create_checkout(
1239        &self,
1240        _request: &cratefield_core::CheckoutRequest,
1241    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
1242        self.guard()?;
1243        self.record(PaymentsCall::Checkout);
1244        Ok(cratefield_core::CheckoutSession {
1245            id: "cs_fake".to_owned(),
1246            url: "https://checkout.stripe.test/cs_fake".to_owned(),
1247        })
1248    }
1249
1250    async fn create_subscription_checkout(
1251        &self,
1252        _request: &cratefield_core::SubscriptionCheckoutRequest,
1253    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
1254        self.guard()?;
1255        self.record(PaymentsCall::SubscriptionCheckout);
1256        Ok(cratefield_core::CheckoutSession {
1257            id: "cs_sub_fake".to_owned(),
1258            url: "https://checkout.stripe.test/cs_sub_fake".to_owned(),
1259        })
1260    }
1261
1262    async fn create_connect_account_link(
1263        &self,
1264        _request: &cratefield_core::ConnectAccountLinkRequest,
1265    ) -> Result<cratefield_core::ConnectAccountLink, cratefield_core::PaymentsError> {
1266        self.guard()?;
1267        self.record(PaymentsCall::ConnectAccountLink);
1268        Ok(cratefield_core::ConnectAccountLink {
1269            account_id: "acct_fake".to_owned(),
1270            url: "https://connect.stripe.test/acct_fake".to_owned(),
1271        })
1272    }
1273
1274    async fn charge_with_transfer(
1275        &self,
1276        _request: &cratefield_core::TransferCharge,
1277    ) -> Result<cratefield_core::Charge, cratefield_core::PaymentsError> {
1278        self.guard()?;
1279        self.record(PaymentsCall::ChargeWithTransfer);
1280        Ok(cratefield_core::Charge {
1281            id: "pi_fake".to_owned(),
1282            status: "succeeded".to_owned(),
1283        })
1284    }
1285
1286    async fn refund(
1287        &self,
1288        _request: &cratefield_core::RefundRequest,
1289    ) -> Result<cratefield_core::Refund, cratefield_core::PaymentsError> {
1290        self.guard()?;
1291        self.record(PaymentsCall::Refund);
1292        Ok(cratefield_core::Refund {
1293            id: "re_fake".to_owned(),
1294        })
1295    }
1296
1297    async fn verify_webhook(
1298        &self,
1299        signature_header: &str,
1300        _body: &[u8],
1301    ) -> Result<cratefield_core::WebhookEvent, cratefield_core::PaymentsError> {
1302        self.record(PaymentsCall::VerifyWebhook);
1303        if signature_header == "invalid" {
1304            return Err(cratefield_core::PaymentsError::SignatureInvalid(
1305                "fake tampered signature".to_owned(),
1306            ));
1307        }
1308        self.guard()?;
1309        Ok(cratefield_core::WebhookEvent {
1310            id: "evt_fake".to_owned(),
1311            kind: "checkout.session.completed".to_owned(),
1312            data: serde_json::json!({ "object": "checkout.session" }),
1313        })
1314    }
1315}
1316
1317// ---------------------------------------------------------------------------
1318// FakeTracker
1319
1320/// A short, non-reversible stand-in for a [`Credential`] in a recorded
1321/// call, after `push.rs`'s `fingerprint` for `Recipient`: enough to tell
1322/// two credentials apart in an assertion, useless for recovering the
1323/// token.
1324///
1325/// `push.rs` hashes with `sha2`, which is not a dependency of the kit; a
1326/// test fake needs no cross-version stability and no collision resistance
1327/// beyond "different credentials assert differently", so std's
1328/// [`DefaultHasher`](std::hash::DefaultHasher) does the same job. The
1329/// plaintext is never stored — that is the point of the port: a fake that
1330/// recorded the secret would make every "the token went nowhere" assertion
1331/// unfalsifiable.
1332fn fingerprint(credential: &Credential) -> String {
1333    use std::hash::{Hash, Hasher};
1334
1335    // The one `expose` in the kit: the fingerprint is computed and the
1336    // borrow dropped before anything is recorded.
1337    let mut hasher = std::hash::DefaultHasher::new();
1338    credential.expose().hash(&mut hasher);
1339    format!("fp:{:016x}", hasher.finish())
1340}
1341
1342/// How a [`FakeTracker`] answers, mirroring [`MailerMode`] and [`PushMode`]
1343/// for the tracker port (issue #431).
1344///
1345/// [`Error`](Self::Error) carries the exact `TrackerError` a test wants,
1346/// text and delay and all (issue #236's rule): the fixed modes only ever
1347/// produce clean strings, so the arms of a caller's outcome mapping — a
1348/// `Rejected` carrying the tracker's own words, a `Transient` carrying a
1349/// provider-stated delay — would otherwise be unreachable from a test.
1350#[derive(Debug, Clone, PartialEq, Eq)]
1351pub enum TrackerMode {
1352    /// Accept and record the ticket.
1353    FileOk,
1354    /// Report that no adapter is configured for this destination.
1355    NotConfigured,
1356    /// Report that the per-tenant credential was refused (`401`/`403`).
1357    Unauthorized,
1358    /// Report that the tracker refused the ticket — a `4xx` about the
1359    /// draft, not about the credential.
1360    Rejected,
1361    /// A retryable failure with no delay stated.
1362    Transient,
1363    /// Exactly this error, text and all.
1364    Error(TrackerError),
1365}
1366
1367/// One `Tracker::file` a [`FakeTracker`] accepted, for assertions.
1368///
1369/// The credential appears only as its non-reversible
1370/// `fingerprint`: proof that a credential reached the port, and never
1371/// the secret itself.
1372#[derive(Debug, Clone, PartialEq, Eq)]
1373pub struct FiledCall {
1374    /// Where the ticket was filed.
1375    pub dest: Destination,
1376    /// The ticket as the caller handed it over.
1377    pub draft: TicketDraft,
1378    /// A fingerprint of the credential the caller passed.
1379    pub credential_fingerprint: String,
1380}
1381
1382/// One `Tracker::status` a [`FakeTracker`] accepted, for assertions.
1383#[derive(Debug, Clone, PartialEq, Eq)]
1384pub struct StatusedCall {
1385    /// Where the ticket lives.
1386    pub dest: Destination,
1387    /// The external id the caller asked about.
1388    pub external_id: String,
1389    /// A fingerprint of the credential the caller passed.
1390    pub credential_fingerprint: String,
1391}
1392
1393/// An in-memory [`Tracker`] for module tests: records every accepted
1394/// `file`/`status` call and answers according to its [`TrackerMode`]. A
1395/// call the mode answered with an error is not recorded, as [`FakePush`]
1396/// does not record a failed send.
1397///
1398/// The mode is global by default and can be overridden **per destination**
1399/// ([`FakeTracker::set_mode_for`]), the [`FakePush`] shape: one tenant's
1400/// expired token among live ones. The state an accepted `status` reports
1401/// is scripted separately ([`FakeTracker::set_state`]), because "the file
1402/// succeeded" and "the ticket has moved on since" are two different
1403/// answers a test wants to set independently.
1404#[derive(Clone, Debug)]
1405pub struct FakeTracker {
1406    inner: Arc<FakeTrackerInner>,
1407}
1408
1409#[derive(Debug)]
1410struct FakeTrackerInner {
1411    mode: Mutex<TrackerMode>,
1412    per_destination: Mutex<HashMap<Destination, TrackerMode>>,
1413    filed: Mutex<Vec<FiledCall>>,
1414    statused: Mutex<Vec<StatusedCall>>,
1415    state: Mutex<TicketState>,
1416}
1417
1418impl FakeTracker {
1419    #[must_use]
1420    pub fn new(mode: TrackerMode) -> Self {
1421        Self {
1422            inner: Arc::new(FakeTrackerInner {
1423                mode: Mutex::new(mode),
1424                per_destination: Mutex::new(HashMap::new()),
1425                filed: Mutex::new(Vec::new()),
1426                statused: Mutex::new(Vec::new()),
1427                state: Mutex::new(TicketState::Open),
1428            }),
1429        }
1430    }
1431
1432    /// Every `file` accepted so far, in order.
1433    #[must_use]
1434    pub fn filed(&self) -> Vec<FiledCall> {
1435        self.inner.filed.lock().expect("tracker lock").clone()
1436    }
1437
1438    /// The most recent `file` accepted.
1439    #[must_use]
1440    pub fn last_filed(&self) -> Option<FiledCall> {
1441        self.inner
1442            .filed
1443            .lock()
1444            .expect("tracker lock")
1445            .last()
1446            .cloned()
1447    }
1448
1449    /// Every `status` accepted so far, in order.
1450    #[must_use]
1451    pub fn statused(&self) -> Vec<StatusedCall> {
1452        self.inner.statused.lock().expect("tracker lock").clone()
1453    }
1454
1455    /// Switches the mode every destination without an override answers
1456    /// with (e.g. flip to `Unauthorized` mid-test).
1457    pub fn set_mode(&self, mode: TrackerMode) {
1458        *self.inner.mode.lock().expect("tracker lock") = mode;
1459    }
1460
1461    /// Makes one destination answer with `mode`, whatever the global mode
1462    /// is — one tenant's expired token among live ones, say.
1463    pub fn set_mode_for(&self, dest: &Destination, mode: TrackerMode) {
1464        self.inner
1465            .per_destination
1466            .lock()
1467            .expect("tracker lock")
1468            .insert(dest.clone(), mode);
1469    }
1470
1471    /// Drops one destination's override, putting it back on the global
1472    /// mode.
1473    pub fn clear_mode_for(&self, dest: &Destination) {
1474        self.inner
1475            .per_destination
1476            .lock()
1477            .expect("tracker lock")
1478            .remove(dest);
1479    }
1480
1481    /// The mode `dest` will answer with.
1482    #[must_use]
1483    pub fn mode_for(&self, dest: &Destination) -> TrackerMode {
1484        self.inner
1485            .per_destination
1486            .lock()
1487            .expect("tracker lock")
1488            .get(dest)
1489            .cloned()
1490            .unwrap_or_else(|| self.inner.mode.lock().expect("tracker lock").clone())
1491    }
1492
1493    /// The state an accepted `status` reports.
1494    pub fn set_state(&self, state: TicketState) {
1495        *self.inner.state.lock().expect("tracker lock") = state;
1496    }
1497
1498    /// The state an accepted `status` currently reports.
1499    #[must_use]
1500    pub fn state(&self) -> TicketState {
1501        *self.inner.state.lock().expect("tracker lock")
1502    }
1503
1504    /// The error arms shared by `file` and `status`: the same fixed modes
1505    /// script both, the way a real adapter answers the one `TrackerError`
1506    /// vocabulary on both methods.
1507    fn error_for(mode: &TrackerMode) -> Option<TrackerError> {
1508        match mode {
1509            TrackerMode::FileOk => None,
1510            TrackerMode::NotConfigured => Some(TrackerError::NotConfigured),
1511            TrackerMode::Unauthorized => Some(TrackerError::Unauthorized),
1512            TrackerMode::Rejected => {
1513                Some(TrackerError::Rejected("fake tracker rejection".to_owned()))
1514            }
1515            TrackerMode::Transient => Some(TrackerError::Transient { retry_after: None }),
1516            TrackerMode::Error(error) => Some(error.clone()),
1517        }
1518    }
1519}
1520
1521impl Default for FakeTracker {
1522    fn default() -> Self {
1523        Self::new(TrackerMode::FileOk)
1524    }
1525}
1526
1527#[async_trait]
1528impl Tracker for FakeTracker {
1529    async fn file(
1530        &self,
1531        dest: &Destination,
1532        cred: &Credential,
1533        draft: &TicketDraft,
1534    ) -> Result<Filed, TrackerError> {
1535        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1536            return Err(error);
1537        }
1538        let id = format!(
1539            "fake-{}",
1540            self.inner.filed.lock().expect("tracker lock").len()
1541        );
1542        self.inner
1543            .filed
1544            .lock()
1545            .expect("tracker lock")
1546            .push(FiledCall {
1547                dest: dest.clone(),
1548                draft: draft.clone(),
1549                credential_fingerprint: fingerprint(cred),
1550            });
1551        Ok(Filed {
1552            url: format!("https://tracker.fake.test/browse/{id}"),
1553            external_id: id,
1554        })
1555    }
1556
1557    async fn status(
1558        &self,
1559        dest: &Destination,
1560        cred: &Credential,
1561        external_id: &str,
1562    ) -> Result<TicketStatus, TrackerError> {
1563        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
1564            return Err(error);
1565        }
1566        self.inner
1567            .statused
1568            .lock()
1569            .expect("tracker lock")
1570            .push(StatusedCall {
1571                dest: dest.clone(),
1572                external_id: external_id.to_owned(),
1573                credential_fingerprint: fingerprint(cred),
1574            });
1575        Ok(TicketStatus {
1576            external_id: external_id.to_owned(),
1577            state: self.state(),
1578            url: None,
1579        })
1580    }
1581}
1582
1583// ---------------------------------------------------------------------------
1584// FakeRealtime
1585
1586/// Room id -> the member ids a [`FakeRealtime`] reports for it.
1587type RealtimeMembers = std::collections::HashMap<String, Vec<String>>;
1588/// The `(room_id, message)` broadcasts a [`FakeRealtime`] recorded.
1589type RealtimeBroadcasts = Vec<(String, Vec<u8>)>;
1590
1591/// An in-memory [`cratefield_core::Realtime`] for module tests: records every
1592/// broadcast per room and reports a fixed member list. It exercises the port a
1593/// module holds (broadcast/members from outside a socket); the socket lifecycle
1594/// and `RoomHandler` are the runtime adapter's job, covered by the native
1595/// adapter's own tests.
1596#[derive(Clone, Default)]
1597pub struct FakeRealtime {
1598    broadcasts: Arc<Mutex<RealtimeBroadcasts>>,
1599    members: Arc<Mutex<RealtimeMembers>>,
1600}
1601
1602impl FakeRealtime {
1603    #[must_use]
1604    pub fn new() -> Self {
1605        Self::default()
1606    }
1607
1608    /// Presets the members a room reports (for `members` assertions).
1609    pub fn set_members(&self, room_id: &str, ids: &[&str]) {
1610        self.members.lock().expect("realtime lock").insert(
1611            room_id.to_owned(),
1612            ids.iter().map(|id| (*id).to_owned()).collect(),
1613        );
1614    }
1615
1616    /// Every `(room_id, message)` broadcast so far.
1617    #[must_use]
1618    pub fn broadcasts(&self) -> Vec<(String, Vec<u8>)> {
1619        self.broadcasts.lock().expect("realtime lock").clone()
1620    }
1621}
1622
1623#[async_trait]
1624impl cratefield_core::Realtime for FakeRealtime {
1625    async fn broadcast(
1626        &self,
1627        room_id: &str,
1628        message: &[u8],
1629    ) -> Result<(), cratefield_core::RealtimeError> {
1630        self.broadcasts
1631            .lock()
1632            .expect("realtime lock")
1633            .push((room_id.to_owned(), message.to_vec()));
1634        Ok(())
1635    }
1636
1637    async fn members(
1638        &self,
1639        room_id: &str,
1640    ) -> Result<Vec<cratefield_core::Member>, cratefield_core::RealtimeError> {
1641        Ok(self
1642            .members
1643            .lock()
1644            .expect("realtime lock")
1645            .get(room_id)
1646            .map(|ids| ids.iter().map(cratefield_core::Member::new).collect())
1647            .unwrap_or_default())
1648    }
1649}
1650
1651/// What a [`FakeAuth`] answers.
1652///
1653/// Every outcome the port has, because a fake that cannot produce one
1654/// makes the arm handling it unreachable from every test — which is how
1655/// a five-arm mapping shipped with four of the arms never executed.
1656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1657pub enum AuthMode {
1658    /// Every request is anonymous, header or not. The deployment where
1659    /// nobody signs in.
1660    Anonymous,
1661    /// A bearer token is taken at face value as the subject's id, and a
1662    /// request with no `Authorization` header is anonymous. The mode for
1663    /// a test that wants both branches without minting a JWT.
1664    TokenIsTheSubject,
1665    /// Every presented credential is refused, and a request with no
1666    /// header is still anonymous — the distinction the port exists to
1667    /// keep.
1668    NotVerified,
1669    /// The verifier cannot answer, credential or not.
1670    Unavailable,
1671}
1672
1673/// A [`cratefield_core::Auth`] answering from a mode rather than from
1674/// a key set.
1675pub struct FakeAuth {
1676    mode: AuthMode,
1677    calls: std::sync::Mutex<Vec<String>>,
1678}
1679
1680impl FakeAuth {
1681    /// A fake in `mode`.
1682    #[must_use]
1683    pub fn new(mode: AuthMode) -> Self {
1684        Self {
1685            mode,
1686            calls: std::sync::Mutex::new(Vec::new()),
1687        }
1688    }
1689
1690    /// A fake that reads the bearer token as the subject's id.
1691    #[must_use]
1692    pub fn subjects() -> Self {
1693        Self::new(AuthMode::TokenIsTheSubject)
1694    }
1695
1696    /// The bearer values it was asked about, in order. `""` records a
1697    /// request that carried no `Authorization` header at all.
1698    #[must_use]
1699    pub fn calls(&self) -> Vec<String> {
1700        self.calls.lock().expect("uncontended").clone()
1701    }
1702}
1703
1704#[async_trait::async_trait]
1705impl cratefield_core::Auth for FakeAuth {
1706    async fn identify(
1707        &self,
1708        headers: &http::HeaderMap,
1709    ) -> Result<cratefield_core::Caller, cratefield_core::AuthError> {
1710        let presented = headers
1711            .get(http::header::AUTHORIZATION)
1712            .and_then(|value| value.to_str().ok())
1713            .and_then(|value| value.strip_prefix("Bearer "))
1714            .map(str::to_owned);
1715        self.calls
1716            .lock()
1717            .expect("uncontended")
1718            .push(presented.clone().unwrap_or_default());
1719        match self.mode {
1720            AuthMode::Anonymous => Ok(cratefield_core::Caller::Anonymous),
1721            AuthMode::Unavailable => Err(cratefield_core::AuthError::Unavailable(
1722                "the fake is in Unavailable mode".to_owned(),
1723            )),
1724            AuthMode::NotVerified | AuthMode::TokenIsTheSubject => match presented {
1725                None => Ok(cratefield_core::Caller::Anonymous),
1726                Some(_) if self.mode == AuthMode::NotVerified => {
1727                    Err(cratefield_core::AuthError::NotVerified)
1728                }
1729                Some(token) => Ok(cratefield_core::Caller::Subject(
1730                    cratefield_core::Subject::new(token).session("fake-session"),
1731                )),
1732            },
1733        }
1734    }
1735}