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}