Files
rutster/crates/rutster-trunk/src/provider/mock.rs
Aaron D. Lee 0320ab5ce0 feat(trunk): CallControlClient trait + MockCallControlClient + TwilioCredentials (slice-5 T2)
The provider call-control seam (green zone, ADR-0008). The trait locks the
boundary so the next provider (Telnyx, etc.) is an implementation, not a
refactor. MockCallControlClient is the CI test double; the live
TwilioCallControlClient (T6) lives behind the twilio-live feature flag.

TwilioCredentials lives ONLY in crates/rutster-trunk/ -- never re-exported
through the workspace (ADR-0009 -- provider credentials never reach the
brain). Its Debug impl is hand-written (NOT derived) so the auth_token
renders as <redacted>, never leaking into tracing/panic output.

Option<SpendToken> on originate is the pre-paved seam for spearhead step-6
(spend cap); this slice passes None everywhere. The signature is locked so
step 6 is additive, not a refactor.

T2 of slice-5. lib.rs gains `pub mod provider;` -- stacked-branches carve-out
(rebase-merge, not squash) per AGENTS.md Git workflow; dev-c rebases forward
for the FOB-side pub mod declarations (g711/twilio_media_streams/session/
loop_driver).

Signed-off-by: Aaron D. Lee <himself@adlee.work>
2026-07-05 16:02:19 +00:00

205 lines
8.2 KiB
Rust

//! # 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<Box<...>>`); 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<T>(lock: &Mutex<T>) -> 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<u64>` because an atomic increment needs no guard (no
/// critical section) — cheaper and lock-poison-free.
counter: std::sync::atomic::AtomicU64,
originated: Mutex<Vec<OriginateRecord>>,
hung_up: Mutex<Vec<String>>,
}
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<OriginateRecord> {
lock_or_recover(&self.originated).clone()
}
/// Snapshot of every `correlation_id` passed to `hangup`, in arrival order.
pub fn hung_up_calls(&self) -> Vec<String> {
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<SpendToken>,
) -> Result<String, CallControlError> {
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);
}
}