Skip to main content

sms_core/
lib.rs

1//! # SMS Core
2//!
3//! Core traits and types for the smskit multi-provider SMS abstraction.
4//!
5//! This crate provides the fundamental building blocks for SMS operations:
6//! - [`SmsClient`] trait for sending SMS messages
7//! - [`InboundWebhook`] trait for processing incoming webhooks
8//! - [`SmsRouter`] for dispatching sends to named providers
9//! - [`FallbackClient`] for try-in-order provider chaining
10//! - Common types for requests, responses, and errors
11//!
12//! ## Sending a message
13//!
14//! ```rust,ignore
15//! use sms_core::{SendRequest, SmsClient};
16//!
17//! // Any SMS provider implements SmsClient
18//! let response = client.send(SendRequest {
19//!     to: "+1234567890",
20//!     from: "+0987654321",
21//!     text: "Hello world!"
22//! }).await?;
23//! ```
24//!
25//! ## Owned requests for async contexts
26//!
27//! When you need to hold a request across `.await` points, use [`OwnedSendRequest`]:
28//!
29//! ```rust,ignore
30//! use sms_core::OwnedSendRequest;
31//!
32//! let req = OwnedSendRequest::new("+1234567890", "+0987654321", "Hello!");
33//! // Can be moved across .await boundaries freely
34//! let response = client.send(req.as_ref()).await?;
35//! ```
36//!
37//! ## Routing to named providers
38//!
39//! ```rust,ignore
40//! use sms_core::SmsRouter;
41//!
42//! let router = SmsRouter::new()
43//!     .with("plivo", plivo_client)
44//!     .with("aws-sns", sns_client);
45//!
46//! // Dispatch by name — callers don't need provider crate imports
47//! let response = router.send_via("plivo", SendRequest { .. }).await?;
48//! ```
49//!
50//! ## Fallback chaining
51//!
52//! ```rust,ignore
53//! use sms_core::FallbackClient;
54//!
55//! let client = FallbackClient::new(vec![primary_client, backup_client]);
56//! // Tries each provider in order; returns first success
57//! let response = client.send(SendRequest { .. }).await?;
58//! ```
59
60use async_trait::async_trait;
61use serde::{Deserialize, Serialize};
62use time::OffsetDateTime;
63use uuid::Uuid;
64
65use std::collections::HashMap;
66use std::sync::Arc;
67
68// ---------------------------------------------------------------------------
69// Errors
70// ---------------------------------------------------------------------------
71
72/// Errors that can occur during SMS send operations.
73///
74/// Each variant maps to a distinct failure class so callers can decide whether
75/// to retry, re-authenticate, fix their input, or escalate.
76#[derive(Debug, thiserror::Error)]
77pub enum SmsError {
78    /// An HTTP / network-level transport error (timeouts, DNS failures, etc.).
79    #[error("http error: {0}")]
80    Http(String),
81
82    /// The provider rejected the caller's credentials.
83    #[error("authentication error: {0}")]
84    Auth(String),
85
86    /// The request itself was malformed (bad phone number, empty text, etc.).
87    #[error("invalid request: {0}")]
88    Invalid(String),
89
90    /// The provider returned a business-logic error (insufficient balance,
91    /// blocked destination, etc.).
92    #[error("provider error: {0}")]
93    Provider(String),
94
95    /// Catch-all for errors that don't fit the categories above.
96    #[error("unexpected: {0}")]
97    Unexpected(String),
98}
99
100/// Errors specific to inbound webhook processing.
101#[derive(Debug, thiserror::Error)]
102pub enum WebhookError {
103    /// The provider name in the URL did not match any registered provider.
104    #[error("provider not found: {0}")]
105    ProviderNotFound(String),
106
107    /// Signature / HMAC verification on the incoming payload failed.
108    #[error("signature verification failed: {0}")]
109    VerificationFailed(String),
110
111    /// The payload could not be deserialized into the expected format.
112    #[error("parsing failed: {0}")]
113    ParseError(String),
114
115    /// A lower-level [`SmsError`] surfaced during webhook handling.
116    #[error("SMS processing error: {0}")]
117    SmsError(#[from] SmsError),
118}
119
120// ---------------------------------------------------------------------------
121// HTTP helpers
122// ---------------------------------------------------------------------------
123
124/// Minimal HTTP status codes used by [`WebhookResponse`].
125///
126/// Only the codes that the webhook pipeline actually produces are listed here;
127/// this is **not** a general-purpose HTTP status enum.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum HttpStatus {
130    /// 200 OK
131    Ok = 200,
132    /// 400 Bad Request
133    BadRequest = 400,
134    /// 401 Unauthorized
135    Unauthorized = 401,
136    /// 404 Not Found
137    NotFound = 404,
138    /// 500 Internal Server Error
139    InternalServerError = 500,
140}
141
142impl HttpStatus {
143    /// Return the numeric HTTP status code.
144    pub fn as_u16(self) -> u16 {
145        self as u16
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Send request / response
151// ---------------------------------------------------------------------------
152
153/// A borrowing SMS send request.
154///
155/// This is the type accepted by [`SmsClient::send`].  It borrows its string
156/// fields to avoid allocations on the hot path.  If you need an owned variant
157/// that can live across `.await` points, see [`OwnedSendRequest`].
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SendRequest<'a> {
160    /// E.164 destination phone number, e.g. `"+14155551234"`.
161    pub to: &'a str,
162    /// E.164 sender / originating number, or an alphanumeric sender ID.
163    pub from: &'a str,
164    /// The message body (plain text).
165    pub text: &'a str,
166}
167
168/// An owned variant of [`SendRequest`] for use in async contexts.
169///
170/// Holding `&str` references across `.await` points requires the referent to
171/// outlive the future, which creates lifetime friction when the strings come
172/// from `String` values.  `OwnedSendRequest` sidesteps this by owning the
173/// data and offering [`as_ref`](OwnedSendRequest::as_ref) to borrow a
174/// `SendRequest<'_>` at the call site.
175///
176/// # Examples
177///
178/// ```
179/// use sms_core::OwnedSendRequest;
180///
181/// let req = OwnedSendRequest::new("+14155551234", "+10005551234", "Hello!");
182/// assert_eq!(req.to, "+14155551234");
183///
184/// // Borrow as a SendRequest<'_> for SmsClient::send()
185/// let borrowed = req.as_ref();
186/// assert_eq!(borrowed.to, req.to);
187/// ```
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189pub struct OwnedSendRequest {
190    /// E.164 destination phone number.
191    pub to: String,
192    /// E.164 sender number or alphanumeric sender ID.
193    pub from: String,
194    /// The message body (plain text).
195    pub text: String,
196}
197
198impl OwnedSendRequest {
199    /// Create a new owned send request.
200    ///
201    /// All three parameters accept anything that converts to `String`,
202    /// so both `&str` and `String` work without explicit `.to_string()` calls.
203    pub fn new(
204        to: impl Into<String>,
205        from: impl Into<String>,
206        text: impl Into<String>,
207    ) -> Self {
208        Self {
209            to: to.into(),
210            from: from.into(),
211            text: text.into(),
212        }
213    }
214
215    /// Borrow this owned request as a [`SendRequest`] suitable for
216    /// [`SmsClient::send`].
217    pub fn as_ref(&self) -> SendRequest<'_> {
218        SendRequest {
219            to: &self.to,
220            from: &self.from,
221            text: &self.text,
222        }
223    }
224}
225
226impl<'a> From<SendRequest<'a>> for OwnedSendRequest {
227    fn from(req: SendRequest<'a>) -> Self {
228        Self {
229            to: req.to.to_owned(),
230            from: req.from.to_owned(),
231            text: req.text.to_owned(),
232        }
233    }
234}
235
236impl<'a> From<&'a OwnedSendRequest> for SendRequest<'a> {
237    fn from(req: &'a OwnedSendRequest) -> Self {
238        req.as_ref()
239    }
240}
241
242/// The response returned after a successful SMS send.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct SendResponse {
245    /// Provider-assigned message identifier.
246    pub id: String,
247    /// Name of the provider that handled the send, e.g. `"plivo"`.
248    pub provider: &'static str,
249    /// Raw JSON payload from the provider, useful for debugging / audit logs.
250    pub raw: serde_json::Value,
251}
252
253// ---------------------------------------------------------------------------
254// Inbound message
255// ---------------------------------------------------------------------------
256
257/// A provider-normalized inbound SMS message (e.g. a reply or MO message).
258///
259/// Every provider adapter converts its native format into this common struct
260/// so that downstream code never needs to know which provider delivered it.
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262pub struct InboundMessage {
263    /// Provider-assigned message ID (if available).
264    pub id: Option<String>,
265    /// Sender phone number.
266    pub from: String,
267    /// Destination phone number / short code.
268    pub to: String,
269    /// The message body.
270    pub text: String,
271    /// When the message was sent/received (if the provider supplies it).
272    pub timestamp: Option<OffsetDateTime>,
273    /// Which provider delivered this message, e.g. `"plivo"`.
274    pub provider: &'static str,
275    /// Raw provider payload for debugging.
276    pub raw: serde_json::Value,
277}
278
279/// Result of webhook processing, containing both the message and response info.
280#[derive(Debug, Clone)]
281pub struct WebhookResult {
282    /// The parsed inbound message.
283    pub message: InboundMessage,
284    /// HTTP status to return to the provider's webhook caller.
285    pub status: u16,
286}
287
288/// A framework-agnostic webhook HTTP response.
289///
290/// Framework adapters convert this into their native response type using the
291/// `ResponseConverter` trait defined in `sms-web-generic`.
292#[derive(Debug, Clone)]
293pub struct WebhookResponse {
294    /// HTTP status code to return.
295    pub status: HttpStatus,
296    /// Response body (JSON).
297    pub body: String,
298    /// The `Content-Type` header value.
299    pub content_type: String,
300}
301
302impl WebhookResponse {
303    /// Build a 200 OK response containing the serialized [`InboundMessage`].
304    pub fn success(message: InboundMessage) -> Self {
305        Self {
306            status: HttpStatus::Ok,
307            body: serde_json::to_string(&message).unwrap_or_else(|_| "{}".to_string()),
308            content_type: "application/json".to_string(),
309        }
310    }
311
312    /// Build an error response with the given status and human-readable message.
313    pub fn error(status: HttpStatus, message: &str) -> Self {
314        Self {
315            status,
316            body: format!(r#"{{"error": "{}"}}"#, message.replace('"', r#"\""#)),
317            content_type: "application/json".to_string(),
318        }
319    }
320}
321
322// ---------------------------------------------------------------------------
323// Core trait: SmsClient
324// ---------------------------------------------------------------------------
325
326/// The primary trait for sending SMS messages.
327///
328/// Every provider crate (`sms-plivo`, `sms-aws-sns`, `sms-twilio`) implements
329/// this trait.  Because the trait is **object-safe** (`Send + Sync`, no
330/// associated types), you can use `Box<dyn SmsClient>` or
331/// `Arc<dyn SmsClient>` for dynamic dispatch — which is exactly what
332/// [`SmsRouter`] and [`FallbackClient`] do under the hood.
333///
334/// # Example
335///
336/// ```rust,ignore
337/// use sms_core::{SmsClient, SendRequest};
338///
339/// async fn send_otp(client: &dyn SmsClient) -> Result<String, sms_core::SmsError> {
340///     let resp = client.send(SendRequest {
341///         to: "+14155551234",
342///         from: "+10005551234",
343///         text: "Your code is 123456",
344///     }).await?;
345///     Ok(resp.id)
346/// }
347/// ```
348#[async_trait]
349pub trait SmsClient: Send + Sync {
350    /// Send a single text SMS and return the provider's response.
351    async fn send(&self, req: SendRequest<'_>) -> Result<SendResponse, SmsError>;
352}
353
354// ---------------------------------------------------------------------------
355// Utility
356// ---------------------------------------------------------------------------
357
358/// Generate a random UUID v4 string, useful as a fallback message ID when the
359/// provider does not return one.
360pub fn fallback_id() -> String {
361    Uuid::new_v4().to_string()
362}
363
364/// Lightweight header representation (`Vec<(name, value)>`) that avoids
365/// coupling the core crate to any particular HTTP framework.
366pub type Headers = Vec<(String, String)>;
367
368// ---------------------------------------------------------------------------
369// Inbound webhook trait
370// ---------------------------------------------------------------------------
371
372/// Provider-agnostic interface for processing inbound SMS webhooks.
373///
374/// Each provider crate implements this trait on its client type, enabling the
375/// unified [`InboundRegistry`] and `WebhookProcessor` to handle any provider
376/// without compile-time knowledge of which ones are in use.
377#[async_trait]
378pub trait InboundWebhook: Send + Sync {
379    /// A stable, lowercase identifier for this provider (e.g. `"plivo"`,
380    /// `"twilio"`, `"aws-sns"`).  Used as the lookup key in
381    /// [`InboundRegistry`].
382    fn provider(&self) -> &'static str;
383
384    /// Parse the raw HTTP payload (headers + body) into a normalized
385    /// [`InboundMessage`].
386    fn parse_inbound(&self, headers: &Headers, body: &[u8]) -> Result<InboundMessage, SmsError>;
387
388    /// Verify the cryptographic signature on the incoming request.
389    ///
390    /// The default implementation is a no-op (always succeeds).  Providers
391    /// that support webhook signatures should override this.
392    fn verify(&self, _headers: &Headers, _body: &[u8]) -> Result<(), SmsError> {
393        Ok(())
394    }
395}
396
397// ---------------------------------------------------------------------------
398// InboundRegistry
399// ---------------------------------------------------------------------------
400
401/// A runtime registry that maps provider names to [`InboundWebhook`]
402/// implementations.
403///
404/// Used by the generic webhook processor to look up the right handler at
405/// request time without compile-time knowledge of which providers are
406/// registered.
407///
408/// # Example
409///
410/// ```rust,ignore
411/// use sms_core::InboundRegistry;
412/// use std::sync::Arc;
413///
414/// let registry = InboundRegistry::new()
415///     .with(Arc::new(plivo_client))
416///     .with(Arc::new(sns_client));
417///
418/// // Later, in a request handler:
419/// if let Some(hook) = registry.get("plivo") {
420///     let msg = hook.parse_inbound(&headers, &body)?;
421/// }
422/// ```
423#[derive(Default, Clone)]
424pub struct InboundRegistry {
425    map: Arc<HashMap<&'static str, Arc<dyn InboundWebhook>>>,
426}
427
428impl InboundRegistry {
429    /// Create an empty registry.
430    pub fn new() -> Self {
431        Self {
432            map: Arc::new(HashMap::new()),
433        }
434    }
435
436    /// Register a provider.  The provider's [`InboundWebhook::provider()`]
437    /// return value is used as the lookup key.
438    pub fn with(mut self, hook: Arc<dyn InboundWebhook>) -> Self {
439        let mut m = (*self.map).clone();
440        m.insert(hook.provider(), hook);
441        self.map = Arc::new(m);
442        self
443    }
444
445    /// Look up a registered provider by name.
446    pub fn get(&self, provider: &str) -> Option<Arc<dyn InboundWebhook>> {
447        self.map.get(provider).cloned()
448    }
449}
450
451// ---------------------------------------------------------------------------
452// SmsRouter — unified dispatch by provider name
453// ---------------------------------------------------------------------------
454
455/// Routes SMS sends to a named provider without requiring the caller to know
456/// about individual provider crate types.
457///
458/// This is the unified dispatch client that eliminates boilerplate in
459/// consumer code.  Instead of matching on a provider enum and constructing
460/// the right client, register each provider once and then call
461/// [`send_via`](SmsRouter::send_via) with a name.
462///
463/// `SmsRouter` also implements [`SmsClient`] itself, forwarding to a
464/// configured default provider.
465///
466/// # Example
467///
468/// ```rust,ignore
469/// use sms_core::{SmsRouter, SendRequest};
470///
471/// let router = SmsRouter::new()
472///     .with("plivo", plivo_client)
473///     .with("aws-sns", sns_client)
474///     .default_provider("plivo");
475///
476/// // Explicit dispatch:
477/// router.send_via("aws-sns", SendRequest { .. }).await?;
478///
479/// // Or use the SmsClient impl (goes to the default):
480/// router.send(SendRequest { .. }).await?;
481/// ```
482#[derive(Clone)]
483pub struct SmsRouter {
484    providers: Arc<HashMap<String, Arc<dyn SmsClient>>>,
485    default: Option<String>,
486}
487
488impl SmsRouter {
489    /// Create an empty router with no providers registered.
490    pub fn new() -> Self {
491        Self {
492            providers: Arc::new(HashMap::new()),
493            default: None,
494        }
495    }
496
497    /// Register a provider under the given name.
498    ///
499    /// If this is the first provider added it automatically becomes the
500    /// default (override with [`default_provider`](SmsRouter::default_provider)).
501    pub fn with(mut self, name: impl Into<String>, client: impl SmsClient + 'static) -> Self {
502        let name = name.into();
503        let mut m = (*self.providers).clone();
504        let first = m.is_empty();
505        m.insert(name.clone(), Arc::new(client));
506        self.providers = Arc::new(m);
507        if first {
508            self.default = Some(name);
509        }
510        self
511    }
512
513    /// Register a provider that is already behind an `Arc`.
514    pub fn with_arc(mut self, name: impl Into<String>, client: Arc<dyn SmsClient>) -> Self {
515        let name = name.into();
516        let mut m = (*self.providers).clone();
517        let first = m.is_empty();
518        m.insert(name.clone(), client);
519        self.providers = Arc::new(m);
520        if first {
521            self.default = Some(name);
522        }
523        self
524    }
525
526    /// Set which provider name is used when calling the [`SmsClient`] trait
527    /// impl directly (i.e. `router.send(..)`).
528    pub fn default_provider(mut self, name: impl Into<String>) -> Self {
529        self.default = Some(name.into());
530        self
531    }
532
533    /// Send a message through a specific named provider.
534    pub async fn send_via(
535        &self,
536        provider: &str,
537        req: SendRequest<'_>,
538    ) -> Result<SendResponse, SmsError> {
539        let client = self
540            .providers
541            .get(provider)
542            .ok_or_else(|| SmsError::Invalid(format!("unknown provider: {}", provider)))?;
543        client.send(req).await
544    }
545
546    /// Returns `true` if a provider with the given name is registered.
547    pub fn has_provider(&self, name: &str) -> bool {
548        self.providers.contains_key(name)
549    }
550
551    /// Returns the name of the current default provider, if any.
552    pub fn default_provider_name(&self) -> Option<&str> {
553        self.default.as_deref()
554    }
555}
556
557impl Default for SmsRouter {
558    fn default() -> Self {
559        Self::new()
560    }
561}
562
563#[async_trait]
564impl SmsClient for SmsRouter {
565    /// Send through the default provider.
566    ///
567    /// Returns [`SmsError::Invalid`] if no default has been set.
568    async fn send(&self, req: SendRequest<'_>) -> Result<SendResponse, SmsError> {
569        let name = self
570            .default
571            .as_deref()
572            .ok_or_else(|| SmsError::Invalid("no default provider configured".into()))?;
573        self.send_via(name, req).await
574    }
575}
576
577// ---------------------------------------------------------------------------
578// FallbackClient — try providers in order
579// ---------------------------------------------------------------------------
580
581/// An [`SmsClient`] that tries a list of providers in order, returning the
582/// first successful response.
583///
584/// This is the pattern every consumer re-invents for primary / backup
585/// failover.  `FallbackClient` encapsulates it once so you don't have to.
586///
587/// All errors from intermediate providers are collected; if every provider
588/// fails, the **last** error is returned (with a summary of all failures in
589/// the message).
590///
591/// # Example
592///
593/// ```rust,ignore
594/// use sms_core::FallbackClient;
595///
596/// let client = FallbackClient::new(vec![
597///     Arc::new(primary_client),
598///     Arc::new(backup_client),
599/// ]);
600///
601/// // Tries primary first; on failure, tries backup.
602/// let response = client.send(SendRequest { .. }).await?;
603/// ```
604pub struct FallbackClient {
605    providers: Vec<Arc<dyn SmsClient>>,
606}
607
608impl FallbackClient {
609    /// Create a new fallback chain.
610    ///
611    /// Providers are tried in the order given.  The list must contain at
612    /// least one provider.
613    pub fn new(providers: Vec<Arc<dyn SmsClient>>) -> Self {
614        assert!(!providers.is_empty(), "FallbackClient requires at least one provider");
615        Self { providers }
616    }
617
618    /// Convenience builder that wraps each client in an `Arc` for you.
619    pub fn from_clients(clients: Vec<Box<dyn SmsClient>>) -> Self {
620        let providers = clients.into_iter().map(Arc::from).collect();
621        Self { providers }
622    }
623
624    /// Returns how many providers are in the chain.
625    pub fn len(&self) -> usize {
626        self.providers.len()
627    }
628
629    /// Returns `true` if the chain is empty (should never happen after `new`).
630    pub fn is_empty(&self) -> bool {
631        self.providers.is_empty()
632    }
633}
634
635#[async_trait]
636impl SmsClient for FallbackClient {
637    /// Try each provider in order.  Returns the first success or, if all
638    /// fail, an error summarizing every failure.
639    async fn send(&self, req: SendRequest<'_>) -> Result<SendResponse, SmsError> {
640        let mut errors: Vec<String> = Vec::new();
641
642        for provider in &self.providers {
643            match provider.send(req.clone()).await {
644                Ok(resp) => return Ok(resp),
645                Err(e) => {
646                    errors.push(e.to_string());
647                }
648            }
649        }
650
651        // All providers failed — return a summary.
652        Err(SmsError::Provider(format!(
653            "all {} providers failed: [{}]",
654            self.providers.len(),
655            errors.join("; ")
656        )))
657    }
658}
659
660// ---------------------------------------------------------------------------
661// Tests
662// ---------------------------------------------------------------------------
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    // -- OwnedSendRequest tests --
669
670    #[test]
671    fn owned_send_request_new() {
672        let req = OwnedSendRequest::new("+14155551234", "+10005551234", "Hello");
673        assert_eq!(req.to, "+14155551234");
674        assert_eq!(req.from, "+10005551234");
675        assert_eq!(req.text, "Hello");
676    }
677
678    #[test]
679    fn owned_send_request_from_string_values() {
680        let to = String::from("+14155551234");
681        let from = String::from("+10005551234");
682        let text = String::from("Hello");
683        let req = OwnedSendRequest::new(to, from, text);
684        assert_eq!(req.to, "+14155551234");
685    }
686
687    #[test]
688    fn owned_send_request_as_ref_roundtrip() {
689        let owned = OwnedSendRequest::new("+1", "+2", "hi");
690        let borrowed = owned.as_ref();
691        assert_eq!(borrowed.to, "+1");
692        assert_eq!(borrowed.from, "+2");
693        assert_eq!(borrowed.text, "hi");
694    }
695
696    #[test]
697    fn owned_send_request_from_send_request() {
698        let borrowed = SendRequest {
699            to: "+1",
700            from: "+2",
701            text: "msg",
702        };
703        let owned: OwnedSendRequest = borrowed.into();
704        assert_eq!(owned.to, "+1");
705        assert_eq!(owned.text, "msg");
706    }
707
708    #[test]
709    fn send_request_from_owned_ref() {
710        let owned = OwnedSendRequest::new("+1", "+2", "hi");
711        let borrowed: SendRequest<'_> = (&owned).into();
712        assert_eq!(borrowed.to, "+1");
713    }
714
715    #[test]
716    fn owned_send_request_serde_roundtrip() {
717        let req = OwnedSendRequest::new("+1", "+2", "test");
718        let json = serde_json::to_string(&req).unwrap();
719        let deser: OwnedSendRequest = serde_json::from_str(&json).unwrap();
720        assert_eq!(req, deser);
721    }
722
723    // -- HttpStatus tests --
724
725    #[test]
726    fn http_status_values() {
727        assert_eq!(HttpStatus::Ok.as_u16(), 200);
728        assert_eq!(HttpStatus::BadRequest.as_u16(), 400);
729        assert_eq!(HttpStatus::Unauthorized.as_u16(), 401);
730        assert_eq!(HttpStatus::NotFound.as_u16(), 404);
731        assert_eq!(HttpStatus::InternalServerError.as_u16(), 500);
732    }
733
734    // -- WebhookResponse tests --
735
736    #[test]
737    fn webhook_response_success_serializes_message() {
738        let msg = InboundMessage {
739            id: Some("msg-1".into()),
740            from: "+1111".into(),
741            to: "+2222".into(),
742            text: "hi".into(),
743            timestamp: None,
744            provider: "test",
745            raw: serde_json::json!({}),
746        };
747        let resp = WebhookResponse::success(msg);
748        assert_eq!(resp.status, HttpStatus::Ok);
749        assert!(resp.body.contains("msg-1"));
750        assert_eq!(resp.content_type, "application/json");
751    }
752
753    #[test]
754    fn webhook_response_error_escapes_quotes() {
755        let resp = WebhookResponse::error(HttpStatus::BadRequest, r#"bad "input""#);
756        assert!(resp.body.contains(r#"bad \"input\""#));
757    }
758
759    // -- InboundRegistry tests --
760
761    #[test]
762    fn inbound_registry_get_returns_none_for_unknown() {
763        let reg = InboundRegistry::new();
764        assert!(reg.get("nonexistent").is_none());
765    }
766
767    // -- SmsError display --
768
769    #[test]
770    fn sms_error_display() {
771        let e = SmsError::Http("timeout".into());
772        assert_eq!(e.to_string(), "http error: timeout");
773
774        let e = SmsError::Auth("bad token".into());
775        assert_eq!(e.to_string(), "authentication error: bad token");
776    }
777
778    // -- WebhookError from SmsError --
779
780    #[test]
781    fn webhook_error_from_sms_error() {
782        let sms_err = SmsError::Provider("oops".into());
783        let wh_err: WebhookError = sms_err.into();
784        assert!(wh_err.to_string().contains("oops"));
785    }
786
787    // -- fallback_id --
788
789    #[test]
790    fn fallback_id_is_valid_uuid() {
791        let id = fallback_id();
792        assert!(uuid::Uuid::parse_str(&id).is_ok());
793    }
794
795    // -- SmsRouter tests --
796
797    /// A mock client that always succeeds.
798    struct MockClient {
799        provider_name: &'static str,
800    }
801
802    #[async_trait]
803    impl SmsClient for MockClient {
804        async fn send(&self, _req: SendRequest<'_>) -> Result<SendResponse, SmsError> {
805            Ok(SendResponse {
806                id: "mock-id".into(),
807                provider: self.provider_name,
808                raw: serde_json::json!({"mock": true}),
809            })
810        }
811    }
812
813    /// A mock client that always fails.
814    struct FailingClient {
815        message: String,
816    }
817
818    #[async_trait]
819    impl SmsClient for FailingClient {
820        async fn send(&self, _req: SendRequest<'_>) -> Result<SendResponse, SmsError> {
821            Err(SmsError::Provider(self.message.clone()))
822        }
823    }
824
825    fn test_request() -> SendRequest<'static> {
826        SendRequest {
827            to: "+14155551234",
828            from: "+10005551234",
829            text: "test",
830        }
831    }
832
833    #[tokio::test]
834    async fn router_send_via_dispatches_correctly() {
835        let router = SmsRouter::new()
836            .with("alpha", MockClient { provider_name: "alpha" })
837            .with("beta", MockClient { provider_name: "beta" });
838
839        let resp = router.send_via("beta", test_request()).await.unwrap();
840        assert_eq!(resp.provider, "beta");
841    }
842
843    #[tokio::test]
844    async fn router_send_via_unknown_provider_errors() {
845        let router = SmsRouter::new()
846            .with("alpha", MockClient { provider_name: "alpha" });
847
848        let err = router.send_via("nope", test_request()).await.unwrap_err();
849        assert!(err.to_string().contains("unknown provider"));
850    }
851
852    #[tokio::test]
853    async fn router_default_is_first_registered() {
854        let router = SmsRouter::new()
855            .with("first", MockClient { provider_name: "first" })
856            .with("second", MockClient { provider_name: "second" });
857
858        assert_eq!(router.default_provider_name(), Some("first"));
859        let resp = router.send(test_request()).await.unwrap();
860        assert_eq!(resp.provider, "first");
861    }
862
863    #[tokio::test]
864    async fn router_explicit_default_override() {
865        let router = SmsRouter::new()
866            .with("first", MockClient { provider_name: "first" })
867            .with("second", MockClient { provider_name: "second" })
868            .default_provider("second");
869
870        let resp = router.send(test_request()).await.unwrap();
871        assert_eq!(resp.provider, "second");
872    }
873
874    #[tokio::test]
875    async fn router_no_default_errors() {
876        let router = SmsRouter::new();
877        let err = router.send(test_request()).await.unwrap_err();
878        assert!(err.to_string().contains("no default provider"));
879    }
880
881    #[test]
882    fn router_has_provider() {
883        let router = SmsRouter::new()
884            .with("plivo", MockClient { provider_name: "plivo" });
885        assert!(router.has_provider("plivo"));
886        assert!(!router.has_provider("twilio"));
887    }
888
889    // -- FallbackClient tests --
890
891    #[tokio::test]
892    async fn fallback_returns_first_success() {
893        let client = FallbackClient::new(vec![
894            Arc::new(MockClient { provider_name: "primary" }),
895            Arc::new(MockClient { provider_name: "backup" }),
896        ]);
897        let resp = client.send(test_request()).await.unwrap();
898        assert_eq!(resp.provider, "primary");
899    }
900
901    #[tokio::test]
902    async fn fallback_skips_failing_provider() {
903        let client = FallbackClient::new(vec![
904            Arc::new(FailingClient { message: "down".into() }),
905            Arc::new(MockClient { provider_name: "backup" }),
906        ]);
907        let resp = client.send(test_request()).await.unwrap();
908        assert_eq!(resp.provider, "backup");
909    }
910
911    #[tokio::test]
912    async fn fallback_all_fail_returns_summary() {
913        let client = FallbackClient::new(vec![
914            Arc::new(FailingClient { message: "err-a".into() }),
915            Arc::new(FailingClient { message: "err-b".into() }),
916        ]);
917        let err = client.send(test_request()).await.unwrap_err();
918        let msg = err.to_string();
919        assert!(msg.contains("all 2 providers failed"));
920        assert!(msg.contains("err-a"));
921        assert!(msg.contains("err-b"));
922    }
923
924    #[test]
925    fn fallback_len() {
926        let client = FallbackClient::new(vec![
927            Arc::new(MockClient { provider_name: "a" }),
928            Arc::new(MockClient { provider_name: "b" }),
929        ]);
930        assert_eq!(client.len(), 2);
931        assert!(!client.is_empty());
932    }
933
934    #[test]
935    #[should_panic(expected = "at least one provider")]
936    fn fallback_empty_panics() {
937        FallbackClient::new(vec![]);
938    }
939}