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::sigv4::{self, Credentials, SigV4Error, SignableRequest};
17use cratefield_core::{
18    ActorError, ActorHandlers, ActorStore, ActorWrites, Actors, Answer, AnswerValue, Calibration,
19    Capability, Captcha, CaptchaError, CertificateStatus, Classifier, ClassifierError,
20    ClassifierProfile, Clock, Completion, Credential, CustomHostname, CustomHostnameError,
21    CustomHostnames, Database, DbError, Decision, Defer, Destination, DnsRecordType, Filed,
22    HostnameClaim, HttpClient, HttpError, KeyValue, KvError, MailError, Mailer, Message, ModelTier,
23    Prompt, ProviderStatus, Question, RateLimitError, RateLimiter, Row, Rows, SendOutcome,
24    Statement, TextModel, TextModelError, TicketComment, TicketDraft, TicketState, TicketStatus,
25    Tracker, TrackerError, UrlSafety, UrlSafetyError, UrlSafetyLimits, UrlSafetySource, UrlVerdict,
26    Validation, Verdict, check_hostname, check_url, run_actor_alarm, run_actor_message,
27    validate_questions,
28};
29use futures_core::Stream;
30use futures_core::future::BoxFuture;
31use http::header::{CONTENT_TYPE, RETRY_AFTER};
32use http::{HeaderValue, Request, Response, StatusCode};
33use std::collections::{BTreeMap, HashMap, VecDeque};
34use std::pin::Pin;
35use std::sync::Arc;
36use std::sync::atomic::{AtomicUsize, Ordering};
37use std::task::{Context, Poll};
38use std::time::Duration;
39
40// Recording fixtures, not request state (see module docs).
41#[allow(clippy::disallowed_types)]
42use std::sync::Mutex;
43
44// ---------------------------------------------------------------------------
45// FakeMailer
46
47/// How a [`FakeMailer`] answers.
48///
49/// [`Error`](Self::Error) is what makes the failure arms testable at all
50/// (issue #236): before it the fake could only ever produce
51/// `MailError::Upstream("fake mailer failure")`, so `Invalid { detail }`
52/// and `DomainNotVerified { domain }` — the two variants that carry the
53/// provider's own text, and therefore the two that can carry a recipient
54/// address — could not be driven from a test. Every arm of a caller's
55/// outcome mapping is now reachable, with the text the caller chooses:
56///
57/// ```
58/// # use cratefield_testing::{FakeMailer, MailerMode};
59/// # use cratefield_core::MailError;
60/// let mailer = FakeMailer::new(MailerMode::Error(MailError::Invalid {
61///     detail: "to: alice@example.test is suppressed".to_owned(),
62/// }));
63/// ```
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum MailerMode {
66    /// Accept and record the message.
67    SendOk,
68    /// Report that the adapter has no API key or no verified domain.
69    NotConfigured,
70    /// A generic upstream failure, for a caller that does not care which.
71    Fail,
72    /// Exactly this error, text and all.
73    Error(MailError),
74}
75
76#[derive(Clone)]
77pub struct FakeMailer {
78    inner: Arc<FakeMailerInner>,
79}
80
81struct FakeMailerInner {
82    mode: Mutex<MailerMode>,
83    sent: Mutex<Vec<Message>>,
84}
85
86impl FakeMailer {
87    #[must_use]
88    pub fn new(mode: MailerMode) -> Self {
89        Self {
90            inner: Arc::new(FakeMailerInner {
91                mode: Mutex::new(mode),
92                sent: Mutex::new(Vec::new()),
93            }),
94        }
95    }
96
97    /// Every message recorded so far.
98    #[must_use]
99    pub fn sent(&self) -> Vec<Message> {
100        self.inner.sent.lock().expect("mailer lock").clone()
101    }
102
103    /// The most recent message.
104    #[must_use]
105    pub fn last_message(&self) -> Option<Message> {
106        self.inner.sent.lock().expect("mailer lock").last().cloned()
107    }
108
109    /// Switches the mode (e.g. degrade to `NotConfigured` mid-test).
110    pub fn set_mode(&self, mode: MailerMode) {
111        *self.inner.mode.lock().expect("mailer lock") = mode;
112    }
113}
114
115#[async_trait]
116impl Mailer for FakeMailer {
117    async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
118        let mode = self.inner.mode.lock().expect("mailer lock").clone();
119        match mode {
120            MailerMode::SendOk => {
121                let id = format!(
122                    "fake-{}",
123                    self.inner.sent.lock().expect("mailer lock").len()
124                );
125                self.inner.sent.lock().expect("mailer lock").push(message);
126                Ok(SendOutcome::Sent { id })
127            }
128            MailerMode::NotConfigured => Ok(SendOutcome::NotConfigured),
129            MailerMode::Fail => Err(MailError::Upstream("fake mailer failure".to_string())),
130            MailerMode::Error(error) => Err(error),
131        }
132    }
133}
134
135// ---------------------------------------------------------------------------
136// FakeCaptcha
137
138#[derive(Clone)]
139pub struct FakeCaptcha {
140    allow_all: bool,
141    allowed_tokens: Arc<Vec<String>>,
142}
143
144impl FakeCaptcha {
145    /// Every token verifies.
146    #[must_use]
147    pub fn allow_all() -> Self {
148        Self {
149            allow_all: true,
150            allowed_tokens: Arc::new(Vec::new()),
151        }
152    }
153
154    /// Only the listed tokens verify.
155    #[must_use]
156    pub fn with_tokens(tokens: impl IntoIterator<Item = impl Into<String>>) -> Self {
157        Self {
158            allow_all: false,
159            allowed_tokens: Arc::new(tokens.into_iter().map(Into::into).collect()),
160        }
161    }
162}
163
164#[async_trait]
165impl Captcha for FakeCaptcha {
166    async fn verify(&self, token: &str, _remote_ip: Option<&str>) -> Result<Verdict, CaptchaError> {
167        let ok = self.allow_all || self.allowed_tokens.iter().any(|t| t == token);
168        Ok(Verdict {
169            ok,
170            reason: (!ok).then(|| "token not allowed".to_string()),
171        })
172    }
173
174    fn binding(&self) -> Option<cratefield_core::CaptchaBinding> {
175        Some(cratefield_core::CaptchaBinding {
176            hostname_bound: true,
177            action_bound: true,
178            fail_open: false,
179        })
180    }
181}
182
183// ---------------------------------------------------------------------------
184// FakeRateLimiter (scripted)
185
186#[derive(Clone)]
187pub struct FakeRateLimiter {
188    inner: Arc<FakeRateLimiterInner>,
189}
190
191struct FakeRateLimiterInner {
192    scripted: Mutex<VecDeque<Decision>>,
193    default: Decision,
194    calls: AtomicUsize,
195}
196
197impl FakeRateLimiter {
198    /// Falls through to `default` once the script is exhausted.
199    #[must_use]
200    pub fn scripted(decisions: Vec<Decision>, default: Decision) -> Self {
201        Self {
202            inner: Arc::new(FakeRateLimiterInner {
203                scripted: Mutex::new(decisions.into_iter().collect()),
204                default,
205                calls: AtomicUsize::new(0),
206            }),
207        }
208    }
209
210    /// Always allows.
211    #[must_use]
212    pub fn always_allow() -> Self {
213        Self::scripted(
214            Vec::new(),
215            Decision {
216                ok: true,
217                retry_after: None,
218                quota: None,
219            },
220        )
221    }
222
223    #[must_use]
224    pub fn calls(&self) -> usize {
225        self.inner.calls.load(Ordering::SeqCst)
226    }
227}
228
229#[async_trait]
230impl RateLimiter for FakeRateLimiter {
231    async fn limit(&self, _key: &str) -> Result<Decision, RateLimitError> {
232        self.inner.calls.fetch_add(1, Ordering::SeqCst);
233        let scripted = self
234            .inner
235            .scripted
236            .lock()
237            .expect("limiter lock")
238            .pop_front();
239        Ok(scripted.unwrap_or_else(|| self.inner.default.clone()))
240    }
241}
242
243// ---------------------------------------------------------------------------
244// FixedClock
245
246#[derive(Debug, Clone)]
247pub struct FixedClock(pub time::OffsetDateTime);
248
249#[async_trait]
250impl Clock for FixedClock {
251    fn now(&self) -> time::OffsetDateTime {
252        self.0
253    }
254}
255
256// ---------------------------------------------------------------------------
257// ManualClock
258
259/// A [`Clock`] a test moves by hand (issue #583), for a fake whose behaviour
260/// depends on time — [`MemoryActors`] fires an alarm once the clock reaches
261/// it. Every clone shares the one instant, so the host and the test that
262/// advances it never diverge.
263#[derive(Debug, Clone)]
264pub struct ManualClock {
265    at: Arc<Mutex<time::OffsetDateTime>>,
266}
267
268impl ManualClock {
269    /// A clock stopped at `at`.
270    #[must_use]
271    pub fn new(at: time::OffsetDateTime) -> Self {
272        Self {
273            at: Arc::new(Mutex::new(at)),
274        }
275    }
276
277    /// Moves the clock forward by `by`. Panics if `by` is too large for
278    /// `time`'s `Duration`.
279    pub fn advance(&self, by: Duration) {
280        let span = time::Duration::try_from(by).expect("a representable span");
281        *self.at.lock().expect("clock lock") += span;
282    }
283}
284
285#[async_trait]
286impl Clock for ManualClock {
287    fn now(&self) -> time::OffsetDateTime {
288        *self.at.lock().expect("clock lock")
289    }
290}
291
292// ---------------------------------------------------------------------------
293// MemoryKeyValue
294
295#[derive(Clone, Default)]
296pub struct MemoryKeyValue {
297    inner: Arc<MemoryKeyValueInner>,
298}
299
300#[derive(Default)]
301struct MemoryKeyValueInner {
302    entries: Mutex<HashMap<String, String>>,
303}
304
305impl MemoryKeyValue {
306    #[must_use]
307    pub fn new() -> Self {
308        Self::default()
309    }
310}
311
312#[async_trait]
313impl KeyValue for MemoryKeyValue {
314    async fn get(&self, key: &str) -> Result<Option<String>, KvError> {
315        Ok(self
316            .inner
317            .entries
318            .lock()
319            .expect("kv lock")
320            .get(key)
321            .cloned())
322    }
323
324    async fn put(&self, key: &str, value: &str, _ttl: Option<Duration>) -> Result<(), KvError> {
325        self.inner
326            .entries
327            .lock()
328            .expect("kv lock")
329            .insert(key.to_string(), value.to_string());
330        Ok(())
331    }
332
333    async fn delete(&self, key: &str) -> Result<(), KvError> {
334        self.inner.entries.lock().expect("kv lock").remove(key);
335        Ok(())
336    }
337}
338
339// ---------------------------------------------------------------------------
340// FakeHttpClient (scripted responses, captures requests)
341
342#[derive(Clone)]
343pub struct FakeHttpClient {
344    inner: Arc<FakeHttpInner>,
345}
346
347struct FakeHttpInner {
348    responses: Mutex<VecDeque<Result<Response<Bytes>, HttpError>>>,
349    captured: Mutex<Vec<(String, String, String)>>, // method, uri, body
350}
351
352impl FakeHttpClient {
353    /// Responds with `responses` in order, then always with a 500.
354    #[must_use]
355    pub fn scripted(responses: Vec<Result<Response<Bytes>, HttpError>>) -> Self {
356        Self {
357            inner: Arc::new(FakeHttpInner {
358                responses: Mutex::new(responses.into_iter().collect()),
359                captured: Mutex::new(Vec::new()),
360            }),
361        }
362    }
363
364    #[must_use]
365    pub fn ok_json(body: &'static str) -> Self {
366        Self::scripted(vec![
367            Response::builder()
368                .status(200)
369                .body(Bytes::from(body))
370                .map_err(|err| HttpError::Transport(err.to_string())),
371        ])
372    }
373
374    /// One Owlpost-shaped `application/problem+json` error (issue #666).
375    #[must_use]
376    pub fn owlpost_problem(status: StatusCode, title: &str, detail: &str) -> Self {
377        Self::scripted(vec![Ok(problem_json(status, title, detail))])
378    }
379
380    /// Owlpost's 429: a problem body carrying `Retry-After: <secs>`, which
381    /// the adapter maps to `MailError::RateLimited` with that delay.
382    #[must_use]
383    pub fn owlpost_rate_limited(retry_after_secs: u64) -> Self {
384        Self::scripted(vec![Ok(with_retry_after(
385            problem_json(
386                StatusCode::TOO_MANY_REQUESTS,
387                "Too Many Requests",
388                "slow down",
389            ),
390            retry_after_secs,
391        ))])
392    }
393
394    /// One Colonizer-shaped error: `{"error": message}`.
395    #[must_use]
396    pub fn colonizer_error(status: StatusCode, message: &str) -> Self {
397        Self::scripted(vec![Ok(error_json(status, message))])
398    }
399
400    /// Colonizer's 429: an `{"error": …}` body carrying `Retry-After`.
401    #[must_use]
402    pub fn colonizer_rate_limited(retry_after_secs: u64) -> Self {
403        Self::scripted(vec![Ok(with_retry_after(
404            error_json(StatusCode::TOO_MANY_REQUESTS, "rate limited"),
405            retry_after_secs,
406        ))])
407    }
408
409    /// Every captured request as `(method, uri, body)`.
410    #[must_use]
411    pub fn captured(&self) -> Vec<(String, String, String)> {
412        self.inner.captured.lock().expect("http lock").clone()
413    }
414}
415
416/// An RFC 9457 `application/problem+json` response — the shape Owlpost
417/// answers errors with: `type`, `title`, `status` and `detail`, the
418/// `status` both the HTTP status and the body's own field. Build one for
419/// a [`scripted`](FakeHttpClient::scripted) sequence, or take it from a
420/// preset like [`FakeHttpClient::owlpost_problem`].
421#[must_use]
422pub fn problem_json(status: StatusCode, title: &str, detail: &str) -> Response<Bytes> {
423    let body = serde_json::json!({
424        "type": "about:blank",
425        "title": title,
426        "status": status.as_u16(),
427        "detail": detail,
428    })
429    .to_string();
430    Response::builder()
431        .status(status)
432        .header(CONTENT_TYPE, "application/problem+json")
433        .body(Bytes::from(body))
434        .expect("a problem+json response builds")
435}
436
437/// A Colonizer-shaped error body, `{"error": message}` under
438/// `application/json`.
439#[must_use]
440pub fn error_json(status: StatusCode, message: &str) -> Response<Bytes> {
441    let body = serde_json::json!({ "error": message }).to_string();
442    Response::builder()
443        .status(status)
444        .header(CONTENT_TYPE, "application/json")
445        .body(Bytes::from(body))
446        .expect("an error response builds")
447}
448
449/// Adds `Retry-After: <retry_after_secs>` to a response in seconds — the
450/// header a 429 carries and the adapters read as a retry hint.
451#[must_use]
452pub fn with_retry_after(mut response: Response<Bytes>, retry_after_secs: u64) -> Response<Bytes> {
453    response
454        .headers_mut()
455        .insert(RETRY_AFTER, HeaderValue::from(retry_after_secs));
456    response
457}
458
459#[async_trait]
460impl HttpClient for FakeHttpClient {
461    async fn send(&self, request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
462        let (parts, body) = request.into_parts();
463        self.inner.captured.lock().expect("http lock").push((
464            parts.method.to_string(),
465            parts.uri.to_string(),
466            String::from_utf8_lossy(&body).to_string(),
467        ));
468        let next = self.inner.responses.lock().expect("http lock").pop_front();
469        next.unwrap_or_else(|| Err(HttpError::Transport("fake http exhausted".to_string())))
470    }
471}
472
473// ---------------------------------------------------------------------------
474// FakeDefer (collects futures; drain runs them)
475
476#[derive(Clone, Default)]
477pub struct FakeDefer {
478    inner: Arc<FakeDeferInner>,
479}
480
481#[derive(Default)]
482struct FakeDeferInner {
483    pending: Mutex<Vec<BoxFuture<'static, ()>>>,
484    deferred: AtomicUsize,
485}
486
487impl FakeDefer {
488    #[must_use]
489    pub fn new() -> Self {
490        Self::default()
491    }
492
493    /// Runs every deferred future to completion, in deferral order.
494    // Async for API symmetry with `drain().await` call sites (the futures
495    // run on a sync block-on inside).
496    #[allow(clippy::unused_async, clippy::unused_async_trait_impl)]
497    pub async fn drain(&self) {
498        while !self.inner.pending.lock().expect("defer lock").is_empty() {
499            let next = self.inner.pending.lock().expect("defer lock").remove(0);
500            pollster::block_on(next);
501        }
502    }
503
504    #[must_use]
505    pub fn deferred_count(&self) -> usize {
506        self.inner.deferred.load(Ordering::SeqCst)
507    }
508}
509
510impl Defer for FakeDefer {
511    fn wait_until(&self, fut: BoxFuture<'static, ()>) {
512        self.inner.deferred.fetch_add(1, Ordering::SeqCst);
513        self.inner.pending.lock().expect("defer lock").push(fut);
514    }
515}
516
517// ---------------------------------------------------------------------------
518// Transparent Database passthrough (re-exported for tests that need a
519// trivial Database without SQLite): an always-empty database.
520
521#[derive(Debug, Clone, Copy, Default)]
522pub struct EmptyDatabase;
523
524#[async_trait]
525impl Database for EmptyDatabase {
526    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
527        Err(DbError::Execute(format!("empty database: {}", stmt.sql)))
528    }
529
530    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
531        if stmt.sql.trim() == "SELECT 1" {
532            Ok(Rows::new(vec![Row::new(vec![(
533                "1".to_string(),
534                sea_query::Value::Int(Some(1)),
535            )])]))
536        } else {
537            Err(DbError::Query(format!("empty database: {}", stmt.sql)))
538        }
539    }
540
541    async fn batch_atomic(&self, _stmts: &[Statement]) -> Result<(), DbError> {
542        Err(DbError::Batch("empty database".to_string()))
543    }
544}
545
546/// An in-process [`Dispatcher`](cratefield_core::Dispatcher) that answers from an
547/// axum [`Router`](axum::Router), so a
548/// module can be exercised through a sidecar mount without a network or a
549/// second Worker (ADR 0009). The conformance kit uses it to run the same
550/// assertions against both mounts (#64).
551///
552/// Also the failure fixture: [`unbound`](FakeDispatcher::unbound) has no
553/// binding at all, and [`failing`](FakeDispatcher::failing) accepts the
554/// binding and then refuses to answer.
555#[derive(Clone)]
556pub struct FakeDispatcher {
557    binding: String,
558    behaviour: FakeDispatch,
559    calls: Arc<AtomicUsize>,
560}
561
562#[derive(Clone)]
563enum FakeDispatch {
564    Serve(Arc<Mutex<axum::Router>>),
565    Unbound,
566    Failing(String),
567}
568
569impl FakeDispatcher {
570    /// Serves `router` on `binding`.
571    #[must_use]
572    pub fn serving(binding: impl Into<String>, router: axum::Router) -> Self {
573        Self {
574            binding: binding.into(),
575            behaviour: FakeDispatch::Serve(Arc::new(Mutex::new(router))),
576            calls: Arc::new(AtomicUsize::new(0)),
577        }
578    }
579
580    /// Has no bindings, so `has()` is always false: the "mounted but this
581    /// deployment has no such binding" case.
582    #[must_use]
583    pub fn unbound() -> Self {
584        Self {
585            binding: String::new(),
586            behaviour: FakeDispatch::Unbound,
587            calls: Arc::new(AtomicUsize::new(0)),
588        }
589    }
590
591    /// Accepts `binding` and then fails to answer.
592    #[must_use]
593    pub fn failing(binding: impl Into<String>, reason: impl Into<String>) -> Self {
594        Self {
595            binding: binding.into(),
596            behaviour: FakeDispatch::Failing(reason.into()),
597            calls: Arc::new(AtomicUsize::new(0)),
598        }
599    }
600
601    /// How many dispatches were attempted. A forwarder must not retry, so a
602    /// single request must leave this at one.
603    #[must_use]
604    pub fn calls(&self) -> usize {
605        self.calls.load(Ordering::SeqCst)
606    }
607}
608
609#[async_trait]
610impl cratefield_core::Dispatcher for FakeDispatcher {
611    fn has(&self, binding: &str) -> bool {
612        !matches!(self.behaviour, FakeDispatch::Unbound) && binding == self.binding
613    }
614
615    async fn dispatch(
616        &self,
617        binding: &str,
618        request: Request<Bytes>,
619    ) -> Result<Response<Bytes>, cratefield_core::DispatchError> {
620        self.calls.fetch_add(1, Ordering::SeqCst);
621        match &self.behaviour {
622            FakeDispatch::Unbound => {
623                Err(cratefield_core::DispatchError::NotBound(binding.to_owned()))
624            }
625            FakeDispatch::Failing(reason) => Err(cratefield_core::DispatchError::Unavailable {
626                binding: binding.to_owned(),
627                reason: reason.clone(),
628            }),
629            FakeDispatch::Serve(router) => {
630                let router = router.lock().unwrap().clone();
631                let (parts, body) = request.into_parts();
632                let request = Request::from_parts(parts, axum::body::Body::from(body));
633                let response =
634                    tower::ServiceExt::oneshot(router, request)
635                        .await
636                        .map_err(|err| cratefield_core::DispatchError::Unavailable {
637                            binding: binding.to_owned(),
638                            reason: err.to_string(),
639                        })?;
640                let (parts, body) = response.into_parts();
641                let bytes = axum::body::to_bytes(body, usize::MAX)
642                    .await
643                    .map_err(|err| cratefield_core::DispatchError::Unavailable {
644                        binding: binding.to_owned(),
645                        reason: err.to_string(),
646                    })?;
647                Ok(Response::from_parts(parts, bytes))
648            }
649        }
650    }
651}
652
653/// An in-memory [`cratefield_core::Blob`] store for module tests.
654/// [`MemoryBlob::new`] has no presigned URLs (`signed_url` reports
655/// `Unsupported`, as a directory store does); [`MemoryBlob::with_presign`] signs
656/// real `SigV4` URLs that [`MemoryBlob::verify_presigned`] checks back.
657#[derive(Clone, Default)]
658pub struct MemoryBlob {
659    objects: Arc<std::sync::Mutex<std::collections::HashMap<String, cratefield_core::BlobObject>>>,
660    /// Multipart uploads in flight, by upload id (issue #586).
661    uploads: Arc<std::sync::Mutex<std::collections::HashMap<String, MemoryUpload>>>,
662    /// Hands out upload ids. Shared across clones so two handles never mint
663    /// the same id.
664    next_upload_id: Arc<AtomicUsize>,
665    /// The clock presigning stamps from; `Some` once `with_presign` is called.
666    presign_clock: Option<Arc<dyn Clock>>,
667}
668
669/// One multipart upload in flight in [`MemoryBlob`] (issue #586): its
670/// target key, content type, and the parts uploaded so far, by part
671/// number, each with the `ETag` the store handed back.
672struct MemoryUpload {
673    key: String,
674    content_type: String,
675    parts: BTreeMap<u16, (String, Vec<u8>)>,
676}
677
678/// Consumes a body stream, refusing once it passes `max_bytes`: the chunk
679/// that would cross the bound is not delivered, and the caller sees
680/// [`cratefield_core::BlobError::TooLarge`] (issue #586). Because nothing
681/// is stored until the whole body is in hand, an over-limit write leaves
682/// no object behind.
683async fn collect_capped(
684    mut body: cratefield_core::BoxStream<'static, Result<Bytes, cratefield_core::StreamError>>,
685    max_bytes: u64,
686) -> Result<(u64, Vec<u8>), cratefield_core::BlobError> {
687    let mut written: u64 = 0;
688    let mut out = Vec::new();
689    while let Some(chunk) = std::future::poll_fn(|cx| body.as_mut().poll_next(cx)).await {
690        let chunk = chunk.map_err(cratefield_core::blob_error_from_stream)?;
691        written = written.saturating_add(chunk.len() as u64);
692        if written > max_bytes {
693            return Err(cratefield_core::BlobError::TooLarge(format!(
694                "streamed body of {written} bytes passed the {max_bytes}-byte bound"
695            )));
696        }
697        out.extend_from_slice(&chunk);
698    }
699    Ok((written, out))
700}
701
702/// The read side of [`MemoryBlob`]: `bytes` split into a few fixed-size
703/// chunks, so a streamed get is genuinely a stream (issue #586).
704fn stored_body_stream(
705    bytes: &[u8],
706) -> cratefield_core::BoxStream<'static, Result<Bytes, cratefield_core::StreamError>> {
707    const CHUNK: usize = 1 << 20;
708    let chunks: Vec<Result<Bytes, cratefield_core::StreamError>> = bytes
709        .chunks(CHUNK)
710        .map(|chunk| Ok(Bytes::copy_from_slice(chunk)))
711        .collect();
712    Box::pin(ChunkStream {
713        chunks: chunks.into_iter(),
714    })
715}
716
717/// A `Stream` over pre-built chunks. Written out because the kit has no
718/// `futures-util`.
719struct ChunkStream {
720    chunks: std::vec::IntoIter<Result<Bytes, cratefield_core::StreamError>>,
721}
722
723impl Stream for ChunkStream {
724    type Item = Result<Bytes, cratefield_core::StreamError>;
725
726    fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
727        Poll::Ready(self.get_mut().chunks.next())
728    }
729}
730
731/// The fixed, obviously fake presigning identity, stable so a test can assert
732/// on the URL text. `blob.test` is reserved for documentation (RFC 2606).
733const PRESIGN_ACCESS_KEY_ID: &str = "AKIATESTACCESSKEY000000";
734const PRESIGN_SECRET_ACCESS_KEY: &str = "memory-blob-presign-test-secret";
735const PRESIGN_HOST: &str = "blob.test";
736
737impl MemoryBlob {
738    #[must_use]
739    pub fn new() -> Self {
740        Self::default()
741    }
742
743    /// Opts the store into presigning: `signed_url` and `signed_put_url` sign
744    /// real `SigV4` URLs stamped from `clock`, checked back by
745    /// [`verify_presigned`](Self::verify_presigned).
746    #[must_use]
747    pub fn with_presign(clock: Arc<dyn Clock>) -> Self {
748        Self {
749            presign_clock: Some(clock),
750            ..Self::default()
751        }
752    }
753
754    /// How many objects are stored, for assertions.
755    #[must_use]
756    pub fn len(&self) -> usize {
757        self.objects.lock().unwrap().len()
758    }
759
760    /// Whether the store is empty, for assertions.
761    #[must_use]
762    pub fn is_empty(&self) -> bool {
763        self.len() == 0
764    }
765
766    /// Signs `method` on `key` (used verbatim as the URL path — keep test keys
767    /// URL-safe) with `headers`, or `Unsupported` without `with_presign`.
768    fn presign(
769        &self,
770        method: &str,
771        key: &str,
772        headers: &[(String, String)],
773        ttl: Duration,
774    ) -> Result<String, cratefield_core::BlobError> {
775        let clock = self
776            .presign_clock
777            .as_ref()
778            .ok_or_else(|| cratefield_core::BlobError::Unsupported("no presigner".to_owned()))?;
779        let path = format!("/{key}");
780        let request = SignableRequest {
781            method,
782            host: PRESIGN_HOST,
783            path: &path,
784            query: &[],
785            headers,
786        };
787        Ok(sigv4::presign(
788            &Credentials::new(PRESIGN_ACCESS_KEY_ID, PRESIGN_SECRET_ACCESS_KEY),
789            "auto",
790            "s3",
791            &request,
792            ttl.as_secs(),
793            clock.now(),
794        ))
795    }
796
797    /// Verifies a URL this store minted, at its clock's current time, and
798    /// returns the key it names.
799    ///
800    /// # Errors
801    ///
802    /// As [`sigv4::verify_presigned`](cratefield_core::sigv4::verify_presigned),
803    /// or [`SigV4Error::Malformed`] without a clock or a URL path.
804    pub fn verify_presigned(
805        &self,
806        url: &str,
807        method: &str,
808        headers: &[(String, String)],
809    ) -> Result<String, SigV4Error> {
810        let clock = self
811            .presign_clock
812            .as_ref()
813            .ok_or_else(|| SigV4Error::Malformed("no presigning clock".to_owned()))?;
814        let credentials = Credentials::new(PRESIGN_ACCESS_KEY_ID, PRESIGN_SECRET_ACCESS_KEY);
815        sigv4::verify_presigned(
816            url,
817            method,
818            headers,
819            &credentials,
820            "auto",
821            "s3",
822            clock.now(),
823        )?;
824        url.split_once("://")
825            .and_then(|(_, rest)| rest.split_once('/'))
826            .map(|(_, path)| path.split(['?', '#']).next().unwrap_or(path).to_owned())
827            .ok_or_else(|| SigV4Error::Malformed("not an absolute URL".to_owned()))
828    }
829}
830
831#[async_trait]
832impl cratefield_core::Blob for MemoryBlob {
833    async fn put(
834        &self,
835        key: &str,
836        bytes: &[u8],
837        content_type: &str,
838    ) -> Result<(), cratefield_core::BlobError> {
839        cratefield_core::check_blob_size(bytes)?;
840        self.objects.lock().unwrap().insert(
841            key.to_owned(),
842            cratefield_core::BlobObject {
843                bytes: bytes.to_vec(),
844                content_type: content_type.to_owned(),
845            },
846        );
847        Ok(())
848    }
849    async fn get(
850        &self,
851        key: &str,
852    ) -> Result<Option<cratefield_core::BlobObject>, cratefield_core::BlobError> {
853        Ok(self.objects.lock().unwrap().get(key).cloned())
854    }
855    async fn delete(&self, key: &str) -> Result<(), cratefield_core::BlobError> {
856        self.objects.lock().unwrap().remove(key);
857        Ok(())
858    }
859    async fn signed_url(
860        &self,
861        key: &str,
862        ttl: std::time::Duration,
863    ) -> Result<String, cratefield_core::BlobError> {
864        self.presign("GET", key, &[], ttl)
865    }
866    async fn signed_put_url(
867        &self,
868        key: &str,
869        content_type: &str,
870        content_length: Option<u64>,
871        ttl: std::time::Duration,
872    ) -> Result<cratefield_core::PresignedPut, cratefield_core::BlobError> {
873        let mut headers = vec![("content-type".to_owned(), content_type.to_owned())];
874        if let Some(length) = content_length {
875            headers.push(("content-length".to_owned(), length.to_string()));
876        }
877        Ok(cratefield_core::PresignedPut {
878            url: self.presign("PUT", key, &headers, ttl)?,
879            method: "PUT",
880            headers,
881        })
882    }
883    async fn put_stream(
884        &self,
885        key: &str,
886        body: cratefield_core::BoxStream<'static, Result<Bytes, cratefield_core::StreamError>>,
887        content_type: &str,
888        max_bytes: u64,
889    ) -> Result<u64, cratefield_core::BlobError> {
890        let (written, bytes) = collect_capped(body, max_bytes).await?;
891        self.objects.lock().unwrap().insert(
892            key.to_owned(),
893            cratefield_core::BlobObject {
894                bytes,
895                content_type: content_type.to_owned(),
896            },
897        );
898        Ok(written)
899    }
900    async fn get_stream(
901        &self,
902        key: &str,
903    ) -> Result<Option<cratefield_core::BlobStream>, cratefield_core::BlobError> {
904        let Some(object) = self.objects.lock().unwrap().get(key).cloned() else {
905            return Ok(None);
906        };
907        let size = object.bytes.len() as u64;
908        Ok(Some(cratefield_core::BlobStream {
909            content_type: object.content_type,
910            size,
911            body: stored_body_stream(&object.bytes),
912        }))
913    }
914    async fn head(
915        &self,
916        key: &str,
917    ) -> Result<Option<cratefield_core::BlobMeta>, cratefield_core::BlobError> {
918        Ok(self
919            .objects
920            .lock()
921            .unwrap()
922            .get(key)
923            .map(|object| cratefield_core::BlobMeta {
924                key: key.to_owned(),
925                size: object.bytes.len() as u64,
926                content_type: object.content_type.clone(),
927            }))
928    }
929    async fn list(
930        &self,
931        prefix: &str,
932        cursor: Option<&str>,
933        limit: usize,
934    ) -> Result<cratefield_core::BlobPage, cratefield_core::BlobError> {
935        let objects = self.objects.lock().unwrap();
936        let mut keys: Vec<String> = objects
937            .keys()
938            .filter(|key| key.starts_with(prefix))
939            .cloned()
940            .collect();
941        keys.sort();
942        let start = cursor.map_or(0, |cursor| {
943            keys.partition_point(|key| key.as_str() <= cursor)
944        });
945        let take = (keys.len() - start).min(limit);
946        let selected = &keys[start..start + take];
947        let page = selected
948            .iter()
949            .map(|key| {
950                let object = &objects[key];
951                cratefield_core::BlobMeta {
952                    key: key.clone(),
953                    size: object.bytes.len() as u64,
954                    content_type: object.content_type.clone(),
955                }
956            })
957            .collect();
958        let cursor = if start + take < keys.len() {
959            selected.last().cloned()
960        } else {
961            None
962        };
963        Ok(cratefield_core::BlobPage {
964            objects: page,
965            cursor,
966        })
967    }
968    async fn create_multipart(
969        &self,
970        key: &str,
971        content_type: &str,
972    ) -> Result<cratefield_core::UploadId, cratefield_core::BlobError> {
973        let id = self.next_upload_id.fetch_add(1, Ordering::Relaxed) + 1;
974        let upload_id = cratefield_core::UploadId::new(format!("upload-{id}"));
975        self.uploads.lock().unwrap().insert(
976            upload_id.as_str().to_owned(),
977            MemoryUpload {
978                key: key.to_owned(),
979                content_type: content_type.to_owned(),
980                parts: BTreeMap::new(),
981            },
982        );
983        Ok(upload_id)
984    }
985    async fn upload_part(
986        &self,
987        key: &str,
988        upload_id: &cratefield_core::UploadId,
989        part_number: u16,
990        body: cratefield_core::BoxStream<'static, Result<Bytes, cratefield_core::StreamError>>,
991        max_part_bytes: u64,
992    ) -> Result<cratefield_core::PartReceipt, cratefield_core::BlobError> {
993        cratefield_core::check_part_number(part_number)?;
994        let (_, bytes) = collect_capped(body, max_part_bytes).await?;
995        let mut uploads = self.uploads.lock().unwrap();
996        let upload = uploads.get_mut(upload_id.as_str()).ok_or_else(|| {
997            cratefield_core::BlobError::Operation(format!(
998                "no multipart upload `{}`",
999                upload_id.as_str()
1000            ))
1001        })?;
1002        if upload.key != key {
1003            return Err(cratefield_core::BlobError::Operation(
1004                "multipart upload belongs to a different key".to_owned(),
1005            ));
1006        }
1007        let etag = format!("part-{part_number}-{}", bytes.len());
1008        upload.parts.insert(part_number, (etag.clone(), bytes));
1009        Ok(cratefield_core::PartReceipt { part_number, etag })
1010    }
1011    async fn complete_multipart(
1012        &self,
1013        key: &str,
1014        upload_id: &cratefield_core::UploadId,
1015        parts: &[cratefield_core::PartReceipt],
1016    ) -> Result<(), cratefield_core::BlobError> {
1017        let mut uploads = self.uploads.lock().unwrap();
1018        // Validate against the stored upload **without** removing it, so a
1019        // refused complete leaves the upload intact for a retry, as a real
1020        // store does.
1021        let assembled = {
1022            let upload = uploads.get(upload_id.as_str()).ok_or_else(|| {
1023                cratefield_core::BlobError::Operation(format!(
1024                    "no multipart upload `{}`",
1025                    upload_id.as_str()
1026                ))
1027            })?;
1028            if upload.key != key {
1029                return Err(cratefield_core::BlobError::Operation(
1030                    "multipart upload belongs to a different key".to_owned(),
1031                ));
1032            }
1033            let mut ordered = parts.to_vec();
1034            ordered.sort_by_key(|receipt| receipt.part_number);
1035            let mut assembled = Vec::new();
1036            for (index, receipt) in ordered.iter().enumerate() {
1037                let (etag, bytes) = upload.parts.get(&receipt.part_number).ok_or_else(|| {
1038                    cratefield_core::BlobError::Operation(format!(
1039                        "part {} was never uploaded",
1040                        receipt.part_number
1041                    ))
1042                })?;
1043                if *etag != receipt.etag {
1044                    return Err(cratefield_core::BlobError::Operation(format!(
1045                        "part {} etag mismatch",
1046                        receipt.part_number
1047                    )));
1048                }
1049                let is_last = index + 1 == ordered.len();
1050                if !is_last && (bytes.len() as u64) < cratefield_core::MIN_MULTIPART_PART_BYTES {
1051                    return Err(cratefield_core::BlobError::Operation(format!(
1052                        "part {} is below the {}-byte minimum for a non-final part",
1053                        receipt.part_number,
1054                        cratefield_core::MIN_MULTIPART_PART_BYTES,
1055                    )));
1056                }
1057                assembled.extend_from_slice(bytes);
1058            }
1059            assembled
1060        };
1061        let upload = uploads
1062            .remove(upload_id.as_str())
1063            .expect("the upload was checked above");
1064        let content_type = upload.content_type;
1065        drop(uploads);
1066        self.objects.lock().unwrap().insert(
1067            key.to_owned(),
1068            cratefield_core::BlobObject {
1069                bytes: assembled,
1070                content_type,
1071            },
1072        );
1073        Ok(())
1074    }
1075    async fn abort_multipart(
1076        &self,
1077        key: &str,
1078        upload_id: &cratefield_core::UploadId,
1079    ) -> Result<(), cratefield_core::BlobError> {
1080        let mut uploads = self.uploads.lock().unwrap();
1081        let upload = uploads.remove(upload_id.as_str()).ok_or_else(|| {
1082            cratefield_core::BlobError::Operation(format!(
1083                "no multipart upload `{}`",
1084                upload_id.as_str()
1085            ))
1086        })?;
1087        if upload.key != key {
1088            uploads.insert(upload_id.as_str().to_owned(), upload);
1089            return Err(cratefield_core::BlobError::Operation(
1090                "multipart upload belongs to a different key".to_owned(),
1091            ));
1092        }
1093        Ok(())
1094    }
1095    async fn list_multipart_uploads(
1096        &self,
1097        prefix: &str,
1098    ) -> Result<Vec<cratefield_core::PendingUpload>, cratefield_core::BlobError> {
1099        let uploads = self.uploads.lock().unwrap();
1100        let mut pending: Vec<cratefield_core::PendingUpload> = uploads
1101            .iter()
1102            .filter(|(_, upload)| upload.key.starts_with(prefix))
1103            .map(|(id, upload)| cratefield_core::PendingUpload {
1104                key: upload.key.clone(),
1105                upload_id: cratefield_core::UploadId::new(id.clone()),
1106            })
1107            .collect();
1108        pending.sort_by(|a, b| a.key.cmp(&b.key));
1109        Ok(pending)
1110    }
1111}
1112
1113// ---------------------------------------------------------------------------
1114// MemoryActors
1115
1116/// How many times one actor's alarm may fire inside a single
1117/// [`MemoryActors::advance`] before the fake stops. A handler that re-arms
1118/// its alarm into the past would otherwise never let the advance return.
1119const MAX_ALARMS_PER_ADVANCE: usize = 4;
1120
1121/// An in-process [`Actors`] host for module tests (issue #583): one
1122/// serialized actor per `(kind, key)`, its values in memory, its one alarm
1123/// driven by a [`ManualClock`] the test advances.
1124///
1125/// It dispatches to the [`ActorHandlers`] a venture registered and owns the
1126/// serialization core leaves to the host: a call takes its actor's async lock
1127/// for the whole of [`run_actor_message`], so two calls to one actor run one
1128/// at a time, and an unknown kind is [`ActorError::NotConfigured`], as a host
1129/// with no handler is.
1130#[derive(Clone)]
1131pub struct MemoryActors {
1132    inner: Arc<MemoryActorsInner>,
1133}
1134
1135struct MemoryActorsInner {
1136    handlers: ActorHandlers,
1137    clock: ManualClock,
1138    /// One entry per `(kind, key)` seen so far; created on first use under
1139    /// the lock, so concurrent first calls share one actor.
1140    actors: Mutex<HashMap<(String, String), Arc<ActorState>>>,
1141}
1142
1143/// One actor instance: its store and the lock that serializes every call and
1144/// alarm run for it.
1145#[derive(Default)]
1146struct ActorState {
1147    store: MemoryActorStore,
1148    lock: futures_util::lock::Mutex<()>,
1149}
1150
1151/// The backing store for one [`MemoryActors`] actor: a sorted map of values
1152/// plus the one alarm. `commit` applies a whole [`ActorWrites`] set under a
1153/// single lock, so a handler's writes land all-or-nothing.
1154#[derive(Default)]
1155struct MemoryActorStore {
1156    state: Mutex<MemoryActorState>,
1157}
1158
1159#[derive(Default)]
1160struct MemoryActorState {
1161    values: BTreeMap<String, Vec<u8>>,
1162    alarm: Option<time::OffsetDateTime>,
1163}
1164
1165#[async_trait]
1166impl ActorStore for MemoryActorStore {
1167    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, ActorError> {
1168        Ok(self
1169            .state
1170            .lock()
1171            .expect("store lock")
1172            .values
1173            .get(key)
1174            .cloned())
1175    }
1176
1177    async fn list_prefix(&self, prefix: &str) -> Result<Vec<(String, Vec<u8>)>, ActorError> {
1178        Ok(self
1179            .state
1180            .lock()
1181            .expect("store lock")
1182            .values
1183            .iter()
1184            .filter(|(key, _)| key.starts_with(prefix))
1185            .map(|(key, value)| (key.clone(), value.clone()))
1186            .collect())
1187    }
1188
1189    async fn alarm(&self) -> Result<Option<time::OffsetDateTime>, ActorError> {
1190        Ok(self.state.lock().expect("store lock").alarm)
1191    }
1192
1193    async fn commit(&self, writes: ActorWrites) -> Result<(), ActorError> {
1194        let mut state = self.state.lock().expect("store lock");
1195        if writes.clear_all {
1196            state.values.clear();
1197        }
1198        for (key, value) in writes.entries {
1199            match value {
1200                Some(value) => {
1201                    state.values.insert(key, value);
1202                }
1203                None => {
1204                    state.values.remove(&key);
1205                }
1206            }
1207        }
1208        if let Some(alarm) = writes.alarm {
1209            state.alarm = alarm;
1210        }
1211        Ok(())
1212    }
1213}
1214
1215impl MemoryActors {
1216    /// A host that dispatches to `handlers` and reads `clock`, which its
1217    /// alarms are measured against.
1218    #[must_use]
1219    pub fn new(handlers: ActorHandlers, clock: ManualClock) -> Self {
1220        Self {
1221            inner: Arc::new(MemoryActorsInner {
1222                handlers,
1223                clock,
1224                actors: Mutex::new(HashMap::new()),
1225            }),
1226        }
1227    }
1228
1229    /// The actor for `(kind, key)`, creating it on first use.
1230    fn actor(&self, kind: &str, key: &str) -> Arc<ActorState> {
1231        self.inner
1232            .actors
1233            .lock()
1234            .expect("actors lock")
1235            .entry((kind.to_owned(), key.to_owned()))
1236            .or_default()
1237            .clone()
1238    }
1239
1240    /// Moves the clock forward by `by`, then runs every alarm due at the new
1241    /// instant under its actor's lock — the way a real host wakes a Durable
1242    /// Object when its alarm passes.
1243    ///
1244    /// An alarm a handler re-arms into the past or now fires again in the
1245    /// same call, up to `MAX_ALARMS_PER_ADVANCE`; one whose handler fails
1246    /// stays armed for the next advance.
1247    pub async fn advance(&self, by: Duration) {
1248        self.inner.clock.advance(by);
1249        let now = self.inner.clock.now();
1250        let actors: Vec<(String, String, Arc<ActorState>)> = {
1251            let registry = self.inner.actors.lock().expect("actors lock");
1252            registry
1253                .iter()
1254                .map(|((kind, key), state)| (kind.clone(), key.clone(), Arc::clone(state)))
1255                .collect()
1256        };
1257        for (kind, key, state) in actors {
1258            let Some(handler) = self.inner.handlers.get(&kind) else {
1259                continue;
1260            };
1261            for _ in 0..MAX_ALARMS_PER_ADVANCE {
1262                let _held = state.lock.lock().await;
1263                let due = matches!(state.store.alarm().await, Ok(Some(at)) if at <= now);
1264                if !due {
1265                    break;
1266                }
1267                if run_actor_alarm(
1268                    handler.as_ref(),
1269                    &state.store,
1270                    &self.inner.clock,
1271                    &kind,
1272                    &key,
1273                )
1274                .await
1275                .is_err()
1276                {
1277                    break;
1278                }
1279            }
1280        }
1281    }
1282}
1283
1284#[async_trait]
1285impl Actors for MemoryActors {
1286    async fn call(&self, kind: &str, key: &str, message: &[u8]) -> Result<Vec<u8>, ActorError> {
1287        let Some(handler) = self.inner.handlers.get(kind) else {
1288            return Err(ActorError::NotConfigured);
1289        };
1290        let state = self.actor(kind, key);
1291        let _held = state.lock.lock().await;
1292        run_actor_message(
1293            handler.as_ref(),
1294            &state.store,
1295            &self.inner.clock,
1296            kind,
1297            key,
1298            message,
1299        )
1300        .await
1301    }
1302}
1303
1304// ---------------------------------------------------------------------------
1305// FakeEmbedder
1306
1307/// The width of every [`FakeEmbedder`] vector, and so of the
1308/// [`ExactVectorIndex`](cratefield_core::ExactVectorIndex) the kit wires in
1309/// [`full_fake_ports`](crate::full_fake_ports) — the two must agree.
1310pub const FAKE_EMBEDDER_DIMENSIONS: usize = 8;
1311
1312/// The model name a [`FakeEmbedder`] reports.
1313pub const FAKE_EMBEDDER_MODEL: &str = "fake-embedder";
1314
1315/// A deterministic [`cratefield_core::Embedder`] for module tests: one
1316/// fixed-width vector per input text, derived from the text's own bytes,
1317/// and a whitespace-word-count `input_tokens`.
1318#[derive(Debug, Clone, Copy, Default)]
1319pub struct FakeEmbedder;
1320
1321impl FakeEmbedder {
1322    /// The vector `text` embeds to: its bytes accumulated round-robin into
1323    /// [`FAKE_EMBEDDER_DIMENSIONS`] slots. Pure, so a test can compute the
1324    /// expected vector itself.
1325    #[must_use]
1326    pub fn vector(text: &str) -> Vec<f32> {
1327        let mut out = vec![0.0; FAKE_EMBEDDER_DIMENSIONS];
1328        for (index, byte) in text.bytes().enumerate() {
1329            out[index % FAKE_EMBEDDER_DIMENSIONS] += f32::from(byte);
1330        }
1331        out
1332    }
1333}
1334
1335#[async_trait]
1336impl cratefield_core::Embedder for FakeEmbedder {
1337    async fn embed(
1338        &self,
1339        texts: &[String],
1340    ) -> Result<cratefield_core::Embeddings, cratefield_core::EmbedError> {
1341        let input_tokens = texts
1342            .iter()
1343            .map(|text| text.split_whitespace().count() as u64)
1344            .sum();
1345        let mut embeddings =
1346            cratefield_core::Embeddings::new(FAKE_EMBEDDER_MODEL).usage(input_tokens);
1347        for text in texts {
1348            embeddings = embeddings.vector(Self::vector(text));
1349        }
1350        Ok(embeddings)
1351    }
1352}
1353
1354// ---------------------------------------------------------------------------
1355// FakePush
1356
1357/// How a [`FakePush`] responds, mirroring [`MailerMode`] for the push port.
1358///
1359/// [`Error`](Self::Error) carries the provider text a real adapter would
1360/// have wrapped (issue #236). The fixed modes only ever produce clean
1361/// strings, so no test using them could show a device token or a push
1362/// endpoint arriving somewhere it should not.
1363#[derive(Debug, Clone, PartialEq, Eq)]
1364pub enum PushMode {
1365    /// Accept and record the notification.
1366    DeliverOk,
1367    /// Report the adapter is not configured (no key).
1368    NotConfigured,
1369    /// Report the device token is dead (APNs `410`): the caller prunes it.
1370    Unregistered,
1371    /// A retryable failure.
1372    Transient,
1373    /// Exactly this error, text and all.
1374    Error(cratefield_core::PushError),
1375}
1376
1377/// An in-memory [`cratefield_core::Push`] for module tests: records every
1378/// `(recipient, notification)` and answers according to its [`PushMode`].
1379///
1380/// The mode is global by default and can be overridden **per recipient**
1381/// ([`FakePush::set_mode_for`]), so a fan-out test can make exactly one of
1382/// five devices dead and assert that only that one is pruned — the thing a
1383/// single global mode cannot express (issue #177).
1384#[derive(Clone)]
1385pub struct FakePush {
1386    inner: Arc<FakePushInner>,
1387}
1388
1389struct FakePushInner {
1390    mode: Mutex<PushMode>,
1391    per_recipient: Mutex<HashMap<cratefield_core::Recipient, PushMode>>,
1392    sent: Mutex<Vec<(cratefield_core::Recipient, cratefield_core::Notification)>>,
1393}
1394
1395impl FakePush {
1396    #[must_use]
1397    pub fn new(mode: PushMode) -> Self {
1398        Self {
1399            inner: Arc::new(FakePushInner {
1400                mode: Mutex::new(mode),
1401                per_recipient: Mutex::new(HashMap::new()),
1402                sent: Mutex::new(Vec::new()),
1403            }),
1404        }
1405    }
1406
1407    /// Every `(recipient, notification)` delivered so far. A send that
1408    /// answered `NotConfigured` or failed is not recorded.
1409    #[must_use]
1410    pub fn sent(&self) -> Vec<(cratefield_core::Recipient, cratefield_core::Notification)> {
1411        self.inner.sent.lock().expect("push lock").clone()
1412    }
1413
1414    /// The most recent `(recipient, notification)`.
1415    #[must_use]
1416    pub fn last(&self) -> Option<(cratefield_core::Recipient, cratefield_core::Notification)> {
1417        self.inner.sent.lock().expect("push lock").last().cloned()
1418    }
1419
1420    /// Everything delivered to one recipient.
1421    #[must_use]
1422    pub fn sent_to(
1423        &self,
1424        recipient: &cratefield_core::Recipient,
1425    ) -> Vec<cratefield_core::Notification> {
1426        self.inner
1427            .sent
1428            .lock()
1429            .expect("push lock")
1430            .iter()
1431            .filter(|(to, _)| to == recipient)
1432            .map(|(_, notification)| notification.clone())
1433            .collect()
1434    }
1435
1436    /// Switches the mode every recipient without an override answers with
1437    /// (e.g. flip to `Unregistered` mid-test).
1438    pub fn set_mode(&self, mode: PushMode) {
1439        *self.inner.mode.lock().expect("push lock") = mode;
1440    }
1441
1442    /// Makes one recipient answer with `mode`, whatever the global mode is —
1443    /// one dead token among live ones, say.
1444    pub fn set_mode_for(&self, recipient: &cratefield_core::Recipient, mode: PushMode) {
1445        self.inner
1446            .per_recipient
1447            .lock()
1448            .expect("push lock")
1449            .insert(recipient.clone(), mode);
1450    }
1451
1452    /// Drops one recipient's override, putting it back on the global mode.
1453    pub fn clear_mode_for(&self, recipient: &cratefield_core::Recipient) {
1454        self.inner
1455            .per_recipient
1456            .lock()
1457            .expect("push lock")
1458            .remove(recipient);
1459    }
1460
1461    /// The mode `recipient` will answer with.
1462    #[must_use]
1463    pub fn mode_for(&self, recipient: &cratefield_core::Recipient) -> PushMode {
1464        self.inner
1465            .per_recipient
1466            .lock()
1467            .expect("push lock")
1468            .get(recipient)
1469            .cloned()
1470            .unwrap_or_else(|| self.inner.mode.lock().expect("push lock").clone())
1471    }
1472}
1473
1474impl Default for FakePush {
1475    fn default() -> Self {
1476        Self::new(PushMode::DeliverOk)
1477    }
1478}
1479
1480#[async_trait]
1481impl cratefield_core::Push for FakePush {
1482    async fn send(
1483        &self,
1484        to: &cratefield_core::Recipient,
1485        notification: &cratefield_core::Notification,
1486    ) -> Result<cratefield_core::PushOutcome, cratefield_core::PushError> {
1487        match self.mode_for(to) {
1488            PushMode::DeliverOk => {
1489                let id = format!(
1490                    "fake-push-{}",
1491                    self.inner.sent.lock().expect("push lock").len()
1492                );
1493                self.inner
1494                    .sent
1495                    .lock()
1496                    .expect("push lock")
1497                    .push((to.clone(), notification.clone()));
1498                Ok(cratefield_core::PushOutcome::Delivered { id: Some(id) })
1499            }
1500            PushMode::NotConfigured => Ok(cratefield_core::PushOutcome::NotConfigured),
1501            PushMode::Unregistered => Err(cratefield_core::PushError::Unregistered),
1502            PushMode::Transient => Err(cratefield_core::PushError::transient("fake push failure")),
1503            PushMode::Error(error) => Err(error),
1504        }
1505    }
1506}
1507
1508// ---------------------------------------------------------------------------
1509// FakeTextModel
1510
1511/// How a [`FakeTextModel`] answers, mirroring [`PushMode`] for the text
1512/// model port (issue #429).
1513///
1514/// [`Error`](Self::Error) carries the exact error a test wants, and
1515/// [`Rejected`](Self::Rejected)/[`Transient`](Self::Transient) are the two
1516/// fixed modes the port's own contract names — a caller's retry and
1517/// back-off arms stay reachable without inventing provider responses.
1518#[derive(Debug, Clone, PartialEq)]
1519pub enum TextModelMode {
1520    /// Complete with this text, under a deterministic `fake-<tier>` model
1521    /// name and plausible token counts.
1522    Reply(String),
1523    /// Answer exactly this completion, usage and parsed JSON included.
1524    Complete(cratefield_core::Completion),
1525    /// Report the tier is unwired (`TextModelError::NotConfigured`).
1526    NotConfigured,
1527    /// A non-retryable refusal carrying the provider text.
1528    Rejected(String),
1529    /// A retryable failure with the provider's back-off, where it said one.
1530    Transient { retry_after: Option<Duration> },
1531    /// Exactly this error, text and all.
1532    Error(cratefield_core::TextModelError),
1533}
1534
1535/// An in-memory [`TextModel`] for module tests: records every [`Prompt`]
1536/// it completed and answers according to its [`TextModelMode`].
1537///
1538/// The mode is global by default and can be overridden **per tier**
1539/// ([`FakeTextModel::set_mode_for`]), mirroring [`FakePush`]'s
1540/// per-recipient override — the natural analogue, and the thing the port
1541/// exists for: one test can wire the fast tier to a drafted reply and the
1542/// strong tier to a refusal, and watch a module treat the two differently
1543/// without either vendor being named.
1544///
1545/// A completion that answered `NotConfigured` or failed is **not**
1546/// recorded, the same rule [`FakePush`] applies to a send — the recording
1547/// means "the model answered", and a caller that retried on
1548/// `Transient { .. }` then sees one recorded prompt per attempt it got an
1549/// answer for.
1550#[derive(Clone)]
1551pub struct FakeTextModel {
1552    inner: Arc<FakeTextModelInner>,
1553}
1554
1555struct FakeTextModelInner {
1556    mode: Mutex<TextModelMode>,
1557    per_tier: Mutex<HashMap<ModelTier, TextModelMode>>,
1558    prompts: Mutex<Vec<Prompt>>,
1559}
1560
1561/// Plausible, deterministic token counts for a fake answer: roughly four
1562/// characters per token on both sides. Stable for a given prompt and text,
1563/// which is what an assertion needs — no test wants to guess a provider's
1564/// tokenizer.
1565fn fake_usage(prompt: &Prompt, text: &str) -> (u64, u64) {
1566    let prompt_chars = prompt.system.as_deref().map_or(0, str::len)
1567        + prompt
1568            .messages
1569            .iter()
1570            .map(|turn| turn.content.len())
1571            .sum::<usize>();
1572    let input = (prompt_chars / 4).max(1) as u64;
1573    let output = (text.len() / 4).max(1) as u64;
1574    (input, output)
1575}
1576
1577impl FakeTextModel {
1578    #[must_use]
1579    pub fn new(mode: TextModelMode) -> Self {
1580        Self {
1581            inner: Arc::new(FakeTextModelInner {
1582                mode: Mutex::new(mode),
1583                per_tier: Mutex::new(HashMap::new()),
1584                prompts: Mutex::new(Vec::new()),
1585            }),
1586        }
1587    }
1588
1589    /// Every prompt answered so far, in order. A completion that answered
1590    /// `NotConfigured` or failed is not recorded.
1591    #[must_use]
1592    pub fn prompts(&self) -> Vec<Prompt> {
1593        self.inner.prompts.lock().expect("text model lock").clone()
1594    }
1595
1596    /// The most recent prompt answered.
1597    #[must_use]
1598    pub fn last(&self) -> Option<Prompt> {
1599        self.inner
1600            .prompts
1601            .lock()
1602            .expect("text model lock")
1603            .last()
1604            .cloned()
1605    }
1606
1607    /// Switches the mode every tier without an override answers with
1608    /// (e.g. flip to `NotConfigured` mid-test).
1609    pub fn set_mode(&self, mode: TextModelMode) {
1610        *self.inner.mode.lock().expect("text model lock") = mode;
1611    }
1612
1613    /// Makes one tier answer with `mode`, whatever the global mode is —
1614    /// the fast tier drafting and the strong tier refusing, say.
1615    pub fn set_mode_for(&self, tier: ModelTier, mode: TextModelMode) {
1616        self.inner
1617            .per_tier
1618            .lock()
1619            .expect("text model lock")
1620            .insert(tier, mode);
1621    }
1622
1623    /// Drops one tier's override, putting it back on the global mode.
1624    pub fn clear_mode_for(&self, tier: ModelTier) {
1625        self.inner
1626            .per_tier
1627            .lock()
1628            .expect("text model lock")
1629            .remove(&tier);
1630    }
1631
1632    /// The mode `tier` will answer with.
1633    #[must_use]
1634    pub fn mode_for(&self, tier: ModelTier) -> TextModelMode {
1635        self.inner
1636            .per_tier
1637            .lock()
1638            .expect("text model lock")
1639            .get(&tier)
1640            .cloned()
1641            .unwrap_or_else(|| self.inner.mode.lock().expect("text model lock").clone())
1642    }
1643
1644    fn record(&self, prompt: &Prompt) {
1645        self.inner
1646            .prompts
1647            .lock()
1648            .expect("text model lock")
1649            .push(prompt.clone());
1650    }
1651}
1652
1653impl Default for FakeTextModel {
1654    fn default() -> Self {
1655        Self::new(TextModelMode::Reply("fake completion".to_owned()))
1656    }
1657}
1658
1659#[async_trait]
1660impl TextModel for FakeTextModel {
1661    async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
1662        // The fake answers plain completions; a prompt carrying tools would
1663        // otherwise be answered as if none were offered. Refuse it the way
1664        // a real adapter without the capability does, so a caller is told
1665        // rather than silently misled.
1666        if !prompt.tools.is_empty() {
1667            return Err(TextModelError::Unsupported(Capability::Tools));
1668        }
1669        // An image prompt over a bound is refused here too, before the
1670        // prompt is recorded or answered — the same rule a real adapter
1671        // applies before any network call.
1672        prompt.check_images()?;
1673        match self.mode_for(prompt.tier) {
1674            TextModelMode::Reply(text) => {
1675                self.record(prompt);
1676                let (input_tokens, output_tokens) = fake_usage(prompt, &text);
1677                Ok(
1678                    Completion::new(text, format!("fake-{}", prompt.tier.name()))
1679                        .usage(input_tokens, output_tokens),
1680                )
1681            }
1682            TextModelMode::Complete(completion) => {
1683                self.record(prompt);
1684                Ok(completion)
1685            }
1686            TextModelMode::NotConfigured => Err(TextModelError::NotConfigured),
1687            TextModelMode::Rejected(message) => Err(TextModelError::Rejected(message)),
1688            TextModelMode::Transient { retry_after } => {
1689                Err(TextModelError::Transient { retry_after })
1690            }
1691            TextModelMode::Error(error) => Err(error),
1692        }
1693    }
1694
1695    fn supports(&self, _tier: ModelTier, capability: Capability) -> bool {
1696        // The fake answers an image prompt — it refuses neither text nor
1697        // images — so it reports vision, and a router lets an image prompt
1698        // through to it rather than refusing it for capability. It carries
1699        // no tools, and still reports none.
1700        capability == Capability::Images
1701    }
1702}
1703
1704// ---------------------------------------------------------------------------
1705// FakeClassifier
1706
1707/// How a [`FakeClassifier`] answers, mirroring [`TextModelMode`] for the
1708/// classifier port (issue #456).
1709///
1710/// The fixed modes [`NotConfigured`](Self::NotConfigured),
1711/// [`Rejected`](Self::Rejected) and [`Transient`](Self::Transient) are the
1712/// three the port's own contract names, and [`Error`](Self::Error) carries
1713/// any `ClassifierError` a test wants — between them every arm of a
1714/// caller's error mapping is reachable, `Transport` included.
1715#[derive(Debug, Clone, PartialEq)]
1716pub enum ClassifierMode {
1717    /// Answer exactly these, keyed by question id. A question the set asks
1718    /// that the map does not name falls back to the deterministic answer,
1719    /// so one test can script a subset and let the rest stay plausible.
1720    Answers(BTreeMap<String, Answer>),
1721    /// Answer every question deterministically from its own labels (the
1722    /// default): the first label, with a plausible normalised distribution
1723    /// over all of them. A module under conformance gets a usable answer
1724    /// without scripting anything.
1725    Deterministic,
1726    /// Report the port is unwired (`ClassifierError::NotConfigured`).
1727    NotConfigured,
1728    /// A non-retryable refusal carrying the provider text.
1729    Rejected(String),
1730    /// A retryable failure with the provider's back-off, where it said one.
1731    Transient { retry_after: Option<Duration> },
1732    /// Exactly this error, text and all.
1733    Error(ClassifierError),
1734}
1735
1736/// One `Classifier::ask` a [`FakeClassifier`] answered, for assertions:
1737/// the state as it arrived (untruncated — this fake has no provider
1738/// budget to spend) and the question ids in the set, in map order.
1739#[derive(Debug, Clone, PartialEq, Eq)]
1740pub struct Ask {
1741    /// The state the questions were asked about.
1742    pub state: String,
1743    /// The ids of the questions in the set, in `BTreeMap` order.
1744    pub question_ids: Vec<String>,
1745}
1746
1747/// An in-memory [`Classifier`] for module tests: records every ask it
1748/// answered and answers according to its [`ClassifierMode`].
1749///
1750/// The mode is global by default and can be overridden **per question**
1751/// ([`FakeClassifier::set_answer_for`]), mirroring [`FakeTextModel`]'s
1752/// per-tier override: one test can script one question's probabilities
1753/// while the rest stay deterministic.
1754///
1755/// An ask that failed (`NotConfigured`, `Rejected`, `Transient` or
1756/// `Error`) is **not** recorded, the same rule [`FakeTextModel`] applies
1757/// to a completion — the recording means "the classifier answered", so a
1758/// caller that retried on `Transient { .. }` sees one recorded ask per
1759/// attempt that got answers.
1760#[derive(Clone)]
1761pub struct FakeClassifier {
1762    inner: Arc<FakeClassifierInner>,
1763}
1764
1765struct FakeClassifierInner {
1766    mode: Mutex<ClassifierMode>,
1767    per_question: Mutex<BTreeMap<String, Answer>>,
1768    asks: Mutex<Vec<Ask>>,
1769    profile: Mutex<ClassifierProfile>,
1770}
1771
1772/// The deterministic answer for `question`: its first label
1773/// ([`Question::labels`] order — first criterion, first level, or `true`
1774/// for a `Noul`) carries most of the mass, spread plausibly over all the
1775/// labels. Pure: the same question always answers the same way, which is
1776/// what an assertion needs.
1777fn deterministic_answer(question: &Question) -> Answer {
1778    let labels = question.labels();
1779    let chosen = labels.first().copied().unwrap_or_default();
1780    // Most of the mass on the chosen label, the rest split evenly: for a
1781    // two-label question 0.6/0.4, for five 0.6 then four shares of 0.1.
1782    let share = if labels.len() > 1 {
1783        0.4 / f32::from(u16::try_from(labels.len() - 1).unwrap_or(1))
1784    } else {
1785        0.0
1786    };
1787    let probabilities: BTreeMap<String, f32> = labels
1788        .iter()
1789        .map(|label| {
1790            let mass = if *label == chosen { 0.6 } else { share };
1791            ((*label).to_owned(), mass)
1792        })
1793        .collect();
1794    match question {
1795        Question::Choice { .. } => Answer::choice(chosen, probabilities),
1796        Question::Noul { .. } => Answer::noul(chosen == "true", probabilities),
1797        // A scale's labels are usually named for their scores ("1".."5"),
1798        // so the first level parses; when it does not, the score is 0.0
1799        // and the confidence still comes from the label itself.
1800        Question::Score { .. } => Answer::new(
1801            AnswerValue::Score(chosen.parse().unwrap_or_default()),
1802            probabilities,
1803            0.6,
1804        ),
1805    }
1806}
1807
1808impl FakeClassifier {
1809    #[must_use]
1810    pub fn new(mode: ClassifierMode) -> Self {
1811        Self {
1812            inner: Arc::new(FakeClassifierInner {
1813                mode: Mutex::new(mode),
1814                per_question: Mutex::new(BTreeMap::new()),
1815                asks: Mutex::new(Vec::new()),
1816                profile: Mutex::new(ClassifierProfile::new(
1817                    Calibration::Classifier,
1818                    cratefield_core::DEFAULT_MAX_STATE_CHARS,
1819                )),
1820            }),
1821        }
1822    }
1823
1824    /// Every ask answered so far, in order. An ask that failed is not
1825    /// recorded.
1826    #[must_use]
1827    pub fn asks(&self) -> Vec<Ask> {
1828        self.inner.asks.lock().expect("classifier lock").clone()
1829    }
1830
1831    /// The most recent ask answered.
1832    #[must_use]
1833    pub fn last(&self) -> Option<Ask> {
1834        self.inner
1835            .asks
1836            .lock()
1837            .expect("classifier lock")
1838            .last()
1839            .cloned()
1840    }
1841
1842    /// Switches the mode every question without an override answers with
1843    /// (e.g. flip to `NotConfigured` mid-test).
1844    pub fn set_mode(&self, mode: ClassifierMode) {
1845        *self.inner.mode.lock().expect("classifier lock") = mode;
1846    }
1847
1848    /// Makes one question answer with `answer`, whatever the mode is —
1849    /// scripting one question's probabilities while the rest stay
1850    /// deterministic, say.
1851    pub fn set_answer_for(&self, question_id: impl Into<String>, answer: Answer) {
1852        self.inner
1853            .per_question
1854            .lock()
1855            .expect("classifier lock")
1856            .insert(question_id.into(), answer);
1857    }
1858
1859    /// Drops one question's override, putting it back on the mode.
1860    pub fn clear_answer_for(&self, question_id: &str) {
1861        self.inner
1862            .per_question
1863            .lock()
1864            .expect("classifier lock")
1865            .remove(question_id);
1866    }
1867
1868    /// Reports `calibration` and `max_state_chars` from
1869    /// [`Classifier::profile`], so a test can exercise a module's
1870    /// calibration- or truncation-aware behaviour against either family.
1871    pub fn set_profile(&self, calibration: Calibration, max_state_chars: usize) {
1872        *self.inner.profile.lock().expect("classifier lock") =
1873            ClassifierProfile::new(calibration, max_state_chars);
1874    }
1875}
1876
1877impl Default for FakeClassifier {
1878    fn default() -> Self {
1879        Self::new(ClassifierMode::Deterministic)
1880    }
1881}
1882
1883#[async_trait]
1884impl Classifier for FakeClassifier {
1885    fn profile(&self) -> ClassifierProfile {
1886        *self.inner.profile.lock().expect("classifier lock")
1887    }
1888
1889    async fn ask(
1890        &self,
1891        state: &str,
1892        questions: &BTreeMap<String, Question>,
1893    ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
1894        // A malformed set lands on `Rejected`, never a panic — the same
1895        // way a real adapter refuses it.
1896        validate_questions(questions)?;
1897
1898        let mode = self.inner.mode.lock().expect("classifier lock").clone();
1899        match mode {
1900            ClassifierMode::NotConfigured => return Err(ClassifierError::NotConfigured),
1901            ClassifierMode::Rejected(message) => return Err(ClassifierError::Rejected(message)),
1902            ClassifierMode::Transient { retry_after } => {
1903                return Err(ClassifierError::Transient { retry_after });
1904            }
1905            ClassifierMode::Error(error) => return Err(error),
1906            ClassifierMode::Answers(_) | ClassifierMode::Deterministic => {}
1907        }
1908
1909        let overrides = self.inner.per_question.lock().expect("classifier lock");
1910        let answers: BTreeMap<String, Answer> = questions
1911            .iter()
1912            .map(|(id, question)| {
1913                let answer = overrides.get(id).cloned().unwrap_or_else(|| match &mode {
1914                    ClassifierMode::Answers(map) => map
1915                        .get(id)
1916                        .cloned()
1917                        .unwrap_or_else(|| deterministic_answer(question)),
1918                    _ => deterministic_answer(question),
1919                });
1920                (id.clone(), answer)
1921            })
1922            .collect();
1923        drop(overrides);
1924
1925        self.inner.asks.lock().expect("classifier lock").push(Ask {
1926            state: state.to_owned(),
1927            question_ids: questions.keys().cloned().collect(),
1928        });
1929        Ok(answers)
1930    }
1931}
1932
1933// ---------------------------------------------------------------------------
1934// FakePayments
1935
1936/// How a [`FakePayments`] responds.
1937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1938pub enum PaymentsMode {
1939    /// Succeed and record the call.
1940    Ok,
1941    /// Report `NotConfigured` (no Stripe key).
1942    NotConfigured,
1943    /// A retryable failure.
1944    Transient,
1945}
1946
1947/// What a [`FakePayments`] recorded, for assertions.
1948#[derive(Debug, Clone, PartialEq, Eq)]
1949pub enum PaymentsCall {
1950    Checkout,
1951    SubscriptionCheckout,
1952    ConnectAccountLink,
1953    ChargeWithTransfer,
1954    Refund,
1955    VerifyWebhook,
1956}
1957
1958/// An in-memory [`cratefield_core::Payments`] for module tests: records which
1959/// calls were made and answers per its [`PaymentsMode`]. `verify_webhook`
1960/// treats a signature header of `"invalid"` as a tampered event.
1961#[derive(Clone)]
1962pub struct FakePayments {
1963    inner: Arc<FakePaymentsInner>,
1964}
1965
1966struct FakePaymentsInner {
1967    mode: Mutex<PaymentsMode>,
1968    calls: Mutex<Vec<PaymentsCall>>,
1969}
1970
1971impl FakePayments {
1972    #[must_use]
1973    pub fn new(mode: PaymentsMode) -> Self {
1974        Self {
1975            inner: Arc::new(FakePaymentsInner {
1976                mode: Mutex::new(mode),
1977                calls: Mutex::new(Vec::new()),
1978            }),
1979        }
1980    }
1981
1982    /// The calls recorded so far.
1983    #[must_use]
1984    pub fn calls(&self) -> Vec<PaymentsCall> {
1985        self.inner.calls.lock().expect("payments lock").clone()
1986    }
1987
1988    pub fn set_mode(&self, mode: PaymentsMode) {
1989        *self.inner.mode.lock().expect("payments lock") = mode;
1990    }
1991
1992    fn record(&self, call: PaymentsCall) {
1993        self.inner.calls.lock().expect("payments lock").push(call);
1994    }
1995
1996    fn guard(&self) -> Result<(), cratefield_core::PaymentsError> {
1997        match *self.inner.mode.lock().expect("payments lock") {
1998            PaymentsMode::Ok => Ok(()),
1999            PaymentsMode::NotConfigured => Err(cratefield_core::PaymentsError::NotConfigured),
2000            PaymentsMode::Transient => Err(cratefield_core::PaymentsError::Transient(
2001                "fake payments failure".to_owned(),
2002            )),
2003        }
2004    }
2005}
2006
2007impl Default for FakePayments {
2008    fn default() -> Self {
2009        Self::new(PaymentsMode::Ok)
2010    }
2011}
2012
2013#[async_trait]
2014impl cratefield_core::Payments for FakePayments {
2015    async fn create_checkout(
2016        &self,
2017        _request: &cratefield_core::CheckoutRequest,
2018    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
2019        self.guard()?;
2020        self.record(PaymentsCall::Checkout);
2021        Ok(cratefield_core::CheckoutSession {
2022            id: "cs_fake".to_owned(),
2023            url: "https://checkout.stripe.test/cs_fake".to_owned(),
2024        })
2025    }
2026
2027    async fn create_subscription_checkout(
2028        &self,
2029        _request: &cratefield_core::SubscriptionCheckoutRequest,
2030    ) -> Result<cratefield_core::CheckoutSession, cratefield_core::PaymentsError> {
2031        self.guard()?;
2032        self.record(PaymentsCall::SubscriptionCheckout);
2033        Ok(cratefield_core::CheckoutSession {
2034            id: "cs_sub_fake".to_owned(),
2035            url: "https://checkout.stripe.test/cs_sub_fake".to_owned(),
2036        })
2037    }
2038
2039    async fn create_connect_account_link(
2040        &self,
2041        _request: &cratefield_core::ConnectAccountLinkRequest,
2042    ) -> Result<cratefield_core::ConnectAccountLink, cratefield_core::PaymentsError> {
2043        self.guard()?;
2044        self.record(PaymentsCall::ConnectAccountLink);
2045        Ok(cratefield_core::ConnectAccountLink {
2046            account_id: "acct_fake".to_owned(),
2047            url: "https://connect.stripe.test/acct_fake".to_owned(),
2048        })
2049    }
2050
2051    async fn charge_with_transfer(
2052        &self,
2053        _request: &cratefield_core::TransferCharge,
2054    ) -> Result<cratefield_core::Charge, cratefield_core::PaymentsError> {
2055        self.guard()?;
2056        self.record(PaymentsCall::ChargeWithTransfer);
2057        Ok(cratefield_core::Charge {
2058            id: "pi_fake".to_owned(),
2059            status: "succeeded".to_owned(),
2060        })
2061    }
2062
2063    async fn refund(
2064        &self,
2065        _request: &cratefield_core::RefundRequest,
2066    ) -> Result<cratefield_core::Refund, cratefield_core::PaymentsError> {
2067        self.guard()?;
2068        self.record(PaymentsCall::Refund);
2069        Ok(cratefield_core::Refund {
2070            id: "re_fake".to_owned(),
2071        })
2072    }
2073
2074    async fn verify_webhook(
2075        &self,
2076        signature_header: &str,
2077        _body: &[u8],
2078    ) -> Result<cratefield_core::WebhookEvent, cratefield_core::PaymentsError> {
2079        self.record(PaymentsCall::VerifyWebhook);
2080        if signature_header == "invalid" {
2081            return Err(cratefield_core::PaymentsError::SignatureInvalid(
2082                "fake tampered signature".to_owned(),
2083            ));
2084        }
2085        self.guard()?;
2086        Ok(cratefield_core::WebhookEvent {
2087            id: "evt_fake".to_owned(),
2088            kind: "checkout.session.completed".to_owned(),
2089            data: serde_json::json!({ "object": "checkout.session" }),
2090        })
2091    }
2092}
2093
2094// ---------------------------------------------------------------------------
2095// FakeTracker
2096
2097/// A short, non-reversible stand-in for a [`Credential`] in a recorded
2098/// call, after `push.rs`'s `fingerprint` for `Recipient`: enough to tell
2099/// two credentials apart in an assertion, useless for recovering the
2100/// token.
2101///
2102/// `push.rs` hashes with `sha2`, which is not a dependency of the kit; a
2103/// test fake needs no cross-version stability and no collision resistance
2104/// beyond "different credentials assert differently", so std's
2105/// [`DefaultHasher`](std::hash::DefaultHasher) does the same job. The
2106/// plaintext is never stored — that is the point of the port: a fake that
2107/// recorded the secret would make every "the token went nowhere" assertion
2108/// unfalsifiable.
2109fn fingerprint(credential: &Credential) -> String {
2110    use std::hash::{Hash, Hasher};
2111
2112    // The one `expose` in the kit: the fingerprint is computed and the
2113    // borrow dropped before anything is recorded.
2114    let mut hasher = std::hash::DefaultHasher::new();
2115    credential.expose().hash(&mut hasher);
2116    format!("fp:{:016x}", hasher.finish())
2117}
2118
2119/// How a [`FakeTracker`] answers, mirroring [`MailerMode`] and [`PushMode`]
2120/// for the tracker port (issue #431).
2121///
2122/// [`Error`](Self::Error) carries the exact `TrackerError` a test wants,
2123/// text and delay and all (issue #236's rule): the fixed modes only ever
2124/// produce clean strings, so the arms of a caller's outcome mapping — a
2125/// `Rejected` carrying the tracker's own words, a `Transient` carrying a
2126/// provider-stated delay — would otherwise be unreachable from a test.
2127#[derive(Debug, Clone, PartialEq, Eq)]
2128pub enum TrackerMode {
2129    /// Accept and record the ticket.
2130    FileOk,
2131    /// Report that no adapter is configured for this destination.
2132    NotConfigured,
2133    /// Report that the per-tenant credential was refused (`401`/`403`).
2134    Unauthorized,
2135    /// Report that the tracker refused the ticket — a `4xx` about the
2136    /// draft, not about the credential.
2137    Rejected,
2138    /// A retryable failure with no delay stated.
2139    Transient,
2140    /// Exactly this error, text and all.
2141    Error(TrackerError),
2142}
2143
2144/// One `Tracker::file` a [`FakeTracker`] accepted, for assertions.
2145///
2146/// The credential appears only as its non-reversible
2147/// `fingerprint`: proof that a credential reached the port, and never
2148/// the secret itself.
2149#[derive(Debug, Clone, PartialEq, Eq)]
2150pub struct FiledCall {
2151    /// Where the ticket was filed.
2152    pub dest: Destination,
2153    /// The ticket as the caller handed it over.
2154    pub draft: TicketDraft,
2155    /// A fingerprint of the credential the caller passed.
2156    pub credential_fingerprint: String,
2157}
2158
2159/// One `Tracker::status` a [`FakeTracker`] accepted, for assertions.
2160#[derive(Debug, Clone, PartialEq, Eq)]
2161pub struct StatusedCall {
2162    /// Where the ticket lives.
2163    pub dest: Destination,
2164    /// The external id the caller asked about.
2165    pub external_id: String,
2166    /// A fingerprint of the credential the caller passed.
2167    pub credential_fingerprint: String,
2168}
2169
2170/// One `Tracker::comment` a [`FakeTracker`] accepted, for assertions.
2171#[derive(Debug, Clone, PartialEq, Eq)]
2172pub struct CommentedCall {
2173    /// Where the ticket lives.
2174    pub dest: Destination,
2175    /// The ticket the note was added to.
2176    pub external_id: String,
2177    /// The note as the caller handed it over.
2178    pub comment: TicketComment,
2179    /// A fingerprint of the credential the caller passed.
2180    pub credential_fingerprint: String,
2181}
2182
2183/// An in-memory [`Tracker`] for module tests: records every accepted
2184/// `file`/`status`/`comment` call and answers according to its
2185/// [`TrackerMode`]. A
2186/// call the mode answered with an error is not recorded, as [`FakePush`]
2187/// does not record a failed send.
2188///
2189/// The mode is global by default and can be overridden **per destination**
2190/// ([`FakeTracker::set_mode_for`]), the [`FakePush`] shape: one tenant's
2191/// expired token among live ones. The state an accepted `status` reports
2192/// is scripted separately ([`FakeTracker::set_state`]), because "the file
2193/// succeeded" and "the ticket has moved on since" are two different
2194/// answers a test wants to set independently.
2195#[derive(Clone, Debug)]
2196pub struct FakeTracker {
2197    inner: Arc<FakeTrackerInner>,
2198}
2199
2200#[derive(Debug)]
2201struct FakeTrackerInner {
2202    mode: Mutex<TrackerMode>,
2203    per_destination: Mutex<HashMap<Destination, TrackerMode>>,
2204    filed: Mutex<Vec<FiledCall>>,
2205    statused: Mutex<Vec<StatusedCall>>,
2206    commented: Mutex<Vec<CommentedCall>>,
2207    state: Mutex<TicketState>,
2208}
2209
2210impl FakeTracker {
2211    #[must_use]
2212    pub fn new(mode: TrackerMode) -> Self {
2213        Self {
2214            inner: Arc::new(FakeTrackerInner {
2215                mode: Mutex::new(mode),
2216                per_destination: Mutex::new(HashMap::new()),
2217                filed: Mutex::new(Vec::new()),
2218                statused: Mutex::new(Vec::new()),
2219                commented: Mutex::new(Vec::new()),
2220                state: Mutex::new(TicketState::Open),
2221            }),
2222        }
2223    }
2224
2225    /// Every `file` accepted so far, in order.
2226    #[must_use]
2227    pub fn filed(&self) -> Vec<FiledCall> {
2228        self.inner.filed.lock().expect("tracker lock").clone()
2229    }
2230
2231    /// The most recent `file` accepted.
2232    #[must_use]
2233    pub fn last_filed(&self) -> Option<FiledCall> {
2234        self.inner
2235            .filed
2236            .lock()
2237            .expect("tracker lock")
2238            .last()
2239            .cloned()
2240    }
2241
2242    /// Every `status` accepted so far, in order.
2243    #[must_use]
2244    pub fn statused(&self) -> Vec<StatusedCall> {
2245        self.inner.statused.lock().expect("tracker lock").clone()
2246    }
2247
2248    /// Every `comment` accepted so far, in order.
2249    #[must_use]
2250    pub fn commented(&self) -> Vec<CommentedCall> {
2251        self.inner.commented.lock().expect("tracker lock").clone()
2252    }
2253
2254    /// Switches the mode every destination without an override answers
2255    /// with (e.g. flip to `Unauthorized` mid-test).
2256    pub fn set_mode(&self, mode: TrackerMode) {
2257        *self.inner.mode.lock().expect("tracker lock") = mode;
2258    }
2259
2260    /// Makes one destination answer with `mode`, whatever the global mode
2261    /// is — one tenant's expired token among live ones, say.
2262    pub fn set_mode_for(&self, dest: &Destination, mode: TrackerMode) {
2263        self.inner
2264            .per_destination
2265            .lock()
2266            .expect("tracker lock")
2267            .insert(dest.clone(), mode);
2268    }
2269
2270    /// Drops one destination's override, putting it back on the global
2271    /// mode.
2272    pub fn clear_mode_for(&self, dest: &Destination) {
2273        self.inner
2274            .per_destination
2275            .lock()
2276            .expect("tracker lock")
2277            .remove(dest);
2278    }
2279
2280    /// The mode `dest` will answer with.
2281    #[must_use]
2282    pub fn mode_for(&self, dest: &Destination) -> TrackerMode {
2283        self.inner
2284            .per_destination
2285            .lock()
2286            .expect("tracker lock")
2287            .get(dest)
2288            .cloned()
2289            .unwrap_or_else(|| self.inner.mode.lock().expect("tracker lock").clone())
2290    }
2291
2292    /// The state an accepted `status` reports.
2293    pub fn set_state(&self, state: TicketState) {
2294        *self.inner.state.lock().expect("tracker lock") = state;
2295    }
2296
2297    /// The state an accepted `status` currently reports.
2298    #[must_use]
2299    pub fn state(&self) -> TicketState {
2300        *self.inner.state.lock().expect("tracker lock")
2301    }
2302
2303    /// The error arms shared by `file`, `status` and `comment`: the same
2304    /// fixed modes script all three, the way a real adapter answers the
2305    /// one `TrackerError` vocabulary on every method.
2306    fn error_for(mode: &TrackerMode) -> Option<TrackerError> {
2307        match mode {
2308            TrackerMode::FileOk => None,
2309            TrackerMode::NotConfigured => Some(TrackerError::NotConfigured),
2310            TrackerMode::Unauthorized => Some(TrackerError::Unauthorized),
2311            TrackerMode::Rejected => {
2312                Some(TrackerError::Rejected("fake tracker rejection".to_owned()))
2313            }
2314            TrackerMode::Transient => Some(TrackerError::Transient { retry_after: None }),
2315            TrackerMode::Error(error) => Some(error.clone()),
2316        }
2317    }
2318}
2319
2320impl Default for FakeTracker {
2321    fn default() -> Self {
2322        Self::new(TrackerMode::FileOk)
2323    }
2324}
2325
2326#[async_trait]
2327impl Tracker for FakeTracker {
2328    async fn file(
2329        &self,
2330        dest: &Destination,
2331        cred: &Credential,
2332        draft: &TicketDraft,
2333    ) -> Result<Filed, TrackerError> {
2334        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
2335            return Err(error);
2336        }
2337        let id = format!(
2338            "fake-{}",
2339            self.inner.filed.lock().expect("tracker lock").len()
2340        );
2341        self.inner
2342            .filed
2343            .lock()
2344            .expect("tracker lock")
2345            .push(FiledCall {
2346                dest: dest.clone(),
2347                draft: draft.clone(),
2348                credential_fingerprint: fingerprint(cred),
2349            });
2350        Ok(Filed {
2351            url: format!("https://tracker.fake.test/browse/{id}"),
2352            external_id: id,
2353        })
2354    }
2355
2356    async fn status(
2357        &self,
2358        dest: &Destination,
2359        cred: &Credential,
2360        external_id: &str,
2361    ) -> Result<TicketStatus, TrackerError> {
2362        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
2363            return Err(error);
2364        }
2365        self.inner
2366            .statused
2367            .lock()
2368            .expect("tracker lock")
2369            .push(StatusedCall {
2370                dest: dest.clone(),
2371                external_id: external_id.to_owned(),
2372                credential_fingerprint: fingerprint(cred),
2373            });
2374        Ok(TicketStatus {
2375            external_id: external_id.to_owned(),
2376            state: self.state(),
2377            url: None,
2378        })
2379    }
2380
2381    async fn comment(
2382        &self,
2383        dest: &Destination,
2384        cred: &Credential,
2385        external_id: &str,
2386        comment: &TicketComment,
2387    ) -> Result<(), TrackerError> {
2388        if let Some(error) = Self::error_for(&self.mode_for(dest)) {
2389            return Err(error);
2390        }
2391        self.inner
2392            .commented
2393            .lock()
2394            .expect("tracker lock")
2395            .push(CommentedCall {
2396                dest: dest.clone(),
2397                external_id: external_id.to_owned(),
2398                comment: comment.clone(),
2399                credential_fingerprint: fingerprint(cred),
2400            });
2401        Ok(())
2402    }
2403}
2404
2405// ---------------------------------------------------------------------------
2406// FakeRealtime
2407
2408/// Room id -> the member ids a [`FakeRealtime`] reports for it.
2409type RealtimeMembers = std::collections::HashMap<String, Vec<String>>;
2410/// The `(room_id, message)` broadcasts a [`FakeRealtime`] recorded.
2411type RealtimeBroadcasts = Vec<(String, Vec<u8>)>;
2412
2413/// An in-memory [`cratefield_core::Realtime`] for module tests: records every
2414/// broadcast per room and reports a fixed member list. It exercises the port a
2415/// module holds (broadcast/members from outside a socket); the socket lifecycle
2416/// and `RoomHandler` are the runtime adapter's job, covered by the native
2417/// adapter's own tests.
2418#[derive(Clone, Default)]
2419pub struct FakeRealtime {
2420    broadcasts: Arc<Mutex<RealtimeBroadcasts>>,
2421    members: Arc<Mutex<RealtimeMembers>>,
2422}
2423
2424impl FakeRealtime {
2425    #[must_use]
2426    pub fn new() -> Self {
2427        Self::default()
2428    }
2429
2430    /// Presets the members a room reports (for `members` assertions).
2431    pub fn set_members(&self, room_id: &str, ids: &[&str]) {
2432        self.members.lock().expect("realtime lock").insert(
2433            room_id.to_owned(),
2434            ids.iter().map(|id| (*id).to_owned()).collect(),
2435        );
2436    }
2437
2438    /// Every `(room_id, message)` broadcast so far.
2439    #[must_use]
2440    pub fn broadcasts(&self) -> Vec<(String, Vec<u8>)> {
2441        self.broadcasts.lock().expect("realtime lock").clone()
2442    }
2443}
2444
2445#[async_trait]
2446impl cratefield_core::Realtime for FakeRealtime {
2447    async fn broadcast(
2448        &self,
2449        room_id: &str,
2450        message: &[u8],
2451    ) -> Result<(), cratefield_core::RealtimeError> {
2452        self.broadcasts
2453            .lock()
2454            .expect("realtime lock")
2455            .push((room_id.to_owned(), message.to_vec()));
2456        Ok(())
2457    }
2458
2459    async fn members(
2460        &self,
2461        room_id: &str,
2462    ) -> Result<Vec<cratefield_core::Member>, cratefield_core::RealtimeError> {
2463        Ok(self
2464            .members
2465            .lock()
2466            .expect("realtime lock")
2467            .get(room_id)
2468            .map(|ids| ids.iter().map(cratefield_core::Member::new).collect())
2469            .unwrap_or_default())
2470    }
2471}
2472
2473/// What a [`FakeAuth`] answers.
2474///
2475/// Every outcome the port has, because a fake that cannot produce one
2476/// makes the arm handling it unreachable from every test — which is how
2477/// a five-arm mapping shipped with four of the arms never executed.
2478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2479pub enum AuthMode {
2480    /// Every request is anonymous, header or not. The deployment where
2481    /// nobody signs in.
2482    Anonymous,
2483    /// A bearer token is taken at face value as the subject's id, and a
2484    /// request with no `Authorization` header is anonymous. The mode for
2485    /// a test that wants both branches without minting a JWT.
2486    TokenIsTheSubject,
2487    /// Every presented credential is refused, and a request with no
2488    /// header is still anonymous — the distinction the port exists to
2489    /// keep.
2490    NotVerified,
2491    /// The verifier cannot answer, credential or not.
2492    Unavailable,
2493}
2494
2495/// A [`cratefield_core::Auth`] answering from a mode rather than from
2496/// a key set.
2497pub struct FakeAuth {
2498    mode: AuthMode,
2499    calls: std::sync::Mutex<Vec<String>>,
2500}
2501
2502impl FakeAuth {
2503    /// A fake in `mode`.
2504    #[must_use]
2505    pub fn new(mode: AuthMode) -> Self {
2506        Self {
2507            mode,
2508            calls: std::sync::Mutex::new(Vec::new()),
2509        }
2510    }
2511
2512    /// A fake that reads the bearer token as the subject's id.
2513    #[must_use]
2514    pub fn subjects() -> Self {
2515        Self::new(AuthMode::TokenIsTheSubject)
2516    }
2517
2518    /// The bearer values it was asked about, in order. `""` records a
2519    /// request that carried no `Authorization` header at all.
2520    #[must_use]
2521    pub fn calls(&self) -> Vec<String> {
2522        self.calls.lock().expect("uncontended").clone()
2523    }
2524}
2525
2526#[async_trait::async_trait]
2527impl cratefield_core::Auth for FakeAuth {
2528    async fn identify(
2529        &self,
2530        headers: &http::HeaderMap,
2531    ) -> Result<cratefield_core::Caller, cratefield_core::AuthError> {
2532        let presented = headers
2533            .get(http::header::AUTHORIZATION)
2534            .and_then(|value| value.to_str().ok())
2535            .and_then(|value| value.strip_prefix("Bearer "))
2536            .map(str::to_owned);
2537        self.calls
2538            .lock()
2539            .expect("uncontended")
2540            .push(presented.clone().unwrap_or_default());
2541        match self.mode {
2542            AuthMode::Anonymous => Ok(cratefield_core::Caller::Anonymous),
2543            AuthMode::Unavailable => Err(cratefield_core::AuthError::Unavailable(
2544                "the fake is in Unavailable mode".to_owned(),
2545            )),
2546            AuthMode::NotVerified | AuthMode::TokenIsTheSubject => match presented {
2547                None => Ok(cratefield_core::Caller::Anonymous),
2548                Some(_) if self.mode == AuthMode::NotVerified => {
2549                    Err(cratefield_core::AuthError::NotVerified)
2550                }
2551                Some(token) => Ok(cratefield_core::Caller::Subject(
2552                    cratefield_core::Subject::new(token).session("fake-session"),
2553                )),
2554            },
2555        }
2556    }
2557}
2558
2559// ---------------------------------------------------------------------------
2560// FakeCustomHostnames
2561
2562/// An in-memory [`cratefield_core::CustomHostnames`] for module tests:
2563/// records every call, walks a claim from pending to live on demand, and
2564/// scripts the error the next call answers with.
2565///
2566/// `create` runs [`cratefield_core::check_hostname`] first, exactly as a
2567/// real adapter must, so a test can drive the `Refused` arm — the refusal
2568/// never reaches a provider.
2569#[derive(Clone)]
2570pub struct FakeCustomHostnames {
2571    inner: Arc<FakeCustomHostnamesInner>,
2572}
2573
2574struct FakeCustomHostnamesInner {
2575    own_zone: String,
2576    hostnames: Mutex<BTreeMap<String, CustomHostname>>,
2577    calls: Mutex<Vec<String>>,
2578    /// The scripted answer for the next call, if any.
2579    refusals: Mutex<VecDeque<CustomHostnameError>>,
2580    next_id: AtomicUsize,
2581}
2582
2583impl FakeCustomHostnames {
2584    /// A fake serving hostnames outside `own_zone` — the zone whose names
2585    /// are refused as the deployment's own.
2586    #[must_use]
2587    pub fn new(own_zone: impl Into<String>) -> Self {
2588        Self {
2589            inner: Arc::new(FakeCustomHostnamesInner {
2590                own_zone: own_zone.into(),
2591                hostnames: Mutex::new(BTreeMap::new()),
2592                calls: Mutex::new(Vec::new()),
2593                refusals: Mutex::new(VecDeque::new()),
2594                next_id: AtomicUsize::new(0),
2595            }),
2596        }
2597    }
2598
2599    /// Every call so far, in order, as `"create share.acme.com"`.
2600    #[must_use]
2601    pub fn calls(&self) -> Vec<String> {
2602        self.inner
2603            .calls
2604            .lock()
2605            .expect("custom hostnames lock")
2606            .clone()
2607    }
2608
2609    /// Walks a claim to `Active`/`Active` with no records left to publish.
2610    ///
2611    /// A hostname that was never claimed is a no-op rather than a panic:
2612    /// a test's setup failing should fail on the assertion, not here.
2613    pub fn activate(&self, hostname: &str) {
2614        let key = self.key(hostname);
2615        if let Some(claim) = self
2616            .inner
2617            .hostnames
2618            .lock()
2619            .expect("custom hostnames lock")
2620            .get_mut(&key)
2621        {
2622            claim.status = ProviderStatus::Active;
2623            claim.certificate = CertificateStatus::Active;
2624            claim.validation.clear();
2625        }
2626    }
2627
2628    /// Records the claim as failed with the provider's own `reason`.
2629    pub fn fail(&self, hostname: &str, reason: &str) {
2630        let key = self.key(hostname);
2631        if let Some(claim) = self
2632            .inner
2633            .hostnames
2634            .lock()
2635            .expect("custom hostnames lock")
2636            .get_mut(&key)
2637        {
2638            claim.status = ProviderStatus::Failed {
2639                reason: reason.to_owned(),
2640            };
2641        }
2642    }
2643
2644    /// Makes the next call answer `error` instead of its normal answer.
2645    /// Queue several to script a sequence.
2646    pub fn refuse_next(&self, error: CustomHostnameError) {
2647        self.inner
2648            .refusals
2649            .lock()
2650            .expect("custom hostnames lock")
2651            .push_back(error);
2652    }
2653
2654    /// The key a hostname is stored under: the same normalisation the
2655    /// adapter's `check_hostname` call produces.
2656    fn key(&self, hostname: &str) -> String {
2657        check_hostname(hostname, &self.inner.own_zone)
2658            .unwrap_or_else(|_| hostname.trim().trim_end_matches('.').to_ascii_lowercase())
2659    }
2660
2661    fn log(&self, call: String) {
2662        self.inner
2663            .calls
2664            .lock()
2665            .expect("custom hostnames lock")
2666            .push(call);
2667    }
2668
2669    fn take_refusal(&self) -> Option<CustomHostnameError> {
2670        self.inner
2671            .refusals
2672            .lock()
2673            .expect("custom hostnames lock")
2674            .pop_front()
2675    }
2676}
2677
2678#[async_trait]
2679impl CustomHostnames for FakeCustomHostnames {
2680    async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError> {
2681        self.log(format!("create {}", claim.hostname));
2682        if let Some(error) = self.take_refusal() {
2683            return Err(error);
2684        }
2685        let hostname = check_hostname(&claim.hostname, &self.inner.own_zone)
2686            .map_err(CustomHostnameError::Refused)?;
2687        let mut hostnames = self.inner.hostnames.lock().expect("custom hostnames lock");
2688        if hostnames.contains_key(&hostname) {
2689            return Err(CustomHostnameError::AlreadyExists);
2690        }
2691        let id = format!(
2692            "fake-{}",
2693            self.inner.next_id.fetch_add(1, Ordering::Relaxed)
2694        );
2695        let created = CustomHostname {
2696            id: id.clone(),
2697            hostname: hostname.clone(),
2698            status: ProviderStatus::Pending,
2699            certificate: CertificateStatus::Pending,
2700            validation: vec![Validation {
2701                record_type: DnsRecordType::Txt,
2702                name: format!("_cf-custom-hostname.{hostname}"),
2703                value: id,
2704            }],
2705        };
2706        hostnames.insert(hostname, created.clone());
2707        Ok(created)
2708    }
2709
2710    async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError> {
2711        self.log(format!("get {hostname}"));
2712        if let Some(error) = self.take_refusal() {
2713            return Err(error);
2714        }
2715        let key = self.key(hostname);
2716        Ok(self
2717            .inner
2718            .hostnames
2719            .lock()
2720            .expect("custom hostnames lock")
2721            .get(&key)
2722            .cloned())
2723    }
2724
2725    async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError> {
2726        self.log(format!("delete {hostname}"));
2727        if let Some(error) = self.take_refusal() {
2728            return Err(error);
2729        }
2730        let key = self.key(hostname);
2731        self.inner
2732            .hostnames
2733            .lock()
2734            .expect("custom hostnames lock")
2735            .remove(&key);
2736        Ok(())
2737    }
2738
2739    async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError> {
2740        self.log(format!("refresh {hostname}"));
2741        if let Some(error) = self.take_refusal() {
2742            return Err(error);
2743        }
2744        let key = self.key(hostname);
2745        self.inner
2746            .hostnames
2747            .lock()
2748            .expect("custom hostnames lock")
2749            .get(&key)
2750            .cloned()
2751            .ok_or(CustomHostnameError::NotFound)
2752    }
2753}
2754
2755#[cfg(test)]
2756mod tests {
2757    use super::*;
2758    use cratefield_core::Blob as _;
2759
2760    fn json_body(response: &Response<Bytes>) -> serde_json::Value {
2761        serde_json::from_slice(response.body()).expect("the body is json")
2762    }
2763
2764    #[test]
2765    fn problem_json_carries_the_problem_fields_and_media_type() {
2766        let response = problem_json(
2767            StatusCode::TOO_MANY_REQUESTS,
2768            "Too Many Requests",
2769            "slow down",
2770        );
2771        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
2772        assert_eq!(response.headers()[CONTENT_TYPE], "application/problem+json");
2773        let body = json_body(&response);
2774        assert_eq!(body["type"], "about:blank");
2775        assert_eq!(body["title"], "Too Many Requests");
2776        assert_eq!(body["status"], 429);
2777        assert_eq!(body["detail"], "slow down");
2778    }
2779
2780    #[test]
2781    fn error_json_is_the_colonizer_shape() {
2782        let response = error_json(StatusCode::BAD_REQUEST, "bad request");
2783        assert_eq!(response.headers()[CONTENT_TYPE], "application/json");
2784        assert_eq!(json_body(&response)["error"], "bad request");
2785    }
2786
2787    #[test]
2788    fn with_retry_after_sets_the_header_in_seconds() {
2789        let response = with_retry_after(error_json(StatusCode::TOO_MANY_REQUESTS, "slow"), 42);
2790        assert_eq!(response.headers()[RETRY_AFTER], "42");
2791    }
2792
2793    #[test]
2794    fn a_preset_answers_through_the_port() {
2795        // Drive a preset end to end: it must be wired into `scripted`, not
2796        // merely build a body.
2797        let http = FakeHttpClient::owlpost_rate_limited(7);
2798        let request = Request::builder()
2799            .uri("https://api.owlpost.fake/v1/emails")
2800            .body(Bytes::new())
2801            .expect("the request builds");
2802        let response = pollster::block_on(http.send(request)).expect("the preset answers");
2803        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
2804        assert_eq!(response.headers()[RETRY_AFTER], "7");
2805        assert_eq!(json_body(&response)["detail"], "slow down");
2806    }
2807
2808    #[pollster::test]
2809    async fn memory_blob_presigns_and_verifies_back() {
2810        // A clock dated in the past: a URL it stamps is expired if verification
2811        // reads the wall clock instead of the store's.
2812        let at = time::OffsetDateTime::from_unix_timestamp(1_577_836_800).expect("a valid epoch");
2813        let blob = MemoryBlob::with_presign(Arc::new(FixedClock(at)));
2814        let ttl = Duration::from_secs(3600);
2815
2816        let get = blob.signed_url("cms/clip.mp3", ttl).await.unwrap();
2817        assert!(get.starts_with("https://blob.test/cms/clip.mp3?"), "{get}");
2818        assert_eq!(
2819            blob.verify_presigned(&get, "GET", &[]).unwrap(),
2820            "cms/clip.mp3"
2821        );
2822
2823        let put = blob
2824            .signed_put_url("cms/clip.mp3", "audio/mpeg", Some(3), ttl)
2825            .await
2826            .unwrap();
2827        assert_eq!((put.method, put.headers.len()), ("PUT", 2));
2828        assert!(blob.verify_presigned(&put.url, "PUT", &put.headers).is_ok());
2829        // A header the URL was not signed for is refused.
2830        assert!(blob.verify_presigned(&put.url, "PUT", &[]).is_err());
2831
2832        // Without presigning, the Unsupported default stands.
2833        assert!(matches!(
2834            MemoryBlob::new().signed_url("k", ttl).await.unwrap_err(),
2835            cratefield_core::BlobError::Unsupported(_)
2836        ));
2837    }
2838}
2839
2840// ---------------------------------------------------------------------------
2841// FakeUrlSafety
2842
2843/// A [`UrlSafety`] that knows every URL as clean. It runs the port's own
2844/// [`check_url`] first, as every adapter must, so a fixture URL that is
2845/// not http(s) or is over the length ceiling is refused the same way the
2846/// real adapters refuse it. Wired into [`crate::full_fake_ports`] so the
2847/// conformance kit and the runtime clone checks see the port present.
2848#[derive(Debug, Clone, Copy, Default)]
2849pub struct FakeUrlSafety;
2850
2851#[async_trait]
2852impl UrlSafety for FakeUrlSafety {
2853    async fn check(&self, url: &str) -> Result<UrlVerdict, UrlSafetyError> {
2854        check_url(url, &UrlSafetyLimits::default())?;
2855        Ok(UrlVerdict {
2856            url: url.to_owned(),
2857            threats: Vec::new(),
2858            source: UrlSafetySource::GoogleWebRisk,
2859            checked_at: time::OffsetDateTime::from_unix_timestamp(1_800_000_000)
2860                .expect("fixed epoch"),
2861            cache_ttl: None,
2862            cached: false,
2863        })
2864    }
2865}