//! # MockCallControlClient — in-process test double for `CallControlClient` //! //! CI tests + the PSTN-sim e2e integration tests (T7, T8) use this mock; the //! live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live` //! and runs only on the maintainer's machine against real Twilio credentials. //! //! ## Why `std::sync::Mutex` (not `tokio::sync::Mutex`) //! //! The mock's async fn bodies are **synchronous** — they lock, push a record, //! unlock, and return. There is no `.await` while holding the lock, so the //! std mutex (cheaper, no async-overhead, no runtime coupling) is correct. //! `tokio::sync::Mutex` is for guards held across `.await` points; using it //! here would be cargo-cult. The async fns exist only to satisfy the trait's //! `async` signature (desugared by `async_trait` to `Pin>`); their //! bodies do not actually suspend. //! //! ## Poison recovery //! //! `std::sync::Mutex` "poisons" on panic — a previous lock holder that panicked //! leaves the mutex poisoned, and `.lock()` returns `Err(PoisonError)`. For a //! *test double*, a poisoned mutex means a previous test assertion failed; the //! right move is to recover the inner value (`.into_inner()`) rather than //! propagate the poison (which would cascade one test failure into every //! subsequent test). [`lock_or_recover`] does this idiomatically — and avoids //! the `unwrap()`/`expect()` that AGENTS.md's hot-path rule forbids in library //! code (the mock is library code, not `#[cfg(test)]`, because T7/T8 integration //! tests in `tests/` import it). use std::sync::{Mutex, MutexGuard}; use async_trait::async_trait; use super::{CallControlClient, CallControlError, SpendToken}; /// Recover a `MutexGuard` even if the mutex is poisoned. /// /// Poison indicates a prior lock-holder panicked (a test assertion failed /// while holding the lock). For a test double, propagating the poison would /// cascade the failure into every subsequent test that touches the mock; /// recovering the inner guard (`PoisonError::into_inner`) localizes the /// failure. This is the standard documented recovery pattern for non-invariant /// mutexes (see `std::sync::PoisonError::into_inner` docs). fn lock_or_recover(lock: &Mutex) -> MutexGuard<'_, T> { lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } /// A captured `originate` call — for test assertions. /// /// `spend_token_present` (not the token itself) is recorded because `SpendToken` /// is opaque in step 6; the mock only needs to verify whether a token was /// supplied, not inspect it. This keeps the mock forward-compatible with step /// 6's real (opaque, possibly non-`Clone`) token type. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OriginateRecord { /// The `to_phone` arg passed to `originate`. pub to: String, /// The `from_phone` arg passed to `originate`. pub from: String, /// Whether the caller passed `Some(SpendToken)` (step 6 will; step 5 passes `None`). pub spend_token_present: bool, } /// In-process test double for [`CallControlClient`]. No network IO. /// /// `originate` returns a synthetic `CA...` correlation ID (matching Twilio's /// CallSid format — `CA` + 32 hex chars — so downstream code that parses the /// prefix is exercised honestly) and records the call. `hangup` records the /// terminate. Both are infallible in the mock (no real provider to fail); the /// `Result` wrappers return `Ok` so test wiring that propagates the `Result` /// is exercised identically to the live path. pub struct MockCallControlClient { /// Monotonic counter for synthetic CallSid generation. `AtomicU64` rather /// than a `Mutex` because an atomic increment needs no guard (no /// critical section) — cheaper and lock-poison-free. counter: std::sync::atomic::AtomicU64, originated: Mutex>, hung_up: Mutex>, } impl MockCallControlClient { pub fn new() -> Self { Self { counter: std::sync::atomic::AtomicU64::new(0), originated: Mutex::new(Vec::new()), hung_up: Mutex::new(Vec::new()), } } /// Snapshot of every `originate` call the mock received, in arrival order. pub fn originated_calls(&self) -> Vec { lock_or_recover(&self.originated).clone() } /// Snapshot of every `correlation_id` passed to `hangup`, in arrival order. pub fn hung_up_calls(&self) -> Vec { lock_or_recover(&self.hung_up).clone() } } impl Default for MockCallControlClient { fn default() -> Self { Self::new() } } #[async_trait] impl CallControlClient for MockCallControlClient { async fn originate( &self, to_phone: &str, from_phone: &str, spend_token: Option, ) -> Result { let id = self .counter .fetch_add(1, std::sync::atomic::Ordering::Relaxed); // Twilio CallSids are "CA" + 32 hex chars. The mock mirrors that shape // so any code that parses the prefix/length exercises the real path. let call_sid = format!("CA{id:032x}"); let mut originated = lock_or_recover(&self.originated); originated.push(OriginateRecord { to: to_phone.to_owned(), from: from_phone.to_owned(), spend_token_present: spend_token.is_some(), }); Ok(call_sid) } async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> { let mut hung_up = lock_or_recover(&self.hung_up); // Idempotent in the mock (matching the live provider): recording the // same id twice is not an error, matching Twilio's `Status=completed` // semantics. A dedup check would be test-specific logic the real // provider doesn't expose. hung_up.push(correlation_id.to_owned()); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn originate_returns_call_sid_and_records_call() { let mock = MockCallControlClient::new(); let sid = mock .originate("+15551234567", "+15550000000", None) .await .expect("mock originate never errors"); // Twilio CallSid shape: "CA" + 32 hex chars = 34 chars total. assert!(sid.starts_with("CA"), "expected CA prefix, got {sid}"); assert_eq!(sid.len(), 34, "expected 34-char CallSid, got {sid}"); let calls = mock.originated_calls(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].to, "+15551234567"); assert_eq!(calls[0].from, "+15550000000"); assert!( !calls[0].spend_token_present, "step 5 passes None for spend_token" ); } #[tokio::test] async fn originate_produces_distinct_call_sids_per_call() { let mock = MockCallControlClient::new(); let sid_a = mock.originate("+1", "+2", None).await.unwrap(); let sid_b = mock.originate("+3", "+4", None).await.unwrap(); assert_ne!(sid_a, sid_b, "each originate must get a unique CallSid"); assert_eq!(mock.originated_calls().len(), 2); } #[tokio::test] async fn hangup_records_correlation_id_and_is_idempotent() { let mock = MockCallControlClient::new(); mock.hangup("CAdeadbeef").await.unwrap(); // Calling again with the same id is a no-op success (live provider is idempotent). mock.hangup("CAdeadbeef").await.unwrap(); mock.hangup("CAcafef00d").await.unwrap(); let hung = mock.hung_up_calls(); // The mock records every call (no dedup) — 2 for the repeat + 1 for the other. assert_eq!(hung.len(), 3); assert_eq!( hung.iter().filter(|s| s.as_str() == "CAdeadbeef").count(), 2 ); } #[tokio::test] async fn spend_token_some_is_recorded_as_present() { // Step 5 callers pass None, but the trait accepts Some(SpendToken) for // forward-compat with step 6. The mock records presence only. let mock = MockCallControlClient::new(); mock.originate("+1", "+2", Some(SpendToken)).await.unwrap(); let calls = mock.originated_calls(); assert_eq!(calls.len(), 1); assert!(calls[0].spend_token_present); } }