use parking_lot::Mutex;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum NotifyError {
#[error("通知字段缺失: {0}")]
MissingField(String),
#[error("通知发送失败: {0}")]
SendFailed(String),
#[error("HTTP 传输失败: {0}")]
HttpTransport(String),
#[error("序列化失败: {0}")]
Serialize(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(u8)]
pub enum NotifyLevel {
#[default]
Info = 0,
Warning = 1,
Error = 2,
Critical = 3,
}
impl NotifyLevel {
pub fn slack_color(self) -> &'static str {
match self {
Self::Info => "#36a64f",
Self::Warning => "#ffcc00",
Self::Error => "#ff0000",
Self::Critical => "#b22222",
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
Self::Critical => "critical",
}
}
}
impl std::fmt::Display for NotifyLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for NotifyLevel {
type Err = NotifyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"info" => Ok(Self::Info),
"warning" | "warn" => Ok(Self::Warning),
"error" | "err" => Ok(Self::Error),
"critical" | "crit" => Ok(Self::Critical),
other => Err(NotifyError::MissingField(format!("未知通知级别: {other}"))),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Notification {
pub channel: String,
pub title: String,
pub content: String,
pub level: NotifyLevel,
pub metadata: serde_json::Value,
}
impl Notification {
pub fn new() -> Self {
Self::default()
}
pub fn channel(mut self, channel: impl Into<String>) -> Self {
self.channel = channel.into();
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
pub fn level(mut self, level: NotifyLevel) -> Self {
self.level = level;
self
}
pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = metadata;
self
}
pub fn validate(&self) -> Result<(), NotifyError> {
if self.channel.is_empty() {
return Err(NotifyError::MissingField("channel".into()));
}
if self.title.is_empty() {
return Err(NotifyError::MissingField("title".into()));
}
if self.content.is_empty() {
return Err(NotifyError::MissingField("content".into()));
}
Ok(())
}
}
pub trait Notifier: Send + Sync {
fn send(&self, notification: Notification) -> Result<(), NotifyError>;
}
#[derive(Debug, Clone, Default)]
pub struct MemoryNotifier {
sent: Arc<Mutex<Vec<Notification>>>,
}
impl MemoryNotifier {
pub fn new() -> Self {
Self::default()
}
pub fn count(&self) -> usize {
self.sent.lock().len()
}
pub fn all(&self) -> Vec<Notification> {
self.sent.lock().clone()
}
pub fn last(&self) -> Option<Notification> {
self.sent.lock().last().cloned()
}
pub fn clear(&self) {
self.sent.lock().clear();
}
}
impl Notifier for MemoryNotifier {
fn send(&self, notification: Notification) -> Result<(), NotifyError> {
notification.validate()?;
self.sent.lock().push(notification);
Ok(())
}
}
pub trait HttpTransport: Send + Sync {
fn post_json(&self, url: &str, body: &str) -> Result<(), NotifyError>;
}
#[derive(Debug, Default)]
pub struct MemoryHttpTransport {
requests: Mutex<Vec<(String, String)>>,
}
impl MemoryHttpTransport {
pub fn new() -> Self {
Self::default()
}
pub fn count(&self) -> usize {
self.requests.lock().len()
}
pub fn all(&self) -> Vec<(String, String)> {
self.requests.lock().clone()
}
pub fn last(&self) -> Option<(String, String)> {
self.requests.lock().last().cloned()
}
pub fn clear(&self) {
self.requests.lock().clear();
}
}
impl HttpTransport for MemoryHttpTransport {
fn post_json(&self, url: &str, body: &str) -> Result<(), NotifyError> {
self.requests
.lock()
.push((url.to_string(), body.to_string()));
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SlackConfig {
pub webhook_url: String,
pub channel: Option<String>,
pub username: Option<String>,
pub icon_emoji: Option<String>,
}
impl SlackConfig {
pub fn new(webhook_url: impl Into<String>) -> Self {
Self {
webhook_url: webhook_url.into(),
channel: None,
username: None,
icon_emoji: None,
}
}
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
self.channel = Some(channel.into());
self
}
pub fn with_username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
pub fn with_icon_emoji(mut self, icon_emoji: impl Into<String>) -> Self {
self.icon_emoji = Some(icon_emoji.into());
self
}
}
#[derive(Debug, Clone, serde::Serialize)]
struct SlackPayload {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
channel: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon_emoji: Option<String>,
attachments: Vec<SlackAttachment>,
}
#[derive(Debug, Clone, serde::Serialize)]
struct SlackAttachment {
color: String,
title: String,
text: String,
ts: i64,
}
pub struct SlackNotifier {
config: SlackConfig,
transport: Arc<dyn HttpTransport>,
}
impl SlackNotifier {
pub fn new(config: SlackConfig, transport: Arc<dyn HttpTransport>) -> Self {
Self { config, transport }
}
fn build_payload(&self, notification: &Notification) -> Result<String, NotifyError> {
let ts = chrono::Utc::now().timestamp();
let payload = SlackPayload {
text: format!(
"[{}] {} — {}",
notification.level.as_str().to_uppercase(),
notification.title,
notification.content
),
channel: self.config.channel.clone(),
username: self.config.username.clone(),
icon_emoji: self.config.icon_emoji.clone(),
attachments: vec![SlackAttachment {
color: notification.level.slack_color().to_string(),
title: notification.title.clone(),
text: notification.content.clone(),
ts,
}],
};
serde_json::to_string(&payload).map_err(|e| NotifyError::Serialize(e.to_string()))
}
}
impl Notifier for SlackNotifier {
fn send(&self, notification: Notification) -> Result<(), NotifyError> {
notification.validate()?;
if self.config.webhook_url.is_empty() {
return Err(NotifyError::MissingField("webhook_url".into()));
}
let body = self.build_payload(¬ification)?;
self.transport
.post_json(&self.config.webhook_url, &body)
.map_err(|e| NotifyError::HttpTransport(format!("Slack Webhook 发送失败: {e}")))?;
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct SmsMessage {
pub phone: String,
pub template_id: String,
pub template_params: Vec<String>,
pub sign_name: Option<String>,
pub metadata: serde_json::Value,
}
impl SmsMessage {
pub fn new() -> Self {
Self::default()
}
pub fn phone(mut self, phone: impl Into<String>) -> Self {
self.phone = phone.into();
self
}
pub fn template_id(mut self, template_id: impl Into<String>) -> Self {
self.template_id = template_id.into();
self
}
pub fn template_param(mut self, param: impl Into<String>) -> Self {
self.template_params.push(param.into());
self
}
pub fn template_params(mut self, params: Vec<String>) -> Self {
self.template_params = params;
self
}
pub fn sign_name(mut self, sign_name: impl Into<String>) -> Self {
self.sign_name = Some(sign_name.into());
self
}
pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = metadata;
self
}
pub fn validate(&self) -> Result<(), NotifyError> {
if self.phone.is_empty() {
return Err(NotifyError::MissingField("phone".into()));
}
if self.template_id.is_empty() {
return Err(NotifyError::MissingField("template_id".into()));
}
Ok(())
}
}
pub trait SmsNotifier: Send + Sync {
fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError>;
}
#[derive(Debug, Clone, Default)]
pub struct MemorySmsNotifier {
sent: Arc<Mutex<Vec<SmsMessage>>>,
}
impl MemorySmsNotifier {
pub fn new() -> Self {
Self::default()
}
pub fn count(&self) -> usize {
self.sent.lock().len()
}
pub fn all(&self) -> Vec<SmsMessage> {
self.sent.lock().clone()
}
pub fn last(&self) -> Option<SmsMessage> {
self.sent.lock().last().cloned()
}
pub fn clear(&self) {
self.sent.lock().clear();
}
}
impl SmsNotifier for MemorySmsNotifier {
fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError> {
message.validate()?;
self.sent.lock().push(message);
Ok(())
}
}
#[derive(Clone)]
pub struct TencentSmsConfig {
pub secret_id: String,
pub secret_key: String,
pub app_id: String,
pub default_sign_name: Option<String>,
pub region: String,
pub endpoint: String,
}
impl std::fmt::Debug for TencentSmsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TencentSmsConfig")
.field("secret_id", &"***REDACTED***")
.field("secret_key", &"***REDACTED***")
.field("app_id", &self.app_id)
.field("default_sign_name", &self.default_sign_name)
.field("region", &self.region)
.field("endpoint", &self.endpoint)
.finish()
}
}
impl TencentSmsConfig {
pub fn new(
secret_id: impl Into<String>,
secret_key: impl Into<String>,
app_id: impl Into<String>,
) -> Self {
Self {
secret_id: secret_id.into(),
secret_key: secret_key.into(),
app_id: app_id.into(),
default_sign_name: None,
region: "ap-guangzhou".to_string(),
endpoint: "sms.tencentcloudapi.com".to_string(),
}
}
pub fn with_default_sign_name(mut self, sign_name: impl Into<String>) -> Self {
self.default_sign_name = Some(sign_name.into());
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = region.into();
self
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
}
#[derive(Debug, Clone, serde::Serialize)]
struct TencentSmsPayload {
#[serde(rename = "PhoneNumbers")]
phone_numbers: Vec<String>,
#[serde(rename = "TemplateId")]
template_id: String,
#[serde(rename = "TemplateParamSet")]
template_param_set: Vec<String>,
#[serde(rename = "SmsSdkAppId")]
sms_sdk_app_id: String,
#[serde(rename = "SignName", skip_serializing_if = "Option::is_none")]
sign_name: Option<String>,
}
pub struct TencentSmsNotifier {
config: TencentSmsConfig,
transport: Arc<dyn HttpTransport>,
}
impl TencentSmsNotifier {
pub fn new(config: TencentSmsConfig, transport: Arc<dyn HttpTransport>) -> Self {
Self { config, transport }
}
fn build_payload(&self, message: &SmsMessage) -> Result<String, NotifyError> {
let sign_name = message
.sign_name
.clone()
.or_else(|| self.config.default_sign_name.clone());
let payload = TencentSmsPayload {
phone_numbers: vec![message.phone.clone()],
template_id: message.template_id.clone(),
template_param_set: message.template_params.clone(),
sms_sdk_app_id: self.config.app_id.clone(),
sign_name,
};
serde_json::to_string(&payload).map_err(|e| NotifyError::Serialize(e.to_string()))
}
}
impl SmsNotifier for TencentSmsNotifier {
fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError> {
message.validate()?;
if self.config.secret_id.is_empty() {
return Err(NotifyError::MissingField("secret_id".into()));
}
if self.config.secret_key.is_empty() {
return Err(NotifyError::MissingField("secret_key".into()));
}
if self.config.app_id.is_empty() {
return Err(NotifyError::MissingField("app_id".into()));
}
let body = self.build_payload(&message)?;
let url = format!("https://{}/", self.config.endpoint);
self.transport
.post_json(&url, &body)
.map_err(|e| NotifyError::HttpTransport(format!("腾讯云短信发送失败: {e}")))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tencent_sms_config_debug_redacted() {
let config = TencentSmsConfig {
secret_id: "AKIDxxx-test-secret-id".to_string(),
secret_key: "SKxxx-test-secret-key".to_string(),
app_id: "1400000000".to_string(),
default_sign_name: Some("鲜视达科技".to_string()),
region: "ap-guangzhou".to_string(),
endpoint: "sms.tencentcloudapi.com".to_string(),
};
let debug_output = format!("{:?}", config);
assert!(
debug_output.contains("***REDACTED***"),
"Debug 输出应含脱敏占位符"
);
assert!(
!debug_output.contains("AKIDxxx-test-secret-id"),
"Debug 输出不应含真实 secret_id"
);
assert!(
!debug_output.contains("SKxxx-test-secret-key"),
"Debug 输出不应含真实 secret_key"
);
}
#[test]
fn test_tencent_sms_config_field_access_and_clone() {
let config = TencentSmsConfig {
secret_id: "AKIDxxx".to_string(),
secret_key: "SKxxx".to_string(),
app_id: "1400000000".to_string(),
default_sign_name: None,
region: "ap-guangzhou".to_string(),
endpoint: "sms.tencentcloudapi.com".to_string(),
};
assert_eq!(config.secret_id, "AKIDxxx");
assert_eq!(config.secret_key, "SKxxx");
assert!(!config.secret_id.is_empty());
let cloned = config.clone();
assert_eq!(cloned.secret_id, "AKIDxxx");
assert_eq!(cloned.secret_key, "SKxxx");
}
#[test]
fn test_notify_level_default() {
let level = NotifyLevel::default();
assert_eq!(level, NotifyLevel::Info);
}
#[test]
fn test_notify_level_slack_color() {
assert_eq!(NotifyLevel::Info.slack_color(), "#36a64f");
assert_eq!(NotifyLevel::Warning.slack_color(), "#ffcc00");
assert_eq!(NotifyLevel::Error.slack_color(), "#ff0000");
assert_eq!(NotifyLevel::Critical.slack_color(), "#b22222");
}
#[test]
fn test_notify_level_as_str() {
assert_eq!(NotifyLevel::Info.as_str(), "info");
assert_eq!(NotifyLevel::Warning.as_str(), "warning");
assert_eq!(NotifyLevel::Error.as_str(), "error");
assert_eq!(NotifyLevel::Critical.as_str(), "critical");
}
#[test]
fn test_notify_level_display() {
assert_eq!(format!("{}", NotifyLevel::Info), "info");
assert_eq!(format!("{}", NotifyLevel::Warning), "warning");
assert_eq!(format!("{}", NotifyLevel::Error), "error");
assert_eq!(format!("{}", NotifyLevel::Critical), "critical");
}
#[test]
fn test_notify_level_from_str() {
assert_eq!("info".parse::<NotifyLevel>().unwrap(), NotifyLevel::Info);
assert_eq!(
"warning".parse::<NotifyLevel>().unwrap(),
NotifyLevel::Warning
);
assert_eq!("error".parse::<NotifyLevel>().unwrap(), NotifyLevel::Error);
assert_eq!(
"critical".parse::<NotifyLevel>().unwrap(),
NotifyLevel::Critical
);
assert_eq!("warn".parse::<NotifyLevel>().unwrap(), NotifyLevel::Warning);
assert_eq!("err".parse::<NotifyLevel>().unwrap(), NotifyLevel::Error);
assert_eq!(
"crit".parse::<NotifyLevel>().unwrap(),
NotifyLevel::Critical
);
assert_eq!("INFO".parse::<NotifyLevel>().unwrap(), NotifyLevel::Info);
assert_eq!(
"Critical".parse::<NotifyLevel>().unwrap(),
NotifyLevel::Critical
);
assert!("unknown".parse::<NotifyLevel>().is_err());
}
#[test]
fn test_notification_builder() {
let notification = Notification::new()
.channel("slack")
.title("部署完成")
.content("服务已成功部署到生产环境")
.level(NotifyLevel::Info)
.metadata(serde_json::json!({"env": "prod"}));
assert_eq!(notification.channel, "slack");
assert_eq!(notification.title, "部署完成");
assert_eq!(notification.content, "服务已成功部署到生产环境");
assert_eq!(notification.level, NotifyLevel::Info);
assert_eq!(notification.metadata["env"], "prod");
}
#[test]
fn test_notification_default() {
let notification = Notification::default();
assert!(notification.channel.is_empty());
assert!(notification.title.is_empty());
assert!(notification.content.is_empty());
assert_eq!(notification.level, NotifyLevel::Info);
assert!(notification.metadata.is_null());
}
#[test]
fn test_notification_validate_ok() {
let notification = Notification::new()
.channel("slack")
.title("标题")
.content("内容");
assert!(notification.validate().is_ok());
}
#[test]
fn test_notification_validate_missing_channel() {
let notification = Notification::new().title("标题").content("内容");
let err = notification.validate().unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "channel"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_notification_validate_missing_title() {
let notification = Notification::new().channel("slack").content("内容");
let err = notification.validate().unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "title"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_notification_validate_missing_content() {
let notification = Notification::new().channel("slack").title("标题");
let err = notification.validate().unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "content"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_memory_notifier_send() {
let notifier = MemoryNotifier::new();
let notification = Notification::new()
.channel("slack")
.title("标题")
.content("内容")
.level(NotifyLevel::Warning);
notifier.send(notification).unwrap();
assert_eq!(notifier.count(), 1);
let last = notifier.last().unwrap();
assert_eq!(last.channel, "slack");
assert_eq!(last.title, "标题");
assert_eq!(last.content, "内容");
assert_eq!(last.level, NotifyLevel::Warning);
}
#[test]
fn test_memory_notifier_send_multiple() {
let notifier = MemoryNotifier::new();
for i in 0..5 {
notifier
.send(
Notification::new()
.channel("slack")
.title(format!("标题{i}"))
.content("内容"),
)
.unwrap();
}
assert_eq!(notifier.count(), 5);
let all = notifier.all();
assert_eq!(all[0].title, "标题0");
assert_eq!(all[4].title, "标题4");
}
#[test]
fn test_memory_notifier_send_invalid() {
let notifier = MemoryNotifier::new();
let notification = Notification::new().title("标题").content("内容");
assert!(notifier.send(notification).is_err());
assert_eq!(notifier.count(), 0);
}
#[test]
fn test_memory_notifier_clear() {
let notifier = MemoryNotifier::new();
notifier
.send(
Notification::new()
.channel("slack")
.title("标题")
.content("内容"),
)
.unwrap();
assert_eq!(notifier.count(), 1);
notifier.clear();
assert_eq!(notifier.count(), 0);
assert!(notifier.last().is_none());
}
#[test]
fn test_memory_http_transport_post_json() {
let transport = MemoryHttpTransport::new();
transport
.post_json("https://hooks.slack.com/services/xxx", r#"{"text":"hi"}"#)
.unwrap();
assert_eq!(transport.count(), 1);
let (url, body) = transport.last().unwrap();
assert_eq!(url, "https://hooks.slack.com/services/xxx");
assert_eq!(body, r#"{"text":"hi"}"#);
}
#[test]
fn test_memory_http_transport_clear() {
let transport = MemoryHttpTransport::new();
transport.post_json("url", "body").unwrap();
assert_eq!(transport.count(), 1);
transport.clear();
assert_eq!(transport.count(), 0);
}
#[test]
fn test_slack_config_builder() {
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
.with_channel("#alerts")
.with_username("SZ-Rust Bot")
.with_icon_emoji(":alarm_clock:");
assert_eq!(config.webhook_url, "https://hooks.slack.com/services/T/B/X");
assert_eq!(config.channel.as_deref(), Some("#alerts"));
assert_eq!(config.username.as_deref(), Some("SZ-Rust Bot"));
assert_eq!(config.icon_emoji.as_deref(), Some(":alarm_clock:"));
}
#[test]
fn test_slack_config_minimal() {
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
assert_eq!(config.webhook_url, "https://hooks.slack.com/services/T/B/X");
assert!(config.channel.is_none());
assert!(config.username.is_none());
assert!(config.icon_emoji.is_none());
}
#[test]
fn test_slack_notifier_send() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
.with_channel("#alerts")
.with_username("SZ-Rust Bot")
.with_icon_emoji(":alarm_clock:");
let notifier = SlackNotifier::new(config, transport.clone());
let notification = Notification::new()
.channel("slack")
.title("部署完成")
.content("服务已成功部署到生产环境")
.level(NotifyLevel::Info);
notifier.send(notification).unwrap();
assert_eq!(transport.count(), 1);
let (url, body) = transport.last().unwrap();
assert_eq!(url, "https://hooks.slack.com/services/T/B/X");
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(payload["text"].as_str().unwrap().contains("部署完成"));
assert!(payload["text"]
.as_str()
.unwrap()
.contains("服务已成功部署到生产环境"));
assert!(payload["text"].as_str().unwrap().contains("[INFO]"));
assert_eq!(payload["channel"], "#alerts");
assert_eq!(payload["username"], "SZ-Rust Bot");
assert_eq!(payload["icon_emoji"], ":alarm_clock:");
let attachments = payload["attachments"].as_array().unwrap();
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0]["color"], "#36a64f"); assert_eq!(attachments[0]["title"], "部署完成");
assert_eq!(attachments[0]["text"], "服务已成功部署到生产环境");
assert!(attachments[0]["ts"].as_i64().is_some());
}
#[test]
fn test_slack_notifier_level_colors() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
let notifier = SlackNotifier::new(config, transport.clone());
notifier
.send(
Notification::new()
.channel("slack")
.title("w")
.content("c")
.level(NotifyLevel::Warning),
)
.unwrap();
let (_, body) = transport.last().unwrap();
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(payload["attachments"][0]["color"], "#ffcc00");
notifier
.send(
Notification::new()
.channel("slack")
.title("w")
.content("c")
.level(NotifyLevel::Error),
)
.unwrap();
let (_, body) = transport.last().unwrap();
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(payload["attachments"][0]["color"], "#ff0000");
notifier
.send(
Notification::new()
.channel("slack")
.title("w")
.content("c")
.level(NotifyLevel::Critical),
)
.unwrap();
let (_, body) = transport.last().unwrap();
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(payload["attachments"][0]["color"], "#b22222");
assert_eq!(transport.count(), 3);
}
#[test]
fn test_slack_notifier_missing_channel() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
let notifier = SlackNotifier::new(config, transport.clone());
let notification = Notification::new().title("标题").content("内容");
let err = notifier.send(notification).unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "channel"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
assert_eq!(transport.count(), 0);
}
#[test]
fn test_slack_notifier_missing_webhook_url() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new(""); let notifier = SlackNotifier::new(config, transport.clone());
let notification = Notification::new()
.channel("slack")
.title("标题")
.content("内容");
let err = notifier.send(notification).unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "webhook_url"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
assert_eq!(transport.count(), 0);
}
#[test]
fn test_slack_notifier_http_failure() {
struct FailingTransport;
impl HttpTransport for FailingTransport {
fn post_json(&self, _url: &str, _body: &str) -> Result<(), NotifyError> {
Err(NotifyError::HttpTransport("connection refused".to_string()))
}
}
let transport = Arc::new(FailingTransport);
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
let notifier = SlackNotifier::new(config, transport);
let notification = Notification::new()
.channel("slack")
.title("标题")
.content("内容");
let err = notifier.send(notification).unwrap_err();
match err {
NotifyError::HttpTransport(msg) => assert!(msg.contains("connection refused")),
other => panic!("期望 HttpTransport, 实际 {other:?}"),
}
}
#[test]
fn test_slack_notifier_build_payload() {
let transport: Arc<dyn HttpTransport> = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
.with_channel("#alerts")
.with_username("Bot")
.with_icon_emoji(":bell:");
let notifier = SlackNotifier::new(config, transport.clone());
let notification = Notification::new()
.channel("slack")
.title("Test Title")
.content("Test Content")
.level(NotifyLevel::Error);
let payload_json = notifier.build_payload(¬ification).unwrap();
let payload: serde_json::Value = serde_json::from_str(&payload_json).unwrap();
assert_eq!(
payload["text"].as_str().unwrap(),
"[ERROR] Test Title — Test Content"
);
assert_eq!(payload["channel"], "#alerts");
assert_eq!(payload["username"], "Bot");
assert_eq!(payload["icon_emoji"], ":bell:");
let attachments = payload["attachments"].as_array().unwrap();
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0]["color"], "#ff0000");
assert_eq!(attachments[0]["title"], "Test Title");
assert_eq!(attachments[0]["text"], "Test Content");
assert!(attachments[0]["ts"].as_i64().is_some());
}
#[test]
fn test_slack_notifier_send_multiple() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
let notifier = SlackNotifier::new(config, transport.clone());
for i in 0..3 {
notifier
.send(
Notification::new()
.channel("slack")
.title(format!("Title {i}"))
.content("content"),
)
.unwrap();
}
assert_eq!(transport.count(), 3);
}
#[test]
fn test_slack_notifier_minimal_config() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
let notifier = SlackNotifier::new(config, transport.clone());
notifier
.send(
Notification::new()
.channel("slack")
.title("Title")
.content("Content"),
)
.unwrap();
let (_, body) = transport.last().unwrap();
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(payload.get("channel").is_none());
assert!(payload.get("username").is_none());
assert!(payload.get("icon_emoji").is_none());
}
#[test]
fn test_slack_notifier_with_metadata() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
let notifier = SlackNotifier::new(config, transport.clone());
notifier
.send(
Notification::new()
.channel("slack")
.title("Title")
.content("Content")
.metadata(serde_json::json!({"env": "prod", "version": "1.0.0"})),
)
.unwrap();
assert_eq!(transport.count(), 1);
}
#[test]
fn test_sms_message_builder() {
let msg = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456")
.template_param("1234")
.template_param("5")
.sign_name("鲜视达科技")
.metadata(serde_json::json!({"scene": "login"}));
assert_eq!(msg.phone, "+8613800138000");
assert_eq!(msg.template_id, "123456");
assert_eq!(msg.template_params, vec!["1234", "5"]);
assert_eq!(msg.sign_name.as_deref(), Some("鲜视达科技"));
assert_eq!(msg.metadata["scene"], "login");
}
#[test]
fn test_sms_message_validate_ok() {
let msg = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456");
assert!(msg.validate().is_ok());
}
#[test]
fn test_sms_message_validate_missing_phone() {
let msg = SmsMessage::new().template_id("123456");
let err = msg.validate().unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "phone"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_sms_message_validate_missing_template_id() {
let msg = SmsMessage::new().phone("+8613800138000");
let err = msg.validate().unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "template_id"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_memory_sms_notifier_send() {
let notifier = MemorySmsNotifier::new();
let msg = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456")
.template_param("1234");
notifier.send_sms(msg).unwrap();
assert_eq!(notifier.count(), 1);
let last = notifier.last().unwrap();
assert_eq!(last.phone, "+8613800138000");
assert_eq!(last.template_id, "123456");
assert_eq!(last.template_params, vec!["1234"]);
}
#[test]
fn test_memory_sms_notifier_send_multiple() {
let notifier = MemorySmsNotifier::new();
for i in 0..5 {
notifier
.send_sms(
SmsMessage::new()
.phone(format!("+861380013{i:04}"))
.template_id("123456"),
)
.unwrap();
}
assert_eq!(notifier.count(), 5);
let all = notifier.all();
assert_eq!(all[0].phone, "+8613800130000");
assert_eq!(all[4].phone, "+8613800130004");
}
#[test]
fn test_memory_sms_notifier_send_invalid() {
let notifier = MemorySmsNotifier::new();
let msg = SmsMessage::new().template_id("123456");
assert!(notifier.send_sms(msg).is_err());
assert_eq!(notifier.count(), 0);
}
#[test]
fn test_memory_sms_notifier_clear() {
let notifier = MemorySmsNotifier::new();
notifier
.send_sms(
SmsMessage::new()
.phone("+8613800138000")
.template_id("123456"),
)
.unwrap();
assert_eq!(notifier.count(), 1);
notifier.clear();
assert_eq!(notifier.count(), 0);
assert!(notifier.last().is_none());
}
#[test]
fn test_tencent_sms_config_builder() {
let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
.with_default_sign_name("鲜视达科技")
.with_region("ap-beijing")
.with_endpoint("sms.tencentcloudapi.com");
assert_eq!(config.secret_id, "AKIDxxx");
assert_eq!(config.secret_key, "SKxxx");
assert_eq!(config.app_id, "1400000000");
assert_eq!(config.default_sign_name.as_deref(), Some("鲜视达科技"));
assert_eq!(config.region, "ap-beijing");
assert_eq!(config.endpoint, "sms.tencentcloudapi.com");
}
#[test]
fn test_tencent_sms_config_minimal() {
let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
assert_eq!(config.secret_id, "AKIDxxx");
assert_eq!(config.secret_key, "SKxxx");
assert_eq!(config.app_id, "1400000000");
assert!(config.default_sign_name.is_none());
assert_eq!(config.region, "ap-guangzhou");
assert_eq!(config.endpoint, "sms.tencentcloudapi.com");
}
#[test]
fn test_tencent_sms_notifier_send() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
.with_default_sign_name("鲜视达科技");
let notifier = TencentSmsNotifier::new(config, transport.clone());
let msg = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456")
.template_param("1234")
.template_param("5");
notifier.send_sms(msg).unwrap();
assert_eq!(transport.count(), 1);
let (url, body) = transport.last().unwrap();
assert_eq!(url, "https://sms.tencentcloudapi.com/");
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(payload["PhoneNumbers"][0], "+8613800138000");
assert_eq!(payload["TemplateId"], "123456");
assert_eq!(payload["TemplateParamSet"][0], "1234");
assert_eq!(payload["TemplateParamSet"][1], "5");
assert_eq!(payload["SmsSdkAppId"], "1400000000");
assert_eq!(payload["SignName"], "鲜视达科技");
}
#[test]
fn test_tencent_sms_notifier_missing_phone() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
let notifier = TencentSmsNotifier::new(config, transport.clone());
let msg = SmsMessage::new().template_id("123456");
let err = notifier.send_sms(msg).unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "phone"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
assert_eq!(transport.count(), 0);
}
#[test]
fn test_tencent_sms_notifier_missing_credentials() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = TencentSmsConfig::new("", "SKxxx", "1400000000");
let notifier = TencentSmsNotifier::new(config, transport.clone());
let msg = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456");
let err = notifier.send_sms(msg).unwrap_err();
match err {
NotifyError::MissingField(field) => assert_eq!(field, "secret_id"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
assert_eq!(transport.count(), 0);
let config2 = TencentSmsConfig::new("AKIDxxx", "", "1400000000");
let notifier2 = TencentSmsNotifier::new(config2, transport.clone());
let msg2 = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456");
let err2 = notifier2.send_sms(msg2).unwrap_err();
match err2 {
NotifyError::MissingField(field) => assert_eq!(field, "secret_key"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
let config3 = TencentSmsConfig::new("AKIDxxx", "SKxxx", "");
let notifier3 = TencentSmsNotifier::new(config3, transport.clone());
let msg3 = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456");
let err3 = notifier3.send_sms(msg3).unwrap_err();
match err3 {
NotifyError::MissingField(field) => assert_eq!(field, "app_id"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
assert_eq!(transport.count(), 0);
}
#[test]
fn test_tencent_sms_notifier_uses_default_sign_name() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
.with_default_sign_name("鲜视达科技");
let notifier = TencentSmsNotifier::new(config, transport.clone());
let msg = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456");
notifier.send_sms(msg).unwrap();
let (_, body) = transport.last().unwrap();
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(payload["SignName"], "鲜视达科技");
let msg2 = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456")
.sign_name("覆盖签名");
notifier.send_sms(msg2).unwrap();
let (_, body2) = transport.last().unwrap();
let payload2: serde_json::Value = serde_json::from_str(&body2).unwrap();
assert_eq!(payload2["SignName"], "覆盖签名");
assert_eq!(transport.count(), 2);
}
#[test]
fn test_tencent_sms_notifier_no_sign_name() {
let transport = Arc::new(MemoryHttpTransport::new());
let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
let notifier = TencentSmsNotifier::new(config, transport.clone());
let msg = SmsMessage::new()
.phone("+8613800138000")
.template_id("123456");
notifier.send_sms(msg).unwrap();
let (_, body) = transport.last().unwrap();
let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(payload.get("SignName").is_none());
}
}