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>
205 lines
8.2 KiB
Rust
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);
|
|
}
|
|
}
|