openzeppelin_monitor/services/notification/
mod.rs

1//! Notification service implementation.
2//!
3//! This module provides functionality to send notifications through various channels
4//! Supports variable substitution in message templates.
5
6use async_trait::async_trait;
7
8use std::{collections::HashMap, sync::Arc};
9
10mod email;
11mod error;
12pub mod payload_builder;
13mod pool;
14mod script;
15mod template_formatter;
16mod webhook;
17
18use crate::{
19	models::{
20		MonitorMatch, NotificationMessage, ScriptLanguage, Trigger, TriggerType, TriggerTypeConfig,
21		WebhookPayloadMode,
22	},
23	utils::{normalize_string, RetryConfig},
24};
25
26pub use email::{EmailContent, EmailNotifier, SmtpConfig};
27pub use error::NotificationError;
28pub use payload_builder::{
29	DiscordPayloadBuilder, GenericWebhookPayloadBuilder, SlackPayloadBuilder,
30	TelegramPayloadBuilder, WebhookPayloadBuilder,
31};
32pub use pool::NotificationClientPool;
33pub use script::ScriptNotifier;
34pub use webhook::{WebhookConfig, WebhookNotifier};
35
36/// A container for all components needed to configure and send a webhook notification.
37struct WebhookComponents {
38	config: WebhookConfig,
39	retry_policy: RetryConfig,
40	builder: Box<dyn WebhookPayloadBuilder>,
41}
42
43/// A type alias to simplify the complex tuple returned by the internal `match` statement.
44type WebhookParts = (
45	String,                          // url
46	NotificationMessage,             // message
47	Option<String>,                  // method
48	Option<String>,                  // secret
49	Option<HashMap<String, String>>, // headers
50	Box<dyn WebhookPayloadBuilder>,  // payload builder
51);
52
53/// A trait for trigger configurations that can be sent via webhook.
54/// This abstracts away the specific details of each webhook provider.
55trait AsWebhookComponents {
56	/// Consolidates the logic for creating webhook components from a trigger config.
57	/// It returns the generic `WebhookConfig`, RetryConfig and the specific `WebhookPayloadBuilder`
58	/// needed for the given trigger type.
59	fn as_webhook_components(&self) -> Result<WebhookComponents, NotificationError>;
60}
61
62impl AsWebhookComponents for TriggerTypeConfig {
63	fn as_webhook_components(&self) -> Result<WebhookComponents, NotificationError> {
64		let (url, message, method, secret, headers, builder): WebhookParts = match self {
65			TriggerTypeConfig::Webhook {
66				url,
67				message,
68				method,
69				secret,
70				headers,
71				..
72			} => (
73				url.as_ref().to_string(),
74				message.clone(),
75				method.clone(),
76				secret.as_ref().map(|s| s.as_ref().to_string()),
77				headers.clone(),
78				Box::new(GenericWebhookPayloadBuilder),
79			),
80			TriggerTypeConfig::Discord {
81				discord_url,
82				message,
83				..
84			} => (
85				discord_url.as_ref().to_string(),
86				message.clone(),
87				Some("POST".to_string()),
88				None,
89				None,
90				Box::new(DiscordPayloadBuilder),
91			),
92			TriggerTypeConfig::Telegram {
93				token,
94				message,
95				chat_id,
96				disable_web_preview,
97				..
98			} => (
99				format!("https://api.telegram.org/bot{}/sendMessage", token.as_ref()),
100				message.clone(),
101				Some("POST".to_string()),
102				None,
103				None,
104				Box::new(TelegramPayloadBuilder {
105					chat_id: chat_id.clone(),
106					disable_web_preview: disable_web_preview.unwrap_or(false),
107				}),
108			),
109			TriggerTypeConfig::Slack {
110				slack_url, message, ..
111			} => (
112				slack_url.as_ref().to_string(),
113				message.clone(),
114				Some("POST".to_string()),
115				None,
116				None,
117				Box::new(SlackPayloadBuilder),
118			),
119			_ => {
120				return Err(NotificationError::config_error(
121					format!("Trigger type is not webhook-compatible: {:?}", self),
122					None,
123					None,
124				))
125			}
126		};
127
128		// Construct the final WebhookConfig from the extracted parts.
129		let config = WebhookConfig {
130			url,
131			title: message.title,
132			body_template: message.body,
133			method,
134			secret,
135			headers,
136			url_params: None,
137			payload_fields: None,
138		};
139
140		// Use the retry policy from the trigger config
141		let retry_policy = self.get_retry_policy().ok_or_else(|| {
142			NotificationError::config_error(
143				"Webhook trigger config is unexpectedly missing a retry policy.",
144				None,
145				None,
146			)
147		})?;
148
149		Ok(WebhookComponents {
150			config,
151			retry_policy,
152			builder,
153		})
154	}
155}
156
157/// Interface for executing scripts
158///
159/// This Interface is used to execute scripts for notifications.
160/// It is implemented by the ScriptNotifier struct.
161#[async_trait]
162pub trait ScriptExecutor {
163	/// Executes a script to send a custom notifications
164	///
165	/// # Arguments
166	/// * `monitor_match` - The monitor match to send
167	/// * `script_content` - The script content to execute
168	///
169	/// # Returns
170	/// * `Result<(), NotificationError>` - Success or error
171	async fn script_notify(
172		&self,
173		monitor_match: &MonitorMatch,
174		script_content: &(ScriptLanguage, String),
175	) -> Result<(), NotificationError>;
176}
177
178/// Service for managing notifications across different channels
179pub struct NotificationService {
180	/// Client pool for managing notification clients (HTTP, SMTP)
181	client_pool: Arc<NotificationClientPool>,
182}
183
184impl NotificationService {
185	/// Creates a new notification service instance
186	pub fn new() -> Self {
187		NotificationService {
188			client_pool: Arc::new(NotificationClientPool::new()),
189		}
190	}
191
192	/// Executes a notification based on the trigger configuration
193	///
194	/// # Arguments
195	/// * `trigger` - Trigger containing the notification type and parameters
196	/// * `variables` - Variables to substitute in message templates
197	/// * `monitor_match` - Monitor match to send (needed for custom script trigger)
198	/// * `trigger_scripts` - Contains the script content to execute (needed for custom script
199	///   trigger)
200	///
201	/// # Returns
202	/// * `Result<(), NotificationError>` - Success or error
203	pub async fn execute(
204		&self,
205		trigger: &Trigger,
206		variables: &HashMap<String, String>,
207		monitor_match: &MonitorMatch,
208		trigger_scripts: &HashMap<String, (ScriptLanguage, String)>,
209	) -> Result<(), NotificationError> {
210		match &trigger.trigger_type {
211			// Match Webhook-based triggers
212			TriggerType::Slack
213			| TriggerType::Discord
214			| TriggerType::Webhook
215			| TriggerType::Telegram => {
216				// Check if this is a webhook trigger with raw payload mode
217				let is_raw_mode = matches!(
218					&trigger.config,
219					TriggerTypeConfig::Webhook {
220						payload_mode: WebhookPayloadMode::Raw,
221						..
222					}
223				);
224
225				// Use the Webhookable trait to get config, retry policy and payload builder
226				let components = trigger.config.as_webhook_components()?;
227
228				// Get or create the HTTP client from the pool based on the retry policy
229				let http_client = self
230					.client_pool
231					.get_or_create_http_client(&components.retry_policy)
232					.await
233					.map_err(|e| {
234						NotificationError::execution_error(
235							"Failed to get or create HTTP client from pool".to_string(),
236							Some(e.into()),
237							None,
238						)
239					})?;
240
241				// Build the payload based on the mode
242				let payload = if is_raw_mode {
243					// In raw mode, serialize the MonitorMatch directly
244					serde_json::to_value(monitor_match).map_err(|e| {
245						NotificationError::internal_error(
246							format!("Failed to serialize MonitorMatch: {}", e),
247							Some(e.into()),
248							None,
249						)
250					})?
251				} else {
252					// In template mode, use the payload builder
253					components.builder.build_payload(
254						&components.config.title,
255						&components.config.body_template,
256						variables,
257					)
258				};
259
260				// Create the notifier
261				let notifier = WebhookNotifier::new(components.config, http_client)?;
262
263				notifier.notify_json(&payload).await?;
264			}
265			TriggerType::Email => {
266				// Extract SMTP configuration from the trigger
267				let smtp_config = match &trigger.config {
268					TriggerTypeConfig::Email {
269						host,
270						port,
271						username,
272						password,
273						..
274					} => SmtpConfig {
275						host: host.clone(),
276						port: port.unwrap_or(465),
277						username: username.as_ref().to_string(),
278						password: password.as_ref().to_string(),
279					},
280					_ => {
281						return Err(NotificationError::config_error(
282							"Invalid email configuration".to_string(),
283							None,
284							None,
285						));
286					}
287				};
288
289				// Get or create the SMTP client from the pool
290				let smtp_client = self
291					.client_pool
292					.get_or_create_smtp_client(&smtp_config)
293					.await
294					.map_err(|e| {
295						NotificationError::execution_error(
296							"Failed to get SMTP client from pool".to_string(),
297							Some(e.into()),
298							None,
299						)
300					})?;
301
302				let notifier = EmailNotifier::from_config(&trigger.config, smtp_client)?;
303				let message = EmailNotifier::format_message(notifier.body_template(), variables);
304				notifier.notify(&message).await?;
305			}
306			TriggerType::Script => {
307				let notifier = ScriptNotifier::from_config(&trigger.config)?;
308				let monitor_name = match monitor_match {
309					MonitorMatch::EVM(evm_match) => &evm_match.monitor.name,
310					MonitorMatch::Stellar(stellar_match) => &stellar_match.monitor.name,
311					MonitorMatch::Midnight(midnight_match) => &midnight_match.monitor.name,
312					MonitorMatch::Solana(solana_match) => &solana_match.monitor.name,
313				};
314				let script_path = match &trigger.config {
315					TriggerTypeConfig::Script { script_path, .. } => script_path,
316					_ => {
317						return Err(NotificationError::config_error(
318							"Invalid script configuration".to_string(),
319							None,
320							None,
321						));
322					}
323				};
324				let script = trigger_scripts
325					.get(&format!(
326						"{}|{}",
327						normalize_string(monitor_name),
328						script_path
329					))
330					.ok_or_else(|| {
331						NotificationError::config_error(
332							"Script content not found".to_string(),
333							None,
334							None,
335						)
336					});
337				let script_content = match &script {
338					Ok(content) => content,
339					Err(e) => {
340						return Err(NotificationError::config_error(e.to_string(), None, None));
341					}
342				};
343
344				notifier
345					.script_notify(monitor_match, script_content)
346					.await?;
347			}
348		}
349		Ok(())
350	}
351}
352
353impl Default for NotificationService {
354	fn default() -> Self {
355		Self::new()
356	}
357}
358
359#[cfg(test)]
360mod tests {
361	use super::*;
362	use crate::{
363		models::{
364			AddressWithSpec, EVMMonitorMatch, EVMTransactionReceipt, EventCondition,
365			FunctionCondition, MatchConditions, Monitor, MonitorMatch, NotificationMessage,
366			ScriptLanguage, SecretString, SecretValue, SolanaMonitorMatch, TransactionCondition,
367			TriggerType,
368		},
369		utils::tests::builders::{
370			evm::monitor::MonitorBuilder as EVMMonitorBuilder,
371			evm::transaction::TransactionBuilder as EVMTransactionBuilder,
372			solana::block::BlockBuilder as SolanaBlockBuilder,
373			solana::monitor::MonitorBuilder as SolanaMonitorBuilder,
374			solana::transaction::TransactionBuilder as SolanaTransactionBuilder,
375			trigger::TriggerBuilder,
376		},
377	};
378	use std::collections::HashMap;
379
380	fn create_test_evm_monitor(
381		event_conditions: Vec<EventCondition>,
382		function_conditions: Vec<FunctionCondition>,
383		transaction_conditions: Vec<TransactionCondition>,
384		addresses: Vec<AddressWithSpec>,
385	) -> Monitor {
386		let mut builder = EVMMonitorBuilder::new()
387			.name("test")
388			.networks(vec!["evm_mainnet".to_string()]);
389
390		// Add all conditions
391		for event in event_conditions {
392			builder = builder.event(&event.signature, event.expression);
393		}
394		for function in function_conditions {
395			builder = builder.function(&function.signature, function.expression);
396		}
397		for transaction in transaction_conditions {
398			builder = builder.transaction(transaction.status, transaction.expression);
399		}
400
401		// Add addresses
402		for addr in addresses {
403			builder = builder.address(&addr.address);
404		}
405
406		builder.build()
407	}
408
409	fn create_test_solana_monitor(
410		event_conditions: Vec<EventCondition>,
411		function_conditions: Vec<FunctionCondition>,
412		transaction_conditions: Vec<TransactionCondition>,
413		addresses: Vec<AddressWithSpec>,
414	) -> Monitor {
415		let mut builder = SolanaMonitorBuilder::new()
416			.name("test_solana")
417			.networks(vec!["solana_mainnet".to_string()]);
418
419		// Add all conditions
420		for event in event_conditions {
421			builder = builder.event(&event.signature, event.expression);
422		}
423		for function in function_conditions {
424			builder = builder.function(&function.signature, function.expression);
425		}
426		for transaction in transaction_conditions {
427			builder = builder.transaction(transaction.status, transaction.expression);
428		}
429
430		// Add addresses
431		for addr in addresses {
432			builder = builder.address(&addr.address);
433		}
434
435		builder.build()
436	}
437
438	fn create_mock_evm_monitor_match() -> MonitorMatch {
439		MonitorMatch::EVM(Box::new(EVMMonitorMatch {
440			monitor: create_test_evm_monitor(vec![], vec![], vec![], vec![]),
441			transaction: EVMTransactionBuilder::new().build(),
442			receipt: Some(EVMTransactionReceipt::default()),
443			logs: Some(vec![]),
444			network_slug: "evm_mainnet".to_string(),
445			matched_on: MatchConditions {
446				functions: vec![],
447				events: vec![],
448				transactions: vec![],
449			},
450			matched_on_args: None,
451		}))
452	}
453
454	fn create_mock_solana_monitor_match() -> MonitorMatch {
455		MonitorMatch::Solana(Box::new(SolanaMonitorMatch {
456			monitor: create_test_solana_monitor(vec![], vec![], vec![], vec![]),
457			transaction: SolanaTransactionBuilder::new().build(),
458			block: SolanaBlockBuilder::new().build(),
459			network_slug: "solana_mainnet".to_string(),
460			matched_on: MatchConditions {
461				functions: vec![],
462				events: vec![],
463				transactions: vec![],
464			},
465			matched_on_args: None,
466		}))
467	}
468
469	// Keep the old function name for backward compatibility
470	fn create_mock_monitor_match() -> MonitorMatch {
471		create_mock_evm_monitor_match()
472	}
473
474	#[tokio::test]
475	async fn test_slack_notification_invalid_config() {
476		let service = NotificationService::new();
477
478		let trigger = TriggerBuilder::new()
479			.name("test_slack")
480			.script("invalid", ScriptLanguage::Python)
481			.trigger_type(TriggerType::Slack) // Intentionally wrong config type
482			.build();
483
484		let variables = HashMap::new();
485		let result = service
486			.execute(
487				&trigger,
488				&variables,
489				&create_mock_monitor_match(),
490				&HashMap::new(),
491			)
492			.await;
493		assert!(result.is_err());
494		match result {
495			Err(NotificationError::ConfigError(ctx)) => {
496				assert!(ctx
497					.message
498					.contains("Trigger type is not webhook-compatible"));
499			}
500			_ => panic!("Expected ConfigError"),
501		}
502	}
503
504	#[tokio::test]
505	async fn test_email_notification_invalid_config() {
506		let service = NotificationService::new();
507
508		let trigger = TriggerBuilder::new()
509			.name("test_email")
510			.script("invalid", ScriptLanguage::Python)
511			.trigger_type(TriggerType::Email) // Intentionally wrong config type
512			.build();
513
514		let variables = HashMap::new();
515		let result = service
516			.execute(
517				&trigger,
518				&variables,
519				&create_mock_monitor_match(),
520				&HashMap::new(),
521			)
522			.await;
523		assert!(result.is_err());
524		match result {
525			Err(NotificationError::ConfigError(ctx)) => {
526				assert!(ctx.message.contains("Invalid email configuration"));
527			}
528			_ => panic!("Expected ConfigError"),
529		}
530	}
531
532	#[tokio::test]
533	async fn test_webhook_notification_invalid_config() {
534		let service = NotificationService::new();
535
536		let trigger = TriggerBuilder::new()
537			.name("test_webhook")
538			.script("invalid", ScriptLanguage::Python)
539			.trigger_type(TriggerType::Webhook) // Intentionally wrong config type
540			.build();
541
542		let variables = HashMap::new();
543		let result = service
544			.execute(
545				&trigger,
546				&variables,
547				&create_mock_monitor_match(),
548				&HashMap::new(),
549			)
550			.await;
551		assert!(result.is_err());
552		match result {
553			Err(NotificationError::ConfigError(ctx)) => {
554				assert!(ctx
555					.message
556					.contains("Trigger type is not webhook-compatible"));
557			}
558			_ => panic!("Expected ConfigError"),
559		}
560	}
561
562	#[tokio::test]
563	async fn test_discord_notification_invalid_config() {
564		let service = NotificationService::new();
565
566		let trigger = TriggerBuilder::new()
567			.name("test_discord")
568			.script("invalid", ScriptLanguage::Python)
569			.trigger_type(TriggerType::Discord) // Intentionally wrong config type
570			.build();
571
572		let variables = HashMap::new();
573		let result = service
574			.execute(
575				&trigger,
576				&variables,
577				&create_mock_monitor_match(),
578				&HashMap::new(),
579			)
580			.await;
581		assert!(result.is_err());
582		match result {
583			Err(NotificationError::ConfigError(ctx)) => {
584				assert!(ctx
585					.message
586					.contains("Trigger type is not webhook-compatible"));
587			}
588			_ => panic!("Expected ConfigError"),
589		}
590	}
591
592	#[tokio::test]
593	async fn test_telegram_notification_invalid_config() {
594		let service = NotificationService::new();
595
596		let trigger = TriggerBuilder::new()
597			.name("test_telegram")
598			.script("invalid", ScriptLanguage::Python)
599			.trigger_type(TriggerType::Telegram) // Intentionally wrong config type
600			.build();
601
602		let variables = HashMap::new();
603		let result = service
604			.execute(
605				&trigger,
606				&variables,
607				&create_mock_monitor_match(),
608				&HashMap::new(),
609			)
610			.await;
611		assert!(result.is_err());
612		match result {
613			Err(NotificationError::ConfigError(ctx)) => {
614				assert!(ctx
615					.message
616					.contains("Trigger type is not webhook-compatible"));
617			}
618			_ => panic!("Expected ConfigError"),
619		}
620	}
621
622	#[tokio::test]
623	async fn test_script_notification_invalid_config() {
624		let service = NotificationService::new();
625
626		let trigger = TriggerBuilder::new()
627			.name("test_script")
628			.telegram("invalid", "invalid", false)
629			.trigger_type(TriggerType::Script) // Intentionally wrong config type
630			.build();
631
632		let variables = HashMap::new();
633
634		let result = service
635			.execute(
636				&trigger,
637				&variables,
638				&create_mock_monitor_match(),
639				&HashMap::new(),
640			)
641			.await;
642
643		assert!(result.is_err());
644		match result {
645			Err(NotificationError::ConfigError(ctx)) => {
646				assert!(ctx.message.contains("Invalid script configuration"));
647			}
648			_ => panic!("Expected ConfigError"),
649		}
650	}
651
652	#[test]
653	fn as_webhook_components_trait_for_slack_config() {
654		let title = "Slack Title";
655		let message = "Slack Body";
656
657		let slack_config = TriggerTypeConfig::Slack {
658			slack_url: SecretValue::Plain(SecretString::new(
659				"https://slack.example.com".to_string(),
660			)),
661			message: NotificationMessage {
662				title: title.to_string(),
663				body: message.to_string(),
664			},
665			retry_policy: RetryConfig::default(),
666		};
667
668		let components = slack_config.as_webhook_components().unwrap();
669
670		// Assert WebhookConfig is correct
671		assert_eq!(components.config.url, "https://slack.example.com");
672		assert_eq!(components.config.title, title);
673		assert_eq!(components.config.body_template, message);
674		assert_eq!(components.config.method, Some("POST".to_string()));
675		assert!(components.config.secret.is_none());
676
677		// Assert the builder creates the correct payload
678		let payload = components
679			.builder
680			.build_payload(title, message, &HashMap::new());
681		assert!(
682			payload.get("blocks").is_some(),
683			"Expected a Slack payload with 'blocks'"
684		);
685		assert!(
686			payload.get("content").is_none(),
687			"Did not expect a Discord payload"
688		);
689	}
690
691	#[test]
692	fn as_webhook_components_trait_for_discord_config() {
693		let title = "Discord Title";
694		let message = "Discord Body";
695		let discord_config = TriggerTypeConfig::Discord {
696			discord_url: SecretValue::Plain(SecretString::new(
697				"https://discord.example.com".to_string(),
698			)),
699			message: NotificationMessage {
700				title: title.to_string(),
701				body: message.to_string(),
702			},
703			retry_policy: RetryConfig::default(),
704		};
705
706		let components = discord_config.as_webhook_components().unwrap();
707
708		// Assert WebhookConfig is correct
709		assert_eq!(components.config.url, "https://discord.example.com");
710		assert_eq!(components.config.title, title);
711		assert_eq!(components.config.body_template, message);
712		assert_eq!(components.config.method, Some("POST".to_string()));
713
714		// Assert the builder creates the correct payload
715		let payload = components
716			.builder
717			.build_payload(title, message, &HashMap::new());
718		assert!(
719			payload.get("content").is_some(),
720			"Expected a Discord payload with 'content'"
721		);
722		assert!(
723			payload.get("blocks").is_none(),
724			"Did not expect a Slack payload"
725		);
726	}
727
728	#[test]
729	fn as_webhook_components_trait_for_telegram_config() {
730		let title = "Telegram Title";
731		let message = "Telegram Body";
732		let telegram_config = TriggerTypeConfig::Telegram {
733			token: SecretValue::Plain(SecretString::new("test-token".to_string())),
734			chat_id: "12345".to_string(),
735			disable_web_preview: Some(true),
736			message: NotificationMessage {
737				title: title.to_string(),
738				body: message.to_string(),
739			},
740			retry_policy: RetryConfig::default(),
741		};
742
743		let components = telegram_config.as_webhook_components().unwrap();
744
745		// Assert WebhookConfig is correct
746		assert_eq!(
747			components.config.url,
748			"https://api.telegram.org/bottest-token/sendMessage"
749		);
750		assert_eq!(components.config.title, title);
751		assert_eq!(components.config.body_template, message);
752
753		// Assert the builder creates the correct payload
754		let payload = components
755			.builder
756			.build_payload(title, message, &HashMap::new());
757		assert_eq!(payload.get("chat_id").unwrap(), "12345");
758		assert_eq!(payload.get("disable_web_page_preview").unwrap(), &true);
759		assert!(payload.get("text").is_some());
760	}
761
762	#[test]
763	fn as_webhook_components_trait_for_generic_webhook_config() {
764		let title = "Generic Title";
765		let body_template = "Generic Body";
766		let webhook_config = TriggerTypeConfig::Webhook {
767			url: SecretValue::Plain(SecretString::new("https://generic.example.com".to_string())),
768			message: NotificationMessage {
769				title: title.to_string(),
770				body: body_template.to_string(),
771			},
772			method: Some("PUT".to_string()),
773			secret: Some(SecretValue::Plain(SecretString::new(
774				"my-secret".to_string(),
775			))),
776			headers: Some([("X-Custom".to_string(), "Value".to_string())].into()),
777			payload_mode: WebhookPayloadMode::default(),
778			retry_policy: RetryConfig::default(),
779		};
780
781		let components = webhook_config.as_webhook_components().unwrap();
782
783		// Assert WebhookConfig is correct
784		assert_eq!(components.config.url, "https://generic.example.com");
785		assert_eq!(components.config.method, Some("PUT".to_string()));
786		assert_eq!(components.config.secret, Some("my-secret".to_string()));
787		assert!(components.config.headers.is_some());
788		assert_eq!(
789			components.config.headers.unwrap().get("X-Custom").unwrap(),
790			"Value"
791		);
792
793		// Assert the builder creates the correct payload
794		let payload = components
795			.builder
796			.build_payload(title, body_template, &HashMap::new());
797		assert!(payload.get("title").is_some());
798		assert!(payload.get("body").is_some());
799	}
800
801	// ============================================================================
802	// Solana equivalent tests - ensuring parity with EVM tests
803	// ============================================================================
804
805	#[tokio::test]
806	async fn test_slack_notification_invalid_config_solana() {
807		let service = NotificationService::new();
808
809		let trigger = TriggerBuilder::new()
810			.name("test_slack_solana")
811			.script("invalid", ScriptLanguage::Python)
812			.trigger_type(TriggerType::Slack) // Intentionally wrong config type
813			.build();
814
815		let variables = HashMap::new();
816		let result = service
817			.execute(
818				&trigger,
819				&variables,
820				&create_mock_solana_monitor_match(),
821				&HashMap::new(),
822			)
823			.await;
824		assert!(result.is_err());
825		match result {
826			Err(NotificationError::ConfigError(ctx)) => {
827				assert!(ctx
828					.message
829					.contains("Trigger type is not webhook-compatible"));
830			}
831			_ => panic!("Expected ConfigError"),
832		}
833	}
834
835	#[tokio::test]
836	async fn test_email_notification_invalid_config_solana() {
837		let service = NotificationService::new();
838
839		let trigger = TriggerBuilder::new()
840			.name("test_email_solana")
841			.script("invalid", ScriptLanguage::Python)
842			.trigger_type(TriggerType::Email) // Intentionally wrong config type
843			.build();
844
845		let variables = HashMap::new();
846		let result = service
847			.execute(
848				&trigger,
849				&variables,
850				&create_mock_solana_monitor_match(),
851				&HashMap::new(),
852			)
853			.await;
854		assert!(result.is_err());
855		match result {
856			Err(NotificationError::ConfigError(ctx)) => {
857				assert!(ctx.message.contains("Invalid email configuration"));
858			}
859			_ => panic!("Expected ConfigError"),
860		}
861	}
862
863	#[tokio::test]
864	async fn test_webhook_notification_invalid_config_solana() {
865		let service = NotificationService::new();
866
867		let trigger = TriggerBuilder::new()
868			.name("test_webhook_solana")
869			.script("invalid", ScriptLanguage::Python)
870			.trigger_type(TriggerType::Webhook) // Intentionally wrong config type
871			.build();
872
873		let variables = HashMap::new();
874		let result = service
875			.execute(
876				&trigger,
877				&variables,
878				&create_mock_solana_monitor_match(),
879				&HashMap::new(),
880			)
881			.await;
882		assert!(result.is_err());
883		match result {
884			Err(NotificationError::ConfigError(ctx)) => {
885				assert!(ctx
886					.message
887					.contains("Trigger type is not webhook-compatible"));
888			}
889			_ => panic!("Expected ConfigError"),
890		}
891	}
892
893	#[tokio::test]
894	async fn test_discord_notification_invalid_config_solana() {
895		let service = NotificationService::new();
896
897		let trigger = TriggerBuilder::new()
898			.name("test_discord_solana")
899			.script("invalid", ScriptLanguage::Python)
900			.trigger_type(TriggerType::Discord) // Intentionally wrong config type
901			.build();
902
903		let variables = HashMap::new();
904		let result = service
905			.execute(
906				&trigger,
907				&variables,
908				&create_mock_solana_monitor_match(),
909				&HashMap::new(),
910			)
911			.await;
912		assert!(result.is_err());
913		match result {
914			Err(NotificationError::ConfigError(ctx)) => {
915				assert!(ctx
916					.message
917					.contains("Trigger type is not webhook-compatible"));
918			}
919			_ => panic!("Expected ConfigError"),
920		}
921	}
922
923	#[tokio::test]
924	async fn test_telegram_notification_invalid_config_solana() {
925		let service = NotificationService::new();
926
927		let trigger = TriggerBuilder::new()
928			.name("test_telegram_solana")
929			.script("invalid", ScriptLanguage::Python)
930			.trigger_type(TriggerType::Telegram) // Intentionally wrong config type
931			.build();
932
933		let variables = HashMap::new();
934		let result = service
935			.execute(
936				&trigger,
937				&variables,
938				&create_mock_solana_monitor_match(),
939				&HashMap::new(),
940			)
941			.await;
942		assert!(result.is_err());
943		match result {
944			Err(NotificationError::ConfigError(ctx)) => {
945				assert!(ctx
946					.message
947					.contains("Trigger type is not webhook-compatible"));
948			}
949			_ => panic!("Expected ConfigError"),
950		}
951	}
952
953	#[tokio::test]
954	async fn test_script_notification_invalid_config_solana() {
955		let service = NotificationService::new();
956
957		let trigger = TriggerBuilder::new()
958			.name("test_script_solana")
959			.telegram("invalid", "invalid", false)
960			.trigger_type(TriggerType::Script) // Intentionally wrong config type
961			.build();
962
963		let variables = HashMap::new();
964
965		let result = service
966			.execute(
967				&trigger,
968				&variables,
969				&create_mock_solana_monitor_match(),
970				&HashMap::new(),
971			)
972			.await;
973
974		assert!(result.is_err());
975		match result {
976			Err(NotificationError::ConfigError(ctx)) => {
977				assert!(ctx.message.contains("Invalid script configuration"));
978			}
979			_ => panic!("Expected ConfigError"),
980		}
981	}
982}