use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OtpChannel {
Email,
Sms,
}
impl OtpChannel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Email => "email",
Self::Sms => "sms",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OtpContact {
pub email: Option<String>,
pub phone: Option<String>,
}
impl OtpContact {
#[must_use]
pub fn is_empty(&self) -> bool {
self.email.is_none() && self.phone.is_none()
}
#[must_use]
pub fn available_channels(&self) -> Vec<OtpChannel> {
let mut channels = Vec::new();
if self.email.is_some() {
channels.push(OtpChannel::Email);
}
if self.phone.is_some() {
channels.push(OtpChannel::Sms);
}
channels
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OtpDelivery {
pub channel: OtpChannel,
pub masked_destination: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OtpError {
InvalidCode,
MaxAttempts,
NotFound,
Expired,
}
impl OtpError {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::InvalidCode => "INVALID_CODE",
Self::MaxAttempts => "MAX_ATTEMPTS",
Self::NotFound => "NOT_FOUND",
Self::Expired => "EXPIRED",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OtpVerifyOutcome {
Verified,
Invalid {
attempts_remaining: u32,
error: Option<OtpError>,
message: String,
},
}
#[async_trait]
pub trait OtpService: Send + Sync {
async fn send_otp(&self, session_id: &str, contact: &OtpContact)
-> anyhow::Result<OtpDelivery>;
async fn verify_otp(&self, session_id: &str, code: &str) -> OtpVerifyOutcome;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_wire_strings() {
assert_eq!(OtpChannel::Email.as_str(), "email");
assert_eq!(OtpChannel::Sms.as_str(), "sms");
}
#[test]
fn error_wire_strings() {
assert_eq!(OtpError::InvalidCode.as_str(), "INVALID_CODE");
assert_eq!(OtpError::MaxAttempts.as_str(), "MAX_ATTEMPTS");
assert_eq!(OtpError::NotFound.as_str(), "NOT_FOUND");
assert_eq!(OtpError::Expired.as_str(), "EXPIRED");
}
#[test]
fn empty_contact_offers_no_channels() {
let contact = OtpContact::default();
assert!(contact.is_empty());
assert!(contact.available_channels().is_empty());
}
#[test]
fn email_only_contact_offers_email() {
let contact = OtpContact {
email: Some("a@example.com".into()),
phone: None,
};
assert!(!contact.is_empty());
assert_eq!(contact.available_channels(), vec![OtpChannel::Email]);
}
#[test]
fn both_contacts_offer_email_then_sms() {
let contact = OtpContact {
email: Some("a@example.com".into()),
phone: Some("+15551234567".into()),
};
assert_eq!(
contact.available_channels(),
vec![OtpChannel::Email, OtpChannel::Sms]
);
}
#[test]
fn phone_only_contact_offers_sms() {
let contact = OtpContact {
email: None,
phone: Some("+15551234567".into()),
};
assert_eq!(contact.available_channels(), vec![OtpChannel::Sms]);
}
}