openzeppelin_monitor/services/notification/
script.rs1use async_trait::async_trait;
2
3use crate::{
4 models::{MonitorMatch, ScriptLanguage, TriggerTypeConfig},
5 services::notification::{NotificationError, ScriptExecutor},
6 services::trigger::ScriptExecutorFactory,
7};
8
9#[derive(Debug)]
15pub struct ScriptNotifier {
16 config: TriggerTypeConfig,
17}
18
19impl ScriptNotifier {
20 pub fn from_config(config: &TriggerTypeConfig) -> Result<Self, NotificationError> {
22 if let TriggerTypeConfig::Script { .. } = config {
23 Ok(Self {
24 config: config.clone(),
25 })
26 } else {
27 let msg = format!("Invalid script configuration: {:?}", config);
28 Err(NotificationError::config_error(msg, None, None))
29 }
30 }
31}
32
33#[async_trait]
34impl ScriptExecutor for ScriptNotifier {
35 async fn script_notify(
37 &self,
38 monitor_match: &MonitorMatch,
39 script_content: &(ScriptLanguage, String),
40 ) -> Result<(), NotificationError> {
41 match &self.config {
42 TriggerTypeConfig::Script {
43 script_path: _,
44 language,
45 arguments,
46 timeout_ms,
47 } => {
48 let executor = ScriptExecutorFactory::create(language, &script_content.1);
49
50 let result = executor
51 .execute(
52 monitor_match.clone(),
53 timeout_ms,
54 arguments.as_deref(),
55 true,
56 )
57 .await;
58
59 match result {
60 Ok(true) => Ok(()),
61 Ok(false) => Err(NotificationError::execution_error(
62 "Trigger script execution failed",
63 None,
64 None,
65 )),
66 Err(e) => {
67 return Err(NotificationError::execution_error(
68 format!("Trigger script execution error: {}", e),
69 Some(e.into()),
70 None,
71 ));
72 }
73 }
74 }
75 _ => Err(NotificationError::config_error(
76 "Invalid configuration type for ScriptNotifier",
77 None,
78 None,
79 )),
80 }
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use crate::{
88 models::{
89 EVMMonitorMatch, EVMTransactionReceipt, MatchConditions, Monitor, MonitorMatch,
90 NotificationMessage, SecretString, SecretValue, TriggerType,
91 },
92 services::notification::NotificationService,
93 utils::tests::{
94 builders::evm::monitor::MonitorBuilder, evm::transaction::TransactionBuilder,
95 trigger::TriggerBuilder,
96 },
97 };
98 use std::{collections::HashMap, time::Instant};
99
100 fn create_test_script_config() -> TriggerTypeConfig {
101 TriggerTypeConfig::Script {
102 language: ScriptLanguage::Python,
103 script_path: "test_script.py".to_string(),
104 arguments: Some(vec!["arg1".to_string(), "arg2".to_string()]),
105 timeout_ms: 1000,
106 }
107 }
108
109 fn create_test_monitor(
110 name: &str,
111 networks: Vec<&str>,
112 paused: bool,
113 triggers: Vec<&str>,
114 ) -> Monitor {
115 MonitorBuilder::new()
116 .name(name)
117 .networks(networks.into_iter().map(|s| s.to_string()).collect())
118 .paused(paused)
119 .triggers(triggers.into_iter().map(|s| s.to_string()).collect())
120 .build()
121 }
122
123 fn create_test_monitor_match() -> MonitorMatch {
124 MonitorMatch::EVM(Box::new(EVMMonitorMatch {
125 monitor: create_test_monitor("test_monitor", vec!["ethereum_mainnet"], false, vec![]),
126 transaction: TransactionBuilder::new().build(),
127 receipt: Some(EVMTransactionReceipt::default()),
128 logs: Some(vec![]),
129 network_slug: "ethereum_mainnet".to_string(),
130 matched_on: MatchConditions::default(),
131 matched_on_args: None,
132 }))
133 }
134
135 #[test]
136 fn test_from_config_with_script_config() {
137 let config = create_test_script_config();
138 let notifier = ScriptNotifier::from_config(&config);
139 assert!(notifier.is_ok());
140 }
141
142 #[test]
143 fn test_from_config_invalid_type() {
144 let config = TriggerTypeConfig::Slack {
146 slack_url: SecretValue::Plain(SecretString::new("random.url".to_string())),
147 message: NotificationMessage {
148 title: "Test Slack".to_string(),
149 body: "This is a test message".to_string(),
150 },
151 retry_policy: Default::default(),
152 };
153
154 let notifier = ScriptNotifier::from_config(&config);
155 assert!(notifier.is_err());
156
157 let error = notifier.unwrap_err();
158 assert!(matches!(error, NotificationError::ConfigError { .. }));
159 }
160
161 #[tokio::test]
162 async fn test_script_notify_with_valid_script() {
163 let config = create_test_script_config();
164 let notifier = ScriptNotifier::from_config(&config).unwrap();
165 let monitor_match = create_test_monitor_match();
166 let script_content = (ScriptLanguage::Python, "print(True)".to_string());
167
168 let result = notifier
169 .script_notify(&monitor_match, &script_content)
170 .await;
171 assert!(result.is_ok());
172 }
173
174 #[tokio::test]
175 async fn test_script_notify_succeeds_within_timeout() {
176 let config = TriggerTypeConfig::Script {
177 language: ScriptLanguage::Python,
178 script_path: "test_script.py".to_string(),
179 arguments: None,
180 timeout_ms: 1000, };
182 let notifier = ScriptNotifier::from_config(&config).unwrap();
183 let monitor_match = create_test_monitor_match();
184
185 let script_content = (
186 ScriptLanguage::Python,
187 "import time\ntime.sleep(0.3)\nprint(True)".to_string(),
188 );
189
190 let start_time = Instant::now();
191 let result = notifier
192 .script_notify(&monitor_match, &script_content)
193 .await;
194 let elapsed = start_time.elapsed();
195
196 assert!(result.is_ok());
197 assert!(elapsed.as_millis() >= 300);
199 assert!(elapsed.as_millis() < 1000);
201 }
202
203 #[tokio::test]
204 async fn test_script_notify_completes_before_timeout() {
205 let config = TriggerTypeConfig::Script {
206 language: ScriptLanguage::Python,
207 script_path: "test_script.py".to_string(),
208 arguments: None,
209 timeout_ms: 400, };
211 let notifier = ScriptNotifier::from_config(&config).unwrap();
212 let monitor_match = create_test_monitor_match();
213
214 let script_content = (
215 ScriptLanguage::Python,
216 "import time\ntime.sleep(0.5)\nprint(True)".to_string(),
217 );
218 let start_time = Instant::now();
219 let result = notifier
220 .script_notify(&monitor_match, &script_content)
221 .await;
222 let elapsed = start_time.elapsed();
223
224 assert!(result.is_err());
226 assert!(result
227 .unwrap_err()
228 .to_string()
229 .contains("Script execution timed out"));
230 assert!(elapsed.as_millis() >= 400 && elapsed.as_millis() < 600);
232 }
233
234 #[tokio::test]
235 async fn test_script_notify_with_invalid_script() {
236 let config = create_test_script_config();
237 let notifier = ScriptNotifier::from_config(&config).unwrap();
238 let monitor_match = create_test_monitor_match();
239 let script_content = (ScriptLanguage::Python, "invalid syntax".to_string());
240
241 let result = notifier
242 .script_notify(&monitor_match, &script_content)
243 .await;
244 assert!(result.is_err());
245
246 let error = result.unwrap_err();
247 assert!(matches!(error, NotificationError::ExecutionError { .. }));
248 }
249
250 #[tokio::test]
251 async fn test_script_notification_script_content_not_found() {
252 let service = NotificationService::new();
253 let script_config = TriggerTypeConfig::Script {
254 language: ScriptLanguage::Python,
255 script_path: "non_existent_script.py".to_string(), arguments: None,
257 timeout_ms: 1000,
258 };
259 let trigger = TriggerBuilder::new()
260 .name("test_script_missing")
261 .config(script_config) .trigger_type(TriggerType::Script)
263 .build();
264
265 let variables = HashMap::new();
266 let monitor_match = create_test_monitor_match();
267 let trigger_scripts = HashMap::new(); let result = service
270 .execute(&trigger, &variables, &monitor_match, &trigger_scripts)
271 .await;
272
273 assert!(result.is_err());
274 match result.unwrap_err() {
275 NotificationError::ConfigError(ctx) => {
276 assert!(ctx.message.contains("Script content not found"));
277 }
278 _ => panic!("Expected ConfigError for missing script content"),
279 }
280 }
281}