1use std::collections::HashMap;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
65pub enum EventSeverity {
66 Info,
68 Warning,
70 Error,
72 Critical,
74}
75
76impl EventSeverity {
77 #[must_use]
79 pub const fn label(self) -> &'static str {
80 match self {
81 Self::Info => "info",
82 Self::Warning => "warning",
83 Self::Error => "error",
84 Self::Critical => "critical",
85 }
86 }
87
88 #[must_use]
90 pub const fn level(self) -> u8 {
91 match self {
92 Self::Info => 0,
93 Self::Warning => 1,
94 Self::Error => 2,
95 Self::Critical => 3,
96 }
97 }
98}
99
100impl std::fmt::Display for EventSeverity {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.write_str(self.label())
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct SinkEvent {
109 pub event_type: String,
111 pub title: String,
113 pub body: String,
115 pub severity: EventSeverity,
117 pub metadata: HashMap<String, String>,
119 pub timestamp_ms: u64,
121 pub dedup_key: Option<String>,
123}
124
125impl SinkEvent {
126 #[must_use]
128 pub fn new(
129 event_type: impl Into<String>,
130 title: impl Into<String>,
131 body: impl Into<String>,
132 severity: EventSeverity,
133 ) -> Self {
134 Self {
135 event_type: event_type.into(),
136 title: title.into(),
137 body: body.into(),
138 severity,
139 metadata: HashMap::new(),
140 timestamp_ms: current_timestamp_ms(),
141 dedup_key: None,
142 }
143 }
144
145 #[must_use]
147 pub fn workflow_completed(workflow_id: &str, workflow_name: &str) -> Self {
148 let mut ev = Self::new(
149 "workflow.completed",
150 format!("Workflow completed: {workflow_name}"),
151 format!("Workflow '{workflow_name}' (id={workflow_id}) completed successfully."),
152 EventSeverity::Info,
153 );
154 ev.metadata
155 .insert("workflow_id".to_string(), workflow_id.to_string());
156 ev.metadata
157 .insert("workflow_name".to_string(), workflow_name.to_string());
158 ev
159 }
160
161 #[must_use]
163 pub fn workflow_failed(workflow_id: &str, workflow_name: &str, reason: &str) -> Self {
164 let mut ev = Self::new(
165 "workflow.failed",
166 format!("Workflow FAILED: {workflow_name}"),
167 format!("Workflow '{workflow_name}' (id={workflow_id}) failed: {reason}"),
168 EventSeverity::Error,
169 );
170 ev.metadata
171 .insert("workflow_id".to_string(), workflow_id.to_string());
172 ev.metadata
173 .insert("workflow_name".to_string(), workflow_name.to_string());
174 ev.metadata.insert("reason".to_string(), reason.to_string());
175 ev.dedup_key = Some(format!("wf-failed-{workflow_id}"));
176 ev
177 }
178
179 #[must_use]
181 pub fn task_failed(workflow_id: &str, task_name: &str, reason: &str) -> Self {
182 let mut ev = Self::new(
183 "task.failed",
184 format!("Task FAILED: {task_name}"),
185 format!("Task '{task_name}' in workflow {workflow_id} failed: {reason}"),
186 EventSeverity::Warning,
187 );
188 ev.metadata
189 .insert("workflow_id".to_string(), workflow_id.to_string());
190 ev.metadata
191 .insert("task_name".to_string(), task_name.to_string());
192 ev
193 }
194
195 #[must_use]
197 pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
198 self.metadata.insert(key.into(), value.into());
199 self
200 }
201
202 #[must_use]
204 pub fn with_dedup_key(mut self, key: impl Into<String>) -> Self {
205 self.dedup_key = Some(key.into());
206 self
207 }
208}
209
210fn current_timestamp_ms() -> u64 {
211 std::time::SystemTime::now()
212 .duration_since(std::time::UNIX_EPOCH)
213 .unwrap_or_default()
214 .as_millis() as u64
215}
216
217#[derive(Debug, Clone)]
223pub struct SinkOutcome {
224 pub sink_name: String,
226 pub success: bool,
228 pub message: String,
230}
231
232impl SinkOutcome {
233 #[must_use]
235 pub fn ok(sink_name: impl Into<String>, message: impl Into<String>) -> Self {
236 Self {
237 sink_name: sink_name.into(),
238 success: true,
239 message: message.into(),
240 }
241 }
242
243 #[must_use]
245 pub fn err(sink_name: impl Into<String>, message: impl Into<String>) -> Self {
246 Self {
247 sink_name: sink_name.into(),
248 success: false,
249 message: message.into(),
250 }
251 }
252}
253
254pub trait NotificationSink: Send + Sync {
259 fn name(&self) -> &str;
261
262 fn dispatch(&self, event: &SinkEvent) -> SinkOutcome;
267
268 fn accepts_severity(&self, _severity: EventSeverity) -> bool {
272 true
273 }
274}
275
276#[derive(Debug, Clone)]
282pub struct SlackMessage {
283 pub channel: Option<String>,
285 pub username: Option<String>,
287 pub icon_emoji: Option<String>,
289 pub payload_json: String,
291}
292
293#[derive(Debug, Clone)]
298pub struct SlackSink {
299 pub webhook_url: String,
301 pub channel: Option<String>,
303 pub username: Option<String>,
305 pub icon_emoji: Option<String>,
307 pub min_severity: EventSeverity,
309}
310
311impl SlackSink {
312 #[must_use]
314 pub fn new(webhook_url: impl Into<String>) -> Self {
315 Self {
316 webhook_url: webhook_url.into(),
317 channel: None,
318 username: None,
319 icon_emoji: None,
320 min_severity: EventSeverity::Info,
321 }
322 }
323
324 #[must_use]
326 pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
327 self.channel = Some(channel.into());
328 self
329 }
330
331 #[must_use]
333 pub fn with_username(mut self, username: impl Into<String>) -> Self {
334 self.username = Some(username.into());
335 self
336 }
337
338 #[must_use]
340 pub fn with_icon(mut self, emoji: impl Into<String>) -> Self {
341 self.icon_emoji = Some(emoji.into());
342 self
343 }
344
345 #[must_use]
347 pub fn with_min_severity(mut self, min: EventSeverity) -> Self {
348 self.min_severity = min;
349 self
350 }
351
352 #[must_use]
354 pub fn build_message(&self, event: &SinkEvent) -> SlackMessage {
355 let color = match event.severity {
356 EventSeverity::Info => "#36a64f",
357 EventSeverity::Warning => "#ffcc00",
358 EventSeverity::Error => "#ff4444",
359 EventSeverity::Critical => "#cc0000",
360 };
361
362 let attachment = serde_json::json!({
363 "color": color,
364 "title": event.title,
365 "text": event.body,
366 "footer": format!("severity={} type={}", event.severity.label(), event.event_type),
367 "ts": event.timestamp_ms / 1000,
368 });
369
370 let mut payload = serde_json::json!({
371 "attachments": [attachment],
372 });
373
374 if let Some(ch) = &self.channel {
375 payload["channel"] = serde_json::json!(ch);
376 }
377 if let Some(un) = &self.username {
378 payload["username"] = serde_json::json!(un);
379 }
380 if let Some(ic) = &self.icon_emoji {
381 payload["icon_emoji"] = serde_json::json!(ic);
382 }
383
384 SlackMessage {
385 channel: self.channel.clone(),
386 username: self.username.clone(),
387 icon_emoji: self.icon_emoji.clone(),
388 payload_json: payload.to_string(),
389 }
390 }
391}
392
393impl NotificationSink for SlackSink {
394 fn name(&self) -> &str {
395 "slack"
396 }
397
398 fn dispatch(&self, event: &SinkEvent) -> SinkOutcome {
399 if !self.accepts_severity(event.severity) {
400 return SinkOutcome::ok(
401 "slack",
402 format!(
403 "skipped: severity {} below minimum {}",
404 event.severity.label(),
405 self.min_severity.label()
406 ),
407 );
408 }
409 let msg = self.build_message(event);
410 SinkOutcome::ok(
411 "slack",
412 format!(
413 "POST {} payload_len={}",
414 self.webhook_url,
415 msg.payload_json.len()
416 ),
417 )
418 }
419
420 fn accepts_severity(&self, severity: EventSeverity) -> bool {
421 severity >= self.min_severity
422 }
423}
424
425#[derive(Debug, Clone)]
431pub struct SmtpConfig {
432 pub from: String,
434 pub to: Vec<String>,
436 pub subject_prefix: String,
438}
439
440#[derive(Debug, Clone)]
442pub struct EmailMessage {
443 pub from: String,
445 pub to: Vec<String>,
447 pub subject: String,
449 pub body: String,
451}
452
453#[derive(Debug, Clone)]
458pub struct EmailSink {
459 pub config: SmtpConfig,
461 pub min_severity: EventSeverity,
463}
464
465impl EmailSink {
466 #[must_use]
468 pub fn new(config: SmtpConfig) -> Self {
469 Self {
470 config,
471 min_severity: EventSeverity::Warning,
472 }
473 }
474
475 #[must_use]
477 pub fn with_min_severity(mut self, min: EventSeverity) -> Self {
478 self.min_severity = min;
479 self
480 }
481
482 #[must_use]
484 pub fn build_message(&self, event: &SinkEvent) -> EmailMessage {
485 let subject = format!(
486 "{} [{}] {}",
487 self.config.subject_prefix,
488 event.severity.label().to_uppercase(),
489 event.title
490 );
491
492 let mut body_lines = vec![
493 event.body.clone(),
494 String::new(),
495 format!("Event type : {}", event.event_type),
496 format!("Severity : {}", event.severity.label()),
497 format!("Timestamp : {}ms", event.timestamp_ms),
498 ];
499
500 if !event.metadata.is_empty() {
501 body_lines.push(String::new());
502 body_lines.push("Metadata:".to_string());
503 let mut sorted: Vec<(&String, &String)> = event.metadata.iter().collect();
504 sorted.sort_by_key(|(k, _)| k.as_str());
505 for (k, v) in sorted {
506 body_lines.push(format!(" {k}: {v}"));
507 }
508 }
509
510 EmailMessage {
511 from: self.config.from.clone(),
512 to: self.config.to.clone(),
513 subject,
514 body: body_lines.join("\n"),
515 }
516 }
517}
518
519impl NotificationSink for EmailSink {
520 fn name(&self) -> &str {
521 "email"
522 }
523
524 fn dispatch(&self, event: &SinkEvent) -> SinkOutcome {
525 if !self.accepts_severity(event.severity) {
526 return SinkOutcome::ok(
527 "email",
528 format!(
529 "skipped: severity {} below minimum {}",
530 event.severity.label(),
531 self.min_severity.label()
532 ),
533 );
534 }
535 let msg = self.build_message(event);
536 SinkOutcome::ok(
537 "email",
538 format!(
539 "SMTP from={} to={} subject={:?}",
540 msg.from,
541 msg.to.join(","),
542 msg.subject
543 ),
544 )
545 }
546
547 fn accepts_severity(&self, severity: EventSeverity) -> bool {
548 severity >= self.min_severity
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
558pub enum PagerDutySeverity {
559 Info,
561 Warning,
563 Error,
565 Critical,
567}
568
569impl PagerDutySeverity {
570 #[must_use]
572 pub const fn as_str(self) -> &'static str {
573 match self {
574 Self::Info => "info",
575 Self::Warning => "warning",
576 Self::Error => "error",
577 Self::Critical => "critical",
578 }
579 }
580
581 #[must_use]
583 pub fn from_event_severity(s: EventSeverity) -> Self {
584 match s {
585 EventSeverity::Info => Self::Info,
586 EventSeverity::Warning => Self::Warning,
587 EventSeverity::Error => Self::Error,
588 EventSeverity::Critical => Self::Critical,
589 }
590 }
591}
592
593#[derive(Debug, Clone)]
595pub struct PagerDutyConfig {
596 pub routing_key: String,
598 pub default_severity: PagerDutySeverity,
600 pub service_name: String,
602}
603
604#[derive(Debug, Clone)]
607pub struct PagerDutyPayload {
608 pub body_json: String,
610 pub dedup_key: String,
612}
613
614#[derive(Debug, Clone)]
622pub struct PagerDutySink {
623 pub config: PagerDutyConfig,
625 pub min_severity: EventSeverity,
627}
628
629impl PagerDutySink {
630 #[must_use]
632 pub fn new(config: PagerDutyConfig) -> Self {
633 Self {
634 config,
635 min_severity: EventSeverity::Warning,
636 }
637 }
638
639 #[must_use]
641 pub fn with_min_severity(mut self, min: EventSeverity) -> Self {
642 self.min_severity = min;
643 self
644 }
645
646 #[must_use]
648 pub fn build_payload(&self, event: &SinkEvent) -> PagerDutyPayload {
649 let pd_severity = PagerDutySeverity::from_event_severity(event.severity);
650 let dedup_key = event
651 .dedup_key
652 .clone()
653 .unwrap_or_else(|| format!("oximedia-{}", event.event_type));
654
655 let mut custom_details = serde_json::Map::new();
656 for (k, v) in &event.metadata {
657 custom_details.insert(k.clone(), serde_json::json!(v));
658 }
659 custom_details.insert(
660 "event_type".to_string(),
661 serde_json::json!(event.event_type),
662 );
663
664 let body = serde_json::json!({
665 "routing_key": self.config.routing_key,
666 "event_action": "trigger",
667 "dedup_key": dedup_key,
668 "payload": {
669 "summary": event.title,
670 "severity": pd_severity.as_str(),
671 "source": self.config.service_name,
672 "timestamp": event.timestamp_ms,
673 "custom_details": custom_details,
674 },
675 "client": "OxiMedia Workflow",
676 "client_url": "https://github.com/cool-japan/oximedia",
677 });
678
679 PagerDutyPayload {
680 body_json: body.to_string(),
681 dedup_key,
682 }
683 }
684}
685
686impl NotificationSink for PagerDutySink {
687 fn name(&self) -> &str {
688 "pagerduty"
689 }
690
691 fn dispatch(&self, event: &SinkEvent) -> SinkOutcome {
692 if !self.accepts_severity(event.severity) {
693 return SinkOutcome::ok(
694 "pagerduty",
695 format!(
696 "skipped: severity {} below minimum {}",
697 event.severity.label(),
698 self.min_severity.label()
699 ),
700 );
701 }
702 let payload = self.build_payload(event);
703 SinkOutcome::ok(
704 "pagerduty",
705 format!(
706 "POST events.pagerduty.com/v2/enqueue dedup_key={} payload_len={}",
707 payload.dedup_key,
708 payload.body_json.len()
709 ),
710 )
711 }
712
713 fn accepts_severity(&self, severity: EventSeverity) -> bool {
714 severity >= self.min_severity
715 }
716}
717
718#[derive(Debug, Clone, Default)]
724pub struct RouterSummary {
725 pub outcomes: Vec<SinkOutcome>,
727 pub success_count: usize,
729 pub failure_count: usize,
731 pub skip_count: usize,
733}
734
735impl RouterSummary {
736 #[must_use]
738 pub fn all_succeeded(&self) -> bool {
739 self.failure_count == 0
740 }
741}
742
743#[derive(Default)]
748pub struct NotificationRouter {
749 sinks: Vec<Box<dyn NotificationSink>>,
750}
751
752impl std::fmt::Debug for NotificationRouter {
753 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754 let names: Vec<&str> = self.sinks.iter().map(|s| s.name()).collect();
755 f.debug_struct("NotificationRouter")
756 .field("sinks", &names)
757 .finish()
758 }
759}
760
761impl NotificationRouter {
762 #[must_use]
764 pub fn new() -> Self {
765 Self::default()
766 }
767
768 pub fn add_sink(&mut self, sink: Box<dyn NotificationSink>) {
770 self.sinks.push(sink);
771 }
772
773 #[must_use]
775 pub fn sink_count(&self) -> usize {
776 self.sinks.len()
777 }
778
779 pub fn dispatch(&self, event: &SinkEvent) -> RouterSummary {
781 let mut summary = RouterSummary::default();
782
783 for sink in &self.sinks {
784 let outcome = sink.dispatch(event);
785 if outcome.success {
786 if outcome.message.starts_with("skipped:") {
787 summary.skip_count += 1;
788 } else {
789 summary.success_count += 1;
790 }
791 } else {
792 summary.failure_count += 1;
793 }
794 summary.outcomes.push(outcome);
795 }
796
797 summary
798 }
799
800 #[must_use]
802 pub fn sink_names(&self) -> Vec<&str> {
803 self.sinks.iter().map(|s| s.name()).collect()
804 }
805}
806
807#[cfg(test)]
812mod tests {
813 use super::*;
814
815 #[test]
820 fn severity_ordering() {
821 assert!(EventSeverity::Info < EventSeverity::Warning);
822 assert!(EventSeverity::Warning < EventSeverity::Error);
823 assert!(EventSeverity::Error < EventSeverity::Critical);
824 }
825
826 #[test]
827 fn severity_labels() {
828 assert_eq!(EventSeverity::Info.label(), "info");
829 assert_eq!(EventSeverity::Warning.label(), "warning");
830 assert_eq!(EventSeverity::Error.label(), "error");
831 assert_eq!(EventSeverity::Critical.label(), "critical");
832 }
833
834 #[test]
839 fn sink_event_workflow_completed() {
840 let ev = SinkEvent::workflow_completed("wf-001", "transcode");
841 assert_eq!(ev.event_type, "workflow.completed");
842 assert_eq!(ev.severity, EventSeverity::Info);
843 assert_eq!(
844 ev.metadata.get("workflow_id").map(String::as_str),
845 Some("wf-001")
846 );
847 }
848
849 #[test]
850 fn sink_event_workflow_failed_has_dedup_key() {
851 let ev = SinkEvent::workflow_failed("wf-002", "ingest", "disk full");
852 assert!(ev.dedup_key.is_some());
853 assert!(ev.dedup_key.as_deref().unwrap().contains("wf-002"));
854 assert_eq!(ev.severity, EventSeverity::Error);
855 }
856
857 #[test]
858 fn sink_event_task_failed() {
859 let ev = SinkEvent::task_failed("wf-003", "qc-check", "bitrate too high");
860 assert_eq!(ev.event_type, "task.failed");
861 assert_eq!(ev.severity, EventSeverity::Warning);
862 assert_eq!(
863 ev.metadata.get("task_name").map(String::as_str),
864 Some("qc-check")
865 );
866 }
867
868 #[test]
873 fn slack_sink_builds_valid_json_payload() {
874 let sink = SlackSink::new("https://hooks.slack.com/T/B/xxx")
875 .with_channel("#ops")
876 .with_username("Bot");
877 let ev = SinkEvent::workflow_failed("wf-1", "pipe", "oom");
878 let msg = sink.build_message(&ev);
879 let parsed: serde_json::Value =
880 serde_json::from_str(&msg.payload_json).expect("valid JSON");
881 assert!(parsed["attachments"].is_array());
882 assert_eq!(parsed["channel"], "#ops");
883 assert_eq!(parsed["username"], "Bot");
884 }
885
886 #[test]
887 fn slack_sink_skips_below_min_severity() {
888 let sink = SlackSink::new("https://x").with_min_severity(EventSeverity::Error);
889 let ev = SinkEvent::workflow_completed("wf-1", "pipe");
890 let outcome = sink.dispatch(&ev);
891 assert!(outcome.success);
892 assert!(outcome.message.starts_with("skipped:"));
893 }
894
895 #[test]
896 fn slack_sink_dispatches_above_min_severity() {
897 let sink = SlackSink::new("https://hooks.slack.com/x");
898 let ev = SinkEvent::workflow_failed("wf-1", "pipe", "err");
899 let outcome = sink.dispatch(&ev);
900 assert!(outcome.success);
901 assert!(outcome.message.contains("POST"));
902 }
903
904 #[test]
909 fn email_sink_builds_correct_subject() {
910 let sink = EmailSink::new(SmtpConfig {
911 from: "ops@example.com".to_string(),
912 to: vec!["alerts@example.com".to_string()],
913 subject_prefix: "[OxiMedia]".to_string(),
914 });
915 let ev = SinkEvent::workflow_failed("wf-5", "encode", "crash");
916 let msg = sink.build_message(&ev);
917 assert!(msg.subject.starts_with("[OxiMedia]"));
918 assert!(msg.subject.contains("ERROR"));
919 }
920
921 #[test]
922 fn email_sink_body_contains_metadata() {
923 let sink = EmailSink::new(SmtpConfig {
924 from: "a@b.com".to_string(),
925 to: vec!["c@d.com".to_string()],
926 subject_prefix: "[X]".to_string(),
927 });
928 let ev = SinkEvent::workflow_failed("wf-6", "pipe", "oom").with_meta("region", "eu-west-1");
929 let msg = sink.build_message(&ev);
930 assert!(msg.body.contains("region"));
931 assert!(msg.body.contains("eu-west-1"));
932 }
933
934 #[test]
935 fn email_sink_skips_info_by_default() {
936 let sink = EmailSink::new(SmtpConfig {
937 from: "a@b.com".to_string(),
938 to: vec![],
939 subject_prefix: "X".to_string(),
940 });
941 let ev = SinkEvent::workflow_completed("wf-7", "pipe");
942 let outcome = sink.dispatch(&ev);
943 assert!(outcome.success);
944 assert!(outcome.message.starts_with("skipped:"));
945 }
946
947 #[test]
952 fn pagerduty_sink_payload_is_valid_json() {
953 let sink = PagerDutySink::new(PagerDutyConfig {
954 routing_key: "abc123".to_string(),
955 default_severity: PagerDutySeverity::Error,
956 service_name: "OxiMedia".to_string(),
957 });
958 let ev = SinkEvent::workflow_failed("wf-8", "pipe", "disk full");
959 let payload = sink.build_payload(&ev);
960 let parsed: serde_json::Value =
961 serde_json::from_str(&payload.body_json).expect("valid JSON");
962 assert_eq!(parsed["routing_key"], "abc123");
963 assert_eq!(parsed["event_action"], "trigger");
964 assert_eq!(parsed["payload"]["severity"], "error");
965 assert_eq!(parsed["payload"]["source"], "OxiMedia");
966 }
967
968 #[test]
969 fn pagerduty_sink_uses_event_dedup_key() {
970 let sink = PagerDutySink::new(PagerDutyConfig {
971 routing_key: "r".to_string(),
972 default_severity: PagerDutySeverity::Warning,
973 service_name: "S".to_string(),
974 });
975 let ev = SinkEvent::workflow_failed("wf-9", "p", "e");
976 let payload = sink.build_payload(&ev);
977 assert!(payload.dedup_key.contains("wf-9"));
978 }
979
980 #[test]
981 fn pagerduty_severity_mapping() {
982 assert_eq!(
983 PagerDutySeverity::from_event_severity(EventSeverity::Critical).as_str(),
984 "critical"
985 );
986 assert_eq!(
987 PagerDutySeverity::from_event_severity(EventSeverity::Info).as_str(),
988 "info"
989 );
990 }
991
992 #[test]
997 fn router_dispatches_to_all_sinks() {
998 let mut router = NotificationRouter::new();
999 router.add_sink(Box::new(
1000 SlackSink::new("https://hooks.slack.com/x").with_min_severity(EventSeverity::Info),
1001 ));
1002 router.add_sink(Box::new(
1003 EmailSink::new(SmtpConfig {
1004 from: "a@b.com".to_string(),
1005 to: vec!["c@d.com".to_string()],
1006 subject_prefix: "[X]".to_string(),
1007 })
1008 .with_min_severity(EventSeverity::Info),
1009 ));
1010 router.add_sink(Box::new(
1011 PagerDutySink::new(PagerDutyConfig {
1012 routing_key: "r".to_string(),
1013 default_severity: PagerDutySeverity::Error,
1014 service_name: "S".to_string(),
1015 })
1016 .with_min_severity(EventSeverity::Info),
1017 ));
1018
1019 let ev = SinkEvent::workflow_failed("wf-x", "pipe", "crash");
1020 let summary = router.dispatch(&ev);
1021 assert_eq!(summary.outcomes.len(), 3);
1022 assert_eq!(summary.success_count, 3);
1023 assert_eq!(summary.failure_count, 0);
1024 assert!(summary.all_succeeded());
1025 }
1026
1027 #[test]
1028 fn router_counts_skips_correctly() {
1029 let mut router = NotificationRouter::new();
1030 router.add_sink(Box::new(EmailSink::new(SmtpConfig {
1032 from: "a@b.com".to_string(),
1033 to: vec![],
1034 subject_prefix: "[X]".to_string(),
1035 })));
1036
1037 let ev = SinkEvent::workflow_completed("wf-z", "pipe");
1038 let summary = router.dispatch(&ev);
1039 assert_eq!(summary.skip_count, 1);
1040 assert_eq!(summary.success_count, 0);
1041 }
1042
1043 #[test]
1044 fn router_sink_names() {
1045 let mut router = NotificationRouter::new();
1046 router.add_sink(Box::new(SlackSink::new("u")));
1047 router.add_sink(Box::new(PagerDutySink::new(PagerDutyConfig {
1048 routing_key: "r".to_string(),
1049 default_severity: PagerDutySeverity::Error,
1050 service_name: "s".to_string(),
1051 })));
1052 let names = router.sink_names();
1053 assert_eq!(names, vec!["slack", "pagerduty"]);
1054 }
1055}