1use parking_lot::Mutex;
34use std::sync::Arc;
35use thiserror::Error;
36
37#[derive(Debug, Error)]
43pub enum NotifyError {
44 #[error("通知字段缺失: {0}")]
46 MissingField(String),
47 #[error("通知发送失败: {0}")]
49 SendFailed(String),
50 #[error("HTTP 传输失败: {0}")]
52 HttpTransport(String),
53 #[error("序列化失败: {0}")]
55 Serialize(String),
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
66#[repr(u8)]
67pub enum NotifyLevel {
68 #[default]
70 Info = 0,
71 Warning = 1,
73 Error = 2,
75 Critical = 3,
77}
78
79impl NotifyLevel {
80 pub fn slack_color(self) -> &'static str {
87 match self {
88 Self::Info => "#36a64f",
89 Self::Warning => "#ffcc00",
90 Self::Error => "#ff0000",
91 Self::Critical => "#b22222",
92 }
93 }
94
95 pub fn as_str(self) -> &'static str {
97 match self {
98 Self::Info => "info",
99 Self::Warning => "warning",
100 Self::Error => "error",
101 Self::Critical => "critical",
102 }
103 }
104}
105
106impl std::fmt::Display for NotifyLevel {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.write_str(self.as_str())
109 }
110}
111
112impl std::str::FromStr for NotifyLevel {
113 type Err = NotifyError;
114
115 fn from_str(s: &str) -> Result<Self, Self::Err> {
116 match s.to_lowercase().as_str() {
117 "info" => Ok(Self::Info),
118 "warning" | "warn" => Ok(Self::Warning),
119 "error" | "err" => Ok(Self::Error),
120 "critical" | "crit" => Ok(Self::Critical),
121 other => Err(NotifyError::MissingField(format!("未知通知级别: {other}"))),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Default)]
160pub struct Notification {
161 pub channel: String,
163 pub title: String,
165 pub content: String,
167 pub level: NotifyLevel,
169 pub metadata: serde_json::Value,
171}
172
173impl Notification {
174 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn channel(mut self, channel: impl Into<String>) -> Self {
181 self.channel = channel.into();
182 self
183 }
184
185 pub fn title(mut self, title: impl Into<String>) -> Self {
187 self.title = title.into();
188 self
189 }
190
191 pub fn content(mut self, content: impl Into<String>) -> Self {
193 self.content = content.into();
194 self
195 }
196
197 pub fn level(mut self, level: NotifyLevel) -> Self {
199 self.level = level;
200 self
201 }
202
203 pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
205 self.metadata = metadata;
206 self
207 }
208
209 pub fn validate(&self) -> Result<(), NotifyError> {
217 if self.channel.is_empty() {
218 return Err(NotifyError::MissingField("channel".into()));
219 }
220 if self.title.is_empty() {
221 return Err(NotifyError::MissingField("title".into()));
222 }
223 if self.content.is_empty() {
224 return Err(NotifyError::MissingField("content".into()));
225 }
226 Ok(())
227 }
228}
229
230pub trait Notifier: Send + Sync {
247 fn send(&self, notification: Notification) -> Result<(), NotifyError>;
257}
258
259#[derive(Debug, Clone, Default)]
271pub struct MemoryNotifier {
272 sent: Arc<Mutex<Vec<Notification>>>,
274}
275
276impl MemoryNotifier {
277 pub fn new() -> Self {
279 Self::default()
280 }
281
282 pub fn count(&self) -> usize {
284 self.sent.lock().len()
285 }
286
287 pub fn all(&self) -> Vec<Notification> {
289 self.sent.lock().clone()
290 }
291
292 pub fn last(&self) -> Option<Notification> {
294 self.sent.lock().last().cloned()
295 }
296
297 pub fn clear(&self) {
299 self.sent.lock().clear();
300 }
301}
302
303impl Notifier for MemoryNotifier {
304 fn send(&self, notification: Notification) -> Result<(), NotifyError> {
305 notification.validate()?;
307
308 self.sent.lock().push(notification);
310 Ok(())
311 }
312}
313
314pub trait HttpTransport: Send + Sync {
329 fn post_json(&self, url: &str, body: &str) -> Result<(), NotifyError>;
340}
341
342#[derive(Debug, Default)]
350pub struct MemoryHttpTransport {
351 requests: Mutex<Vec<(String, String)>>,
353}
354
355impl MemoryHttpTransport {
356 pub fn new() -> Self {
358 Self::default()
359 }
360
361 pub fn count(&self) -> usize {
363 self.requests.lock().len()
364 }
365
366 pub fn all(&self) -> Vec<(String, String)> {
368 self.requests.lock().clone()
369 }
370
371 pub fn last(&self) -> Option<(String, String)> {
373 self.requests.lock().last().cloned()
374 }
375
376 pub fn clear(&self) {
378 self.requests.lock().clear();
379 }
380}
381
382impl HttpTransport for MemoryHttpTransport {
383 fn post_json(&self, url: &str, body: &str) -> Result<(), NotifyError> {
384 self.requests
385 .lock()
386 .push((url.to_string(), body.to_string()));
387 Ok(())
388 }
389}
390
391#[derive(Debug, Clone)]
399pub struct SlackConfig {
400 pub webhook_url: String,
402 pub channel: Option<String>,
404 pub username: Option<String>,
406 pub icon_emoji: Option<String>,
408}
409
410impl SlackConfig {
411 pub fn new(webhook_url: impl Into<String>) -> Self {
417 Self {
418 webhook_url: webhook_url.into(),
419 channel: None,
420 username: None,
421 icon_emoji: None,
422 }
423 }
424
425 pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
427 self.channel = Some(channel.into());
428 self
429 }
430
431 pub fn with_username(mut self, username: impl Into<String>) -> Self {
433 self.username = Some(username.into());
434 self
435 }
436
437 pub fn with_icon_emoji(mut self, icon_emoji: impl Into<String>) -> Self {
439 self.icon_emoji = Some(icon_emoji.into());
440 self
441 }
442}
443
444#[derive(Debug, Clone, serde::Serialize)]
452struct SlackPayload {
453 text: String,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 channel: Option<String>,
458 #[serde(skip_serializing_if = "Option::is_none")]
460 username: Option<String>,
461 #[serde(skip_serializing_if = "Option::is_none")]
463 icon_emoji: Option<String>,
464 attachments: Vec<SlackAttachment>,
466}
467
468#[derive(Debug, Clone, serde::Serialize)]
470struct SlackAttachment {
471 color: String,
473 title: String,
475 text: String,
477 ts: i64,
479}
480
481pub struct SlackNotifier {
512 config: SlackConfig,
514 transport: Arc<dyn HttpTransport>,
516}
517
518impl SlackNotifier {
519 pub fn new(config: SlackConfig, transport: Arc<dyn HttpTransport>) -> Self {
526 Self { config, transport }
527 }
528
529 fn build_payload(&self, notification: &Notification) -> Result<String, NotifyError> {
539 let ts = chrono::Utc::now().timestamp();
540 let payload = SlackPayload {
541 text: format!(
543 "[{}] {} — {}",
544 notification.level.as_str().to_uppercase(),
545 notification.title,
546 notification.content
547 ),
548 channel: self.config.channel.clone(),
549 username: self.config.username.clone(),
550 icon_emoji: self.config.icon_emoji.clone(),
551 attachments: vec![SlackAttachment {
552 color: notification.level.slack_color().to_string(),
553 title: notification.title.clone(),
554 text: notification.content.clone(),
555 ts,
556 }],
557 };
558
559 serde_json::to_string(&payload).map_err(|e| NotifyError::Serialize(e.to_string()))
560 }
561}
562
563impl Notifier for SlackNotifier {
564 fn send(&self, notification: Notification) -> Result<(), NotifyError> {
565 notification.validate()?;
567
568 if self.config.webhook_url.is_empty() {
570 return Err(NotifyError::MissingField("webhook_url".into()));
571 }
572
573 let body = self.build_payload(¬ification)?;
575
576 self.transport
578 .post_json(&self.config.webhook_url, &body)
579 .map_err(|e| NotifyError::HttpTransport(format!("Slack Webhook 发送失败: {e}")))?;
580
581 Ok(())
582 }
583}
584
585#[derive(Debug, Clone, Default)]
617pub struct SmsMessage {
618 pub phone: String,
620 pub template_id: String,
622 pub template_params: Vec<String>,
624 pub sign_name: Option<String>,
626 pub metadata: serde_json::Value,
628}
629
630impl SmsMessage {
631 pub fn new() -> Self {
633 Self::default()
634 }
635
636 pub fn phone(mut self, phone: impl Into<String>) -> Self {
638 self.phone = phone.into();
639 self
640 }
641
642 pub fn template_id(mut self, template_id: impl Into<String>) -> Self {
644 self.template_id = template_id.into();
645 self
646 }
647
648 pub fn template_param(mut self, param: impl Into<String>) -> Self {
650 self.template_params.push(param.into());
651 self
652 }
653
654 pub fn template_params(mut self, params: Vec<String>) -> Self {
656 self.template_params = params;
657 self
658 }
659
660 pub fn sign_name(mut self, sign_name: impl Into<String>) -> Self {
662 self.sign_name = Some(sign_name.into());
663 self
664 }
665
666 pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
668 self.metadata = metadata;
669 self
670 }
671
672 pub fn validate(&self) -> Result<(), NotifyError> {
679 if self.phone.is_empty() {
680 return Err(NotifyError::MissingField("phone".into()));
681 }
682 if self.template_id.is_empty() {
683 return Err(NotifyError::MissingField("template_id".into()));
684 }
685 Ok(())
686 }
687}
688
689pub trait SmsNotifier: Send + Sync {
702 fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError>;
712}
713
714#[derive(Debug, Clone, Default)]
722pub struct MemorySmsNotifier {
723 sent: Arc<Mutex<Vec<SmsMessage>>>,
725}
726
727impl MemorySmsNotifier {
728 pub fn new() -> Self {
730 Self::default()
731 }
732
733 pub fn count(&self) -> usize {
735 self.sent.lock().len()
736 }
737
738 pub fn all(&self) -> Vec<SmsMessage> {
740 self.sent.lock().clone()
741 }
742
743 pub fn last(&self) -> Option<SmsMessage> {
745 self.sent.lock().last().cloned()
746 }
747
748 pub fn clear(&self) {
750 self.sent.lock().clear();
751 }
752}
753
754impl SmsNotifier for MemorySmsNotifier {
755 fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError> {
756 message.validate()?;
758 self.sent.lock().push(message);
760 Ok(())
761 }
762}
763
764#[derive(Clone)]
777pub struct TencentSmsConfig {
778 pub secret_id: String,
780 pub secret_key: String,
782 pub app_id: String,
784 pub default_sign_name: Option<String>,
786 pub region: String,
788 pub endpoint: String,
790}
791
792impl std::fmt::Debug for TencentSmsConfig {
793 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
795 f.debug_struct("TencentSmsConfig")
796 .field("secret_id", &"***REDACTED***")
797 .field("secret_key", &"***REDACTED***")
798 .field("app_id", &self.app_id)
799 .field("default_sign_name", &self.default_sign_name)
800 .field("region", &self.region)
801 .field("endpoint", &self.endpoint)
802 .finish()
803 }
804}
805
806impl TencentSmsConfig {
807 pub fn new(
815 secret_id: impl Into<String>,
816 secret_key: impl Into<String>,
817 app_id: impl Into<String>,
818 ) -> Self {
819 Self {
820 secret_id: secret_id.into(),
821 secret_key: secret_key.into(),
822 app_id: app_id.into(),
823 default_sign_name: None,
824 region: "ap-guangzhou".to_string(),
825 endpoint: "sms.tencentcloudapi.com".to_string(),
826 }
827 }
828
829 pub fn with_default_sign_name(mut self, sign_name: impl Into<String>) -> Self {
831 self.default_sign_name = Some(sign_name.into());
832 self
833 }
834
835 pub fn with_region(mut self, region: impl Into<String>) -> Self {
837 self.region = region.into();
838 self
839 }
840
841 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
843 self.endpoint = endpoint.into();
844 self
845 }
846}
847
848#[derive(Debug, Clone, serde::Serialize)]
859struct TencentSmsPayload {
860 #[serde(rename = "PhoneNumbers")]
862 phone_numbers: Vec<String>,
863 #[serde(rename = "TemplateId")]
865 template_id: String,
866 #[serde(rename = "TemplateParamSet")]
868 template_param_set: Vec<String>,
869 #[serde(rename = "SmsSdkAppId")]
871 sms_sdk_app_id: String,
872 #[serde(rename = "SignName", skip_serializing_if = "Option::is_none")]
874 sign_name: Option<String>,
875}
876
877pub struct TencentSmsNotifier {
908 config: TencentSmsConfig,
910 transport: Arc<dyn HttpTransport>,
912}
913
914impl TencentSmsNotifier {
915 pub fn new(config: TencentSmsConfig, transport: Arc<dyn HttpTransport>) -> Self {
922 Self { config, transport }
923 }
924
925 fn build_payload(&self, message: &SmsMessage) -> Result<String, NotifyError> {
940 let sign_name = message
941 .sign_name
942 .clone()
943 .or_else(|| self.config.default_sign_name.clone());
944
945 let payload = TencentSmsPayload {
946 phone_numbers: vec![message.phone.clone()],
947 template_id: message.template_id.clone(),
948 template_param_set: message.template_params.clone(),
949 sms_sdk_app_id: self.config.app_id.clone(),
950 sign_name,
951 };
952
953 serde_json::to_string(&payload).map_err(|e| NotifyError::Serialize(e.to_string()))
954 }
955}
956
957impl SmsNotifier for TencentSmsNotifier {
958 fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError> {
959 message.validate()?;
961
962 if self.config.secret_id.is_empty() {
964 return Err(NotifyError::MissingField("secret_id".into()));
965 }
966 if self.config.secret_key.is_empty() {
967 return Err(NotifyError::MissingField("secret_key".into()));
968 }
969 if self.config.app_id.is_empty() {
970 return Err(NotifyError::MissingField("app_id".into()));
971 }
972
973 let body = self.build_payload(&message)?;
975
976 let url = format!("https://{}/", self.config.endpoint);
978
979 self.transport
981 .post_json(&url, &body)
982 .map_err(|e| NotifyError::HttpTransport(format!("腾讯云短信发送失败: {e}")))?;
983
984 Ok(())
985 }
986}
987
988#[cfg(test)]
993mod tests {
994 use super::*;
995
996 #[test]
1002 fn test_tencent_sms_config_debug_redacted() {
1003 let config = TencentSmsConfig {
1004 secret_id: "AKIDxxx-test-secret-id".to_string(),
1005 secret_key: "SKxxx-test-secret-key".to_string(),
1006 app_id: "1400000000".to_string(),
1007 default_sign_name: Some("鲜视达科技".to_string()),
1008 region: "ap-guangzhou".to_string(),
1009 endpoint: "sms.tencentcloudapi.com".to_string(),
1010 };
1011 let debug_output = format!("{:?}", config);
1012 assert!(
1013 debug_output.contains("***REDACTED***"),
1014 "Debug 输出应含脱敏占位符"
1015 );
1016 assert!(
1017 !debug_output.contains("AKIDxxx-test-secret-id"),
1018 "Debug 输出不应含真实 secret_id"
1019 );
1020 assert!(
1021 !debug_output.contains("SKxxx-test-secret-key"),
1022 "Debug 输出不应含真实 secret_key"
1023 );
1024 }
1025
1026 #[test]
1028 fn test_tencent_sms_config_field_access_and_clone() {
1029 let config = TencentSmsConfig {
1030 secret_id: "AKIDxxx".to_string(),
1031 secret_key: "SKxxx".to_string(),
1032 app_id: "1400000000".to_string(),
1033 default_sign_name: None,
1034 region: "ap-guangzhou".to_string(),
1035 endpoint: "sms.tencentcloudapi.com".to_string(),
1036 };
1037 assert_eq!(config.secret_id, "AKIDxxx");
1039 assert_eq!(config.secret_key, "SKxxx");
1040 assert!(!config.secret_id.is_empty());
1041 let cloned = config.clone();
1043 assert_eq!(cloned.secret_id, "AKIDxxx");
1044 assert_eq!(cloned.secret_key, "SKxxx");
1045 }
1046
1047 #[test]
1053 fn test_notify_level_default() {
1054 let level = NotifyLevel::default();
1055 assert_eq!(level, NotifyLevel::Info);
1056 }
1057
1058 #[test]
1060 fn test_notify_level_slack_color() {
1061 assert_eq!(NotifyLevel::Info.slack_color(), "#36a64f");
1062 assert_eq!(NotifyLevel::Warning.slack_color(), "#ffcc00");
1063 assert_eq!(NotifyLevel::Error.slack_color(), "#ff0000");
1064 assert_eq!(NotifyLevel::Critical.slack_color(), "#b22222");
1065 }
1066
1067 #[test]
1069 fn test_notify_level_as_str() {
1070 assert_eq!(NotifyLevel::Info.as_str(), "info");
1071 assert_eq!(NotifyLevel::Warning.as_str(), "warning");
1072 assert_eq!(NotifyLevel::Error.as_str(), "error");
1073 assert_eq!(NotifyLevel::Critical.as_str(), "critical");
1074 }
1075
1076 #[test]
1078 fn test_notify_level_display() {
1079 assert_eq!(format!("{}", NotifyLevel::Info), "info");
1080 assert_eq!(format!("{}", NotifyLevel::Warning), "warning");
1081 assert_eq!(format!("{}", NotifyLevel::Error), "error");
1082 assert_eq!(format!("{}", NotifyLevel::Critical), "critical");
1083 }
1084
1085 #[test]
1087 fn test_notify_level_from_str() {
1088 assert_eq!("info".parse::<NotifyLevel>().unwrap(), NotifyLevel::Info);
1090 assert_eq!(
1091 "warning".parse::<NotifyLevel>().unwrap(),
1092 NotifyLevel::Warning
1093 );
1094 assert_eq!("error".parse::<NotifyLevel>().unwrap(), NotifyLevel::Error);
1095 assert_eq!(
1096 "critical".parse::<NotifyLevel>().unwrap(),
1097 NotifyLevel::Critical
1098 );
1099
1100 assert_eq!("warn".parse::<NotifyLevel>().unwrap(), NotifyLevel::Warning);
1102 assert_eq!("err".parse::<NotifyLevel>().unwrap(), NotifyLevel::Error);
1103 assert_eq!(
1104 "crit".parse::<NotifyLevel>().unwrap(),
1105 NotifyLevel::Critical
1106 );
1107
1108 assert_eq!("INFO".parse::<NotifyLevel>().unwrap(), NotifyLevel::Info);
1110 assert_eq!(
1111 "Critical".parse::<NotifyLevel>().unwrap(),
1112 NotifyLevel::Critical
1113 );
1114
1115 assert!("unknown".parse::<NotifyLevel>().is_err());
1117 }
1118
1119 #[test]
1125 fn test_notification_builder() {
1126 let notification = Notification::new()
1127 .channel("slack")
1128 .title("部署完成")
1129 .content("服务已成功部署到生产环境")
1130 .level(NotifyLevel::Info)
1131 .metadata(serde_json::json!({"env": "prod"}));
1132
1133 assert_eq!(notification.channel, "slack");
1134 assert_eq!(notification.title, "部署完成");
1135 assert_eq!(notification.content, "服务已成功部署到生产环境");
1136 assert_eq!(notification.level, NotifyLevel::Info);
1137 assert_eq!(notification.metadata["env"], "prod");
1138 }
1139
1140 #[test]
1142 fn test_notification_default() {
1143 let notification = Notification::default();
1144 assert!(notification.channel.is_empty());
1145 assert!(notification.title.is_empty());
1146 assert!(notification.content.is_empty());
1147 assert_eq!(notification.level, NotifyLevel::Info);
1148 assert!(notification.metadata.is_null());
1149 }
1150
1151 #[test]
1153 fn test_notification_validate_ok() {
1154 let notification = Notification::new()
1155 .channel("slack")
1156 .title("标题")
1157 .content("内容");
1158 assert!(notification.validate().is_ok());
1159 }
1160
1161 #[test]
1163 fn test_notification_validate_missing_channel() {
1164 let notification = Notification::new().title("标题").content("内容");
1165 let err = notification.validate().unwrap_err();
1166 match err {
1167 NotifyError::MissingField(field) => assert_eq!(field, "channel"),
1168 other => panic!("期望 MissingField, 实际 {other:?}"),
1169 }
1170 }
1171
1172 #[test]
1174 fn test_notification_validate_missing_title() {
1175 let notification = Notification::new().channel("slack").content("内容");
1176 let err = notification.validate().unwrap_err();
1177 match err {
1178 NotifyError::MissingField(field) => assert_eq!(field, "title"),
1179 other => panic!("期望 MissingField, 实际 {other:?}"),
1180 }
1181 }
1182
1183 #[test]
1185 fn test_notification_validate_missing_content() {
1186 let notification = Notification::new().channel("slack").title("标题");
1187 let err = notification.validate().unwrap_err();
1188 match err {
1189 NotifyError::MissingField(field) => assert_eq!(field, "content"),
1190 other => panic!("期望 MissingField, 实际 {other:?}"),
1191 }
1192 }
1193
1194 #[test]
1200 fn test_memory_notifier_send() {
1201 let notifier = MemoryNotifier::new();
1202 let notification = Notification::new()
1203 .channel("slack")
1204 .title("标题")
1205 .content("内容")
1206 .level(NotifyLevel::Warning);
1207
1208 notifier.send(notification).unwrap();
1209 assert_eq!(notifier.count(), 1);
1210
1211 let last = notifier.last().unwrap();
1212 assert_eq!(last.channel, "slack");
1213 assert_eq!(last.title, "标题");
1214 assert_eq!(last.content, "内容");
1215 assert_eq!(last.level, NotifyLevel::Warning);
1216 }
1217
1218 #[test]
1220 fn test_memory_notifier_send_multiple() {
1221 let notifier = MemoryNotifier::new();
1222 for i in 0..5 {
1223 notifier
1224 .send(
1225 Notification::new()
1226 .channel("slack")
1227 .title(format!("标题{i}"))
1228 .content("内容"),
1229 )
1230 .unwrap();
1231 }
1232 assert_eq!(notifier.count(), 5);
1233
1234 let all = notifier.all();
1235 assert_eq!(all[0].title, "标题0");
1236 assert_eq!(all[4].title, "标题4");
1237 }
1238
1239 #[test]
1241 fn test_memory_notifier_send_invalid() {
1242 let notifier = MemoryNotifier::new();
1243 let notification = Notification::new().title("标题").content("内容");
1244 assert!(notifier.send(notification).is_err());
1246 assert_eq!(notifier.count(), 0);
1247 }
1248
1249 #[test]
1251 fn test_memory_notifier_clear() {
1252 let notifier = MemoryNotifier::new();
1253 notifier
1254 .send(
1255 Notification::new()
1256 .channel("slack")
1257 .title("标题")
1258 .content("内容"),
1259 )
1260 .unwrap();
1261 assert_eq!(notifier.count(), 1);
1262
1263 notifier.clear();
1264 assert_eq!(notifier.count(), 0);
1265 assert!(notifier.last().is_none());
1266 }
1267
1268 #[test]
1274 fn test_memory_http_transport_post_json() {
1275 let transport = MemoryHttpTransport::new();
1276 transport
1277 .post_json("https://hooks.slack.com/services/xxx", r#"{"text":"hi"}"#)
1278 .unwrap();
1279
1280 assert_eq!(transport.count(), 1);
1281 let (url, body) = transport.last().unwrap();
1282 assert_eq!(url, "https://hooks.slack.com/services/xxx");
1283 assert_eq!(body, r#"{"text":"hi"}"#);
1284 }
1285
1286 #[test]
1288 fn test_memory_http_transport_clear() {
1289 let transport = MemoryHttpTransport::new();
1290 transport.post_json("url", "body").unwrap();
1291 assert_eq!(transport.count(), 1);
1292
1293 transport.clear();
1294 assert_eq!(transport.count(), 0);
1295 }
1296
1297 #[test]
1303 fn test_slack_config_builder() {
1304 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
1305 .with_channel("#alerts")
1306 .with_username("SZ-Rust Bot")
1307 .with_icon_emoji(":alarm_clock:");
1308
1309 assert_eq!(config.webhook_url, "https://hooks.slack.com/services/T/B/X");
1310 assert_eq!(config.channel.as_deref(), Some("#alerts"));
1311 assert_eq!(config.username.as_deref(), Some("SZ-Rust Bot"));
1312 assert_eq!(config.icon_emoji.as_deref(), Some(":alarm_clock:"));
1313 }
1314
1315 #[test]
1317 fn test_slack_config_minimal() {
1318 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1319 assert_eq!(config.webhook_url, "https://hooks.slack.com/services/T/B/X");
1320 assert!(config.channel.is_none());
1321 assert!(config.username.is_none());
1322 assert!(config.icon_emoji.is_none());
1323 }
1324
1325 #[test]
1331 fn test_slack_notifier_send() {
1332 let transport = Arc::new(MemoryHttpTransport::new());
1333 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
1334 .with_channel("#alerts")
1335 .with_username("SZ-Rust Bot")
1336 .with_icon_emoji(":alarm_clock:");
1337 let notifier = SlackNotifier::new(config, transport.clone());
1338
1339 let notification = Notification::new()
1340 .channel("slack")
1341 .title("部署完成")
1342 .content("服务已成功部署到生产环境")
1343 .level(NotifyLevel::Info);
1344
1345 notifier.send(notification).unwrap();
1346
1347 assert_eq!(transport.count(), 1);
1349 let (url, body) = transport.last().unwrap();
1350 assert_eq!(url, "https://hooks.slack.com/services/T/B/X");
1351
1352 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1354 assert!(payload["text"].as_str().unwrap().contains("部署完成"));
1355 assert!(payload["text"]
1356 .as_str()
1357 .unwrap()
1358 .contains("服务已成功部署到生产环境"));
1359 assert!(payload["text"].as_str().unwrap().contains("[INFO]"));
1360 assert_eq!(payload["channel"], "#alerts");
1361 assert_eq!(payload["username"], "SZ-Rust Bot");
1362 assert_eq!(payload["icon_emoji"], ":alarm_clock:");
1363
1364 let attachments = payload["attachments"].as_array().unwrap();
1366 assert_eq!(attachments.len(), 1);
1367 assert_eq!(attachments[0]["color"], "#36a64f"); assert_eq!(attachments[0]["title"], "部署完成");
1369 assert_eq!(attachments[0]["text"], "服务已成功部署到生产环境");
1370 assert!(attachments[0]["ts"].as_i64().is_some());
1371 }
1372
1373 #[test]
1375 fn test_slack_notifier_level_colors() {
1376 let transport = Arc::new(MemoryHttpTransport::new());
1377 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1378 let notifier = SlackNotifier::new(config, transport.clone());
1379
1380 notifier
1382 .send(
1383 Notification::new()
1384 .channel("slack")
1385 .title("w")
1386 .content("c")
1387 .level(NotifyLevel::Warning),
1388 )
1389 .unwrap();
1390 let (_, body) = transport.last().unwrap();
1391 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1392 assert_eq!(payload["attachments"][0]["color"], "#ffcc00");
1393
1394 notifier
1396 .send(
1397 Notification::new()
1398 .channel("slack")
1399 .title("w")
1400 .content("c")
1401 .level(NotifyLevel::Error),
1402 )
1403 .unwrap();
1404 let (_, body) = transport.last().unwrap();
1405 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1406 assert_eq!(payload["attachments"][0]["color"], "#ff0000");
1407
1408 notifier
1410 .send(
1411 Notification::new()
1412 .channel("slack")
1413 .title("w")
1414 .content("c")
1415 .level(NotifyLevel::Critical),
1416 )
1417 .unwrap();
1418 let (_, body) = transport.last().unwrap();
1419 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1420 assert_eq!(payload["attachments"][0]["color"], "#b22222");
1421
1422 assert_eq!(transport.count(), 3);
1423 }
1424
1425 #[test]
1427 fn test_slack_notifier_missing_channel() {
1428 let transport = Arc::new(MemoryHttpTransport::new());
1429 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1430 let notifier = SlackNotifier::new(config, transport.clone());
1431
1432 let notification = Notification::new().title("标题").content("内容");
1433 let err = notifier.send(notification).unwrap_err();
1434 match err {
1435 NotifyError::MissingField(field) => assert_eq!(field, "channel"),
1436 other => panic!("期望 MissingField, 实际 {other:?}"),
1437 }
1438
1439 assert_eq!(transport.count(), 0);
1441 }
1442
1443 #[test]
1445 fn test_slack_notifier_missing_webhook_url() {
1446 let transport = Arc::new(MemoryHttpTransport::new());
1447 let config = SlackConfig::new(""); let notifier = SlackNotifier::new(config, transport.clone());
1449
1450 let notification = Notification::new()
1451 .channel("slack")
1452 .title("标题")
1453 .content("内容");
1454 let err = notifier.send(notification).unwrap_err();
1455 match err {
1456 NotifyError::MissingField(field) => assert_eq!(field, "webhook_url"),
1457 other => panic!("期望 MissingField, 实际 {other:?}"),
1458 }
1459
1460 assert_eq!(transport.count(), 0);
1461 }
1462
1463 #[test]
1465 fn test_slack_notifier_http_failure() {
1466 struct FailingTransport;
1468 impl HttpTransport for FailingTransport {
1469 fn post_json(&self, _url: &str, _body: &str) -> Result<(), NotifyError> {
1470 Err(NotifyError::HttpTransport("connection refused".to_string()))
1471 }
1472 }
1473
1474 let transport = Arc::new(FailingTransport);
1475 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1476 let notifier = SlackNotifier::new(config, transport);
1477
1478 let notification = Notification::new()
1479 .channel("slack")
1480 .title("标题")
1481 .content("内容");
1482 let err = notifier.send(notification).unwrap_err();
1483 match err {
1484 NotifyError::HttpTransport(msg) => assert!(msg.contains("connection refused")),
1485 other => panic!("期望 HttpTransport, 实际 {other:?}"),
1486 }
1487 }
1488
1489 #[test]
1491 fn test_slack_notifier_build_payload() {
1492 let transport: Arc<dyn HttpTransport> = Arc::new(MemoryHttpTransport::new());
1493 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
1494 .with_channel("#alerts")
1495 .with_username("Bot")
1496 .with_icon_emoji(":bell:");
1497 let notifier = SlackNotifier::new(config, transport.clone());
1498
1499 let notification = Notification::new()
1500 .channel("slack")
1501 .title("Test Title")
1502 .content("Test Content")
1503 .level(NotifyLevel::Error);
1504
1505 let payload_json = notifier.build_payload(¬ification).unwrap();
1506 let payload: serde_json::Value = serde_json::from_str(&payload_json).unwrap();
1507
1508 assert_eq!(
1510 payload["text"].as_str().unwrap(),
1511 "[ERROR] Test Title — Test Content"
1512 );
1513
1514 assert_eq!(payload["channel"], "#alerts");
1516 assert_eq!(payload["username"], "Bot");
1517 assert_eq!(payload["icon_emoji"], ":bell:");
1518
1519 let attachments = payload["attachments"].as_array().unwrap();
1521 assert_eq!(attachments.len(), 1);
1522 assert_eq!(attachments[0]["color"], "#ff0000");
1523 assert_eq!(attachments[0]["title"], "Test Title");
1524 assert_eq!(attachments[0]["text"], "Test Content");
1525 assert!(attachments[0]["ts"].as_i64().is_some());
1526 }
1527
1528 #[test]
1530 fn test_slack_notifier_send_multiple() {
1531 let transport = Arc::new(MemoryHttpTransport::new());
1532 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1533 let notifier = SlackNotifier::new(config, transport.clone());
1534
1535 for i in 0..3 {
1536 notifier
1537 .send(
1538 Notification::new()
1539 .channel("slack")
1540 .title(format!("Title {i}"))
1541 .content("content"),
1542 )
1543 .unwrap();
1544 }
1545 assert_eq!(transport.count(), 3);
1546 }
1547
1548 #[test]
1550 fn test_slack_notifier_minimal_config() {
1551 let transport = Arc::new(MemoryHttpTransport::new());
1552 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1553 let notifier = SlackNotifier::new(config, transport.clone());
1554
1555 notifier
1556 .send(
1557 Notification::new()
1558 .channel("slack")
1559 .title("Title")
1560 .content("Content"),
1561 )
1562 .unwrap();
1563
1564 let (_, body) = transport.last().unwrap();
1565 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1566
1567 assert!(payload.get("channel").is_none());
1569 assert!(payload.get("username").is_none());
1570 assert!(payload.get("icon_emoji").is_none());
1571 }
1572
1573 #[test]
1575 fn test_slack_notifier_with_metadata() {
1576 let transport = Arc::new(MemoryHttpTransport::new());
1577 let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1578 let notifier = SlackNotifier::new(config, transport.clone());
1579
1580 notifier
1581 .send(
1582 Notification::new()
1583 .channel("slack")
1584 .title("Title")
1585 .content("Content")
1586 .metadata(serde_json::json!({"env": "prod", "version": "1.0.0"})),
1587 )
1588 .unwrap();
1589
1590 assert_eq!(transport.count(), 1);
1591 }
1592
1593 #[test]
1599 fn test_sms_message_builder() {
1600 let msg = SmsMessage::new()
1601 .phone("+8613800138000")
1602 .template_id("123456")
1603 .template_param("1234")
1604 .template_param("5")
1605 .sign_name("鲜视达科技")
1606 .metadata(serde_json::json!({"scene": "login"}));
1607
1608 assert_eq!(msg.phone, "+8613800138000");
1609 assert_eq!(msg.template_id, "123456");
1610 assert_eq!(msg.template_params, vec!["1234", "5"]);
1611 assert_eq!(msg.sign_name.as_deref(), Some("鲜视达科技"));
1612 assert_eq!(msg.metadata["scene"], "login");
1613 }
1614
1615 #[test]
1617 fn test_sms_message_validate_ok() {
1618 let msg = SmsMessage::new()
1619 .phone("+8613800138000")
1620 .template_id("123456");
1621 assert!(msg.validate().is_ok());
1622 }
1623
1624 #[test]
1626 fn test_sms_message_validate_missing_phone() {
1627 let msg = SmsMessage::new().template_id("123456");
1628 let err = msg.validate().unwrap_err();
1629 match err {
1630 NotifyError::MissingField(field) => assert_eq!(field, "phone"),
1631 other => panic!("期望 MissingField, 实际 {other:?}"),
1632 }
1633 }
1634
1635 #[test]
1637 fn test_sms_message_validate_missing_template_id() {
1638 let msg = SmsMessage::new().phone("+8613800138000");
1639 let err = msg.validate().unwrap_err();
1640 match err {
1641 NotifyError::MissingField(field) => assert_eq!(field, "template_id"),
1642 other => panic!("期望 MissingField, 实际 {other:?}"),
1643 }
1644 }
1645
1646 #[test]
1652 fn test_memory_sms_notifier_send() {
1653 let notifier = MemorySmsNotifier::new();
1654 let msg = SmsMessage::new()
1655 .phone("+8613800138000")
1656 .template_id("123456")
1657 .template_param("1234");
1658
1659 notifier.send_sms(msg).unwrap();
1660 assert_eq!(notifier.count(), 1);
1661
1662 let last = notifier.last().unwrap();
1663 assert_eq!(last.phone, "+8613800138000");
1664 assert_eq!(last.template_id, "123456");
1665 assert_eq!(last.template_params, vec!["1234"]);
1666 }
1667
1668 #[test]
1670 fn test_memory_sms_notifier_send_multiple() {
1671 let notifier = MemorySmsNotifier::new();
1672 for i in 0..5 {
1673 notifier
1674 .send_sms(
1675 SmsMessage::new()
1676 .phone(format!("+861380013{i:04}"))
1677 .template_id("123456"),
1678 )
1679 .unwrap();
1680 }
1681 assert_eq!(notifier.count(), 5);
1682
1683 let all = notifier.all();
1684 assert_eq!(all[0].phone, "+8613800130000");
1685 assert_eq!(all[4].phone, "+8613800130004");
1686 }
1687
1688 #[test]
1690 fn test_memory_sms_notifier_send_invalid() {
1691 let notifier = MemorySmsNotifier::new();
1692 let msg = SmsMessage::new().template_id("123456");
1693 assert!(notifier.send_sms(msg).is_err());
1695 assert_eq!(notifier.count(), 0);
1696 }
1697
1698 #[test]
1700 fn test_memory_sms_notifier_clear() {
1701 let notifier = MemorySmsNotifier::new();
1702 notifier
1703 .send_sms(
1704 SmsMessage::new()
1705 .phone("+8613800138000")
1706 .template_id("123456"),
1707 )
1708 .unwrap();
1709 assert_eq!(notifier.count(), 1);
1710
1711 notifier.clear();
1712 assert_eq!(notifier.count(), 0);
1713 assert!(notifier.last().is_none());
1714 }
1715
1716 #[test]
1722 fn test_tencent_sms_config_builder() {
1723 let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
1724 .with_default_sign_name("鲜视达科技")
1725 .with_region("ap-beijing")
1726 .with_endpoint("sms.tencentcloudapi.com");
1727
1728 assert_eq!(config.secret_id, "AKIDxxx");
1729 assert_eq!(config.secret_key, "SKxxx");
1730 assert_eq!(config.app_id, "1400000000");
1731 assert_eq!(config.default_sign_name.as_deref(), Some("鲜视达科技"));
1732 assert_eq!(config.region, "ap-beijing");
1733 assert_eq!(config.endpoint, "sms.tencentcloudapi.com");
1734 }
1735
1736 #[test]
1738 fn test_tencent_sms_config_minimal() {
1739 let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
1740 assert_eq!(config.secret_id, "AKIDxxx");
1741 assert_eq!(config.secret_key, "SKxxx");
1742 assert_eq!(config.app_id, "1400000000");
1743 assert!(config.default_sign_name.is_none());
1744 assert_eq!(config.region, "ap-guangzhou");
1745 assert_eq!(config.endpoint, "sms.tencentcloudapi.com");
1746 }
1747
1748 #[test]
1754 fn test_tencent_sms_notifier_send() {
1755 let transport = Arc::new(MemoryHttpTransport::new());
1756 let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
1757 .with_default_sign_name("鲜视达科技");
1758 let notifier = TencentSmsNotifier::new(config, transport.clone());
1759
1760 let msg = SmsMessage::new()
1761 .phone("+8613800138000")
1762 .template_id("123456")
1763 .template_param("1234")
1764 .template_param("5");
1765
1766 notifier.send_sms(msg).unwrap();
1767
1768 assert_eq!(transport.count(), 1);
1770 let (url, body) = transport.last().unwrap();
1771 assert_eq!(url, "https://sms.tencentcloudapi.com/");
1772
1773 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1775 assert_eq!(payload["PhoneNumbers"][0], "+8613800138000");
1776 assert_eq!(payload["TemplateId"], "123456");
1777 assert_eq!(payload["TemplateParamSet"][0], "1234");
1778 assert_eq!(payload["TemplateParamSet"][1], "5");
1779 assert_eq!(payload["SmsSdkAppId"], "1400000000");
1780 assert_eq!(payload["SignName"], "鲜视达科技");
1781 }
1782
1783 #[test]
1785 fn test_tencent_sms_notifier_missing_phone() {
1786 let transport = Arc::new(MemoryHttpTransport::new());
1787 let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
1788 let notifier = TencentSmsNotifier::new(config, transport.clone());
1789
1790 let msg = SmsMessage::new().template_id("123456");
1791 let err = notifier.send_sms(msg).unwrap_err();
1792 match err {
1793 NotifyError::MissingField(field) => assert_eq!(field, "phone"),
1794 other => panic!("期望 MissingField, 实际 {other:?}"),
1795 }
1796
1797 assert_eq!(transport.count(), 0);
1799 }
1800
1801 #[test]
1803 fn test_tencent_sms_notifier_missing_credentials() {
1804 let transport = Arc::new(MemoryHttpTransport::new());
1805 let config = TencentSmsConfig::new("", "SKxxx", "1400000000");
1807 let notifier = TencentSmsNotifier::new(config, transport.clone());
1808
1809 let msg = SmsMessage::new()
1810 .phone("+8613800138000")
1811 .template_id("123456");
1812 let err = notifier.send_sms(msg).unwrap_err();
1813 match err {
1814 NotifyError::MissingField(field) => assert_eq!(field, "secret_id"),
1815 other => panic!("期望 MissingField, 实际 {other:?}"),
1816 }
1817
1818 assert_eq!(transport.count(), 0);
1820
1821 let config2 = TencentSmsConfig::new("AKIDxxx", "", "1400000000");
1823 let notifier2 = TencentSmsNotifier::new(config2, transport.clone());
1824 let msg2 = SmsMessage::new()
1825 .phone("+8613800138000")
1826 .template_id("123456");
1827 let err2 = notifier2.send_sms(msg2).unwrap_err();
1828 match err2 {
1829 NotifyError::MissingField(field) => assert_eq!(field, "secret_key"),
1830 other => panic!("期望 MissingField, 实际 {other:?}"),
1831 }
1832
1833 let config3 = TencentSmsConfig::new("AKIDxxx", "SKxxx", "");
1835 let notifier3 = TencentSmsNotifier::new(config3, transport.clone());
1836 let msg3 = SmsMessage::new()
1837 .phone("+8613800138000")
1838 .template_id("123456");
1839 let err3 = notifier3.send_sms(msg3).unwrap_err();
1840 match err3 {
1841 NotifyError::MissingField(field) => assert_eq!(field, "app_id"),
1842 other => panic!("期望 MissingField, 实际 {other:?}"),
1843 }
1844
1845 assert_eq!(transport.count(), 0);
1847 }
1848
1849 #[test]
1851 fn test_tencent_sms_notifier_uses_default_sign_name() {
1852 let transport = Arc::new(MemoryHttpTransport::new());
1853 let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
1854 .with_default_sign_name("鲜视达科技");
1855 let notifier = TencentSmsNotifier::new(config, transport.clone());
1856
1857 let msg = SmsMessage::new()
1859 .phone("+8613800138000")
1860 .template_id("123456");
1861
1862 notifier.send_sms(msg).unwrap();
1863
1864 let (_, body) = transport.last().unwrap();
1865 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1866 assert_eq!(payload["SignName"], "鲜视达科技");
1867
1868 let msg2 = SmsMessage::new()
1870 .phone("+8613800138000")
1871 .template_id("123456")
1872 .sign_name("覆盖签名");
1873 notifier.send_sms(msg2).unwrap();
1874
1875 let (_, body2) = transport.last().unwrap();
1876 let payload2: serde_json::Value = serde_json::from_str(&body2).unwrap();
1877 assert_eq!(payload2["SignName"], "覆盖签名");
1878
1879 assert_eq!(transport.count(), 2);
1880 }
1881
1882 #[test]
1884 fn test_tencent_sms_notifier_no_sign_name() {
1885 let transport = Arc::new(MemoryHttpTransport::new());
1886 let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
1887 let notifier = TencentSmsNotifier::new(config, transport.clone());
1888
1889 let msg = SmsMessage::new()
1890 .phone("+8613800138000")
1891 .template_id("123456");
1892
1893 notifier.send_sms(msg).unwrap();
1894
1895 let (_, body) = transport.last().unwrap();
1896 let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1897 assert!(payload.get("SignName").is_none());
1899 }
1900}