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