openzeppelin_relayer/services/plugins/
mod.rs

1//! Plugins service module for handling plugins execution and interaction with relayer
2
3use std::{fmt, sync::Arc};
4
5use crate::observability::request_id::get_request_id;
6use crate::{
7    jobs::JobProducerTrait,
8    models::{
9        AppState, NetworkRepoModel, NotificationRepoModel, PluginCallRequest, PluginMetadata,
10        PluginModel, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel,
11    },
12    repositories::{
13        ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository,
14        Repository, TransactionCounterTrait, TransactionRepository,
15    },
16};
17use actix_web::web;
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21use uuid::Uuid;
22
23pub mod runner;
24pub use runner::*;
25
26pub mod relayer_api;
27pub use relayer_api::*;
28
29pub mod script_executor;
30pub use script_executor::*;
31
32pub mod socket;
33pub use socket::*;
34
35#[cfg(test)]
36use mockall::automock;
37
38#[derive(Error, Debug, Serialize)]
39pub enum PluginError {
40    #[error("Socket error: {0}")]
41    SocketError(String),
42    #[error("Plugin error: {0}")]
43    PluginError(String),
44    #[error("Relayer error: {0}")]
45    RelayerError(String),
46    #[error("Plugin execution error: {0}")]
47    PluginExecutionError(String),
48    #[error("Script execution timed out after {0} seconds")]
49    ScriptTimeout(u64),
50    #[error("Invalid method: {0}")]
51    InvalidMethod(String),
52    #[error("Invalid payload: {0}")]
53    InvalidPayload(String),
54    #[error("{0}")]
55    HandlerError(Box<PluginHandlerPayload>),
56}
57
58impl PluginError {
59    /// Enriches the error with traces if it's a HandlerError variant.
60    /// For other variants, returns the error unchanged.
61    pub fn with_traces(self, traces: Vec<serde_json::Value>) -> Self {
62        match self {
63            PluginError::HandlerError(mut payload) => {
64                payload.append_traces(traces);
65                PluginError::HandlerError(payload)
66            }
67            other => other,
68        }
69    }
70}
71
72impl From<PluginError> for String {
73    fn from(error: PluginError) -> Self {
74        error.to_string()
75    }
76}
77
78#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
79pub struct PluginCallResponse {
80    /// The plugin result, parsed as JSON when possible; otherwise a string
81    pub result: serde_json::Value,
82    /// Optional metadata captured during plugin execution (logs/traces)
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub metadata: Option<PluginMetadata>,
85}
86
87#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
88pub struct PluginHandlerError {
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub code: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub details: Option<serde_json::Value>,
93}
94
95#[derive(Debug)]
96pub struct PluginHandlerResponse {
97    pub status: u16,
98    pub message: String,
99    pub error: PluginHandlerError,
100    pub metadata: Option<PluginMetadata>,
101}
102
103#[derive(Debug, Serialize)]
104pub struct PluginHandlerPayload {
105    pub status: u16,
106    pub message: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub code: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub details: Option<serde_json::Value>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub logs: Option<Vec<LogEntry>>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub traces: Option<Vec<serde_json::Value>>,
115}
116
117impl PluginHandlerPayload {
118    fn append_traces(&mut self, traces: Vec<serde_json::Value>) {
119        match &mut self.traces {
120            Some(existing) => existing.extend(traces),
121            None => self.traces = Some(traces),
122        }
123    }
124
125    fn into_response(self, emit_logs: bool, emit_traces: bool) -> PluginHandlerResponse {
126        let logs = if emit_logs { self.logs } else { None };
127        let traces = if emit_traces { self.traces } else { None };
128        let message = derive_handler_message(&self.message, logs.as_deref());
129        let metadata = build_metadata(logs, traces);
130
131        PluginHandlerResponse {
132            status: self.status,
133            message,
134            error: PluginHandlerError {
135                code: self.code,
136                details: self.details,
137            },
138            metadata,
139        }
140    }
141}
142
143impl fmt::Display for PluginHandlerPayload {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.write_str(&self.message)
146    }
147}
148
149fn derive_handler_message(message: &str, logs: Option<&[LogEntry]>) -> String {
150    if !message.trim().is_empty() {
151        return message.to_string();
152    }
153
154    if let Some(logs) = logs {
155        if let Some(entry) = logs
156            .iter()
157            .rev()
158            .find(|entry| matches!(entry.level, LogLevel::Error | LogLevel::Warn))
159        {
160            return entry.message.clone();
161        }
162
163        if let Some(entry) = logs.last() {
164            return entry.message.clone();
165        }
166    }
167
168    "Plugin execution failed".to_string()
169}
170
171fn build_metadata(
172    logs: Option<Vec<LogEntry>>,
173    traces: Option<Vec<serde_json::Value>>,
174) -> Option<PluginMetadata> {
175    if logs.is_some() || traces.is_some() {
176        Some(PluginMetadata { logs, traces })
177    } else {
178        None
179    }
180}
181
182#[derive(Debug)]
183pub enum PluginCallResult {
184    Success(PluginCallResponse),
185    Handler(PluginHandlerResponse),
186    Fatal(PluginError),
187}
188
189#[derive(Default)]
190pub struct PluginService<R: PluginRunnerTrait> {
191    runner: R,
192}
193
194impl<R: PluginRunnerTrait> PluginService<R> {
195    pub fn new(runner: R) -> Self {
196        Self { runner }
197    }
198
199    fn resolve_plugin_path(plugin_path: &str) -> String {
200        if plugin_path.starts_with("plugins/") {
201            plugin_path.to_string()
202        } else {
203            format!("plugins/{plugin_path}")
204        }
205    }
206
207    #[allow(clippy::type_complexity)]
208    async fn call_plugin<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
209        &self,
210        plugin: PluginModel,
211        plugin_call_request: PluginCallRequest,
212        state: Arc<ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>>,
213    ) -> PluginCallResult
214    where
215        J: JobProducerTrait + Send + Sync + 'static,
216        RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
217        TR: TransactionRepository
218            + Repository<TransactionRepoModel, String>
219            + Send
220            + Sync
221            + 'static,
222        NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
223        NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
224        SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
225        TCR: TransactionCounterTrait + Send + Sync + 'static,
226        PR: PluginRepositoryTrait + Send + Sync + 'static,
227        AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
228    {
229        let socket_path = format!("/tmp/{}.sock", Uuid::new_v4());
230        let script_path = Self::resolve_plugin_path(&plugin.path);
231        let script_params = plugin_call_request.params.to_string();
232
233        let result = self
234            .runner
235            .run(
236                plugin.id.clone(),
237                &socket_path,
238                script_path,
239                plugin.timeout,
240                script_params,
241                get_request_id(),
242                state,
243            )
244            .await;
245
246        match result {
247            Ok(script_result) => {
248                // Include logs/traces only if enabled via plugin config
249                let logs = if plugin.emit_logs {
250                    Some(script_result.logs)
251                } else {
252                    None
253                };
254                let traces = if plugin.emit_traces {
255                    Some(script_result.trace)
256                } else {
257                    None
258                };
259                let metadata = build_metadata(logs, traces);
260
261                // Parse return_value string into JSON when possible; otherwise string
262                let result = if script_result.return_value.trim() == "undefined" {
263                    serde_json::Value::Null
264                } else {
265                    serde_json::from_str::<serde_json::Value>(&script_result.return_value)
266                        .unwrap_or(serde_json::Value::String(script_result.return_value))
267                };
268
269                PluginCallResult::Success(PluginCallResponse { result, metadata })
270            }
271            Err(e) => match e {
272                PluginError::HandlerError(payload) => {
273                    let failure = payload.into_response(plugin.emit_logs, plugin.emit_traces);
274                    let has_logs = failure
275                        .metadata
276                        .as_ref()
277                        .and_then(|meta| meta.logs.as_ref())
278                        .is_some();
279                    let has_traces = failure
280                        .metadata
281                        .as_ref()
282                        .and_then(|meta| meta.traces.as_ref())
283                        .is_some();
284
285                    tracing::debug!(
286                        status = failure.status,
287                        message = %failure.message,
288                        code = ?failure.error.code.as_ref(),
289                        details = ?failure.error.details.as_ref(),
290                        has_logs,
291                        has_traces,
292                        "Plugin handler returned error"
293                    );
294
295                    PluginCallResult::Handler(failure)
296                }
297                other => {
298                    // This is an actual execution/infrastructure failure
299                    tracing::error!("Plugin execution failed: {:?}", other);
300                    PluginCallResult::Fatal(other)
301                }
302            },
303        }
304    }
305}
306
307#[async_trait]
308#[cfg_attr(test, automock)]
309pub trait PluginServiceTrait<J, TR, RR, NR, NFR, SR, TCR, PR, AKR>: Send + Sync
310where
311    J: JobProducerTrait + 'static,
312    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
313    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
314    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
315    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
316    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
317    TCR: TransactionCounterTrait + Send + Sync + 'static,
318    PR: PluginRepositoryTrait + Send + Sync + 'static,
319    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
320{
321    fn new(runner: PluginRunner) -> Self;
322    async fn call_plugin(
323        &self,
324        plugin: PluginModel,
325        plugin_call_request: PluginCallRequest,
326        state: Arc<web::ThinData<AppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>>>,
327    ) -> PluginCallResult;
328}
329
330#[async_trait]
331impl<J, TR, RR, NR, NFR, SR, TCR, PR, AKR> PluginServiceTrait<J, TR, RR, NR, NFR, SR, TCR, PR, AKR>
332    for PluginService<PluginRunner>
333where
334    J: JobProducerTrait + 'static,
335    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
336    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
337    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
338    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
339    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
340    TCR: TransactionCounterTrait + Send + Sync + 'static,
341    PR: PluginRepositoryTrait + Send + Sync + 'static,
342    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
343{
344    fn new(runner: PluginRunner) -> Self {
345        Self::new(runner)
346    }
347
348    async fn call_plugin(
349        &self,
350        plugin: PluginModel,
351        plugin_call_request: PluginCallRequest,
352        state: Arc<web::ThinData<AppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>>>,
353    ) -> PluginCallResult {
354        self.call_plugin(plugin, plugin_call_request, state).await
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use std::time::Duration;
361
362    use crate::{
363        constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS,
364        jobs::MockJobProducerTrait,
365        models::PluginModel,
366        repositories::{
367            ApiKeyRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage,
368            PluginRepositoryStorage, RelayerRepositoryStorage, SignerRepositoryStorage,
369            TransactionCounterRepositoryStorage, TransactionRepositoryStorage,
370        },
371        utils::mocks::mockutils::create_mock_app_state,
372    };
373
374    use super::*;
375
376    #[test]
377    fn test_resolve_plugin_path() {
378        assert_eq!(
379            PluginService::<MockPluginRunnerTrait>::resolve_plugin_path("plugins/examples/test.ts"),
380            "plugins/examples/test.ts"
381        );
382
383        assert_eq!(
384            PluginService::<MockPluginRunnerTrait>::resolve_plugin_path("examples/test.ts"),
385            "plugins/examples/test.ts"
386        );
387
388        assert_eq!(
389            PluginService::<MockPluginRunnerTrait>::resolve_plugin_path("test.ts"),
390            "plugins/test.ts"
391        );
392    }
393
394    #[tokio::test]
395    async fn test_call_plugin() {
396        let plugin = PluginModel {
397            id: "test-plugin".to_string(),
398            path: "test-path".to_string(),
399            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
400            emit_logs: true,
401            emit_traces: false,
402        };
403        let app_state =
404            create_mock_app_state(None, None, None, None, Some(vec![plugin.clone()]), None).await;
405
406        let mut plugin_runner = MockPluginRunnerTrait::default();
407
408        plugin_runner
409            .expect_run::<MockJobProducerTrait, RelayerRepositoryStorage, TransactionRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, ApiKeyRepositoryStorage>()
410            .returning(|_, _, _, _, _, _, _| {
411                Ok(ScriptResult {
412                    logs: vec![LogEntry {
413                        level: LogLevel::Log,
414                        message: "test-log".to_string(),
415                    }],
416                    error: "test-error".to_string(),
417                    return_value: "test-result".to_string(),
418                    trace: Vec::new(),
419                })
420            });
421
422        let plugin_service = PluginService::<MockPluginRunnerTrait>::new(plugin_runner);
423        let outcome = plugin_service
424            .call_plugin(
425                plugin,
426                PluginCallRequest {
427                    params: serde_json::Value::Null,
428                },
429                Arc::new(web::ThinData(app_state)),
430            )
431            .await;
432        match outcome {
433            PluginCallResult::Success(result) => {
434                // result should be the string since it is not JSON
435                assert_eq!(
436                    result.result,
437                    serde_json::Value::String("test-result".to_string())
438                );
439                // emit_logs=true -> logs should be present in metadata
440                assert!(result.metadata.and_then(|meta| meta.logs).is_some());
441            }
442            PluginCallResult::Handler(_) | PluginCallResult::Fatal(_) => {
443                panic!("expected success outcome")
444            }
445        }
446    }
447
448    #[tokio::test]
449    async fn test_from_plugin_error_to_string() {
450        let error = PluginError::PluginExecutionError("test-error".to_string());
451        let result: String = error.into();
452        assert_eq!(result, "Plugin execution error: test-error");
453    }
454
455    #[test]
456    fn test_plugin_error_with_traces_handler_error() {
457        let payload = PluginHandlerPayload {
458            status: 400,
459            message: "test message".to_string(),
460            code: Some("TEST_CODE".to_string()),
461            details: None,
462            logs: None,
463            traces: Some(vec![serde_json::json!({"trace": "1"})]),
464        };
465        let error = PluginError::HandlerError(Box::new(payload));
466        let new_traces = vec![
467            serde_json::json!({"trace": "2"}),
468            serde_json::json!({"trace": "3"}),
469        ];
470
471        let enriched_error = error.with_traces(new_traces);
472
473        match enriched_error {
474            PluginError::HandlerError(payload) => {
475                let traces = payload.traces.unwrap();
476                assert_eq!(traces.len(), 3);
477                assert_eq!(traces[0], serde_json::json!({"trace": "1"}));
478                assert_eq!(traces[1], serde_json::json!({"trace": "2"}));
479                assert_eq!(traces[2], serde_json::json!({"trace": "3"}));
480            }
481            _ => panic!("Expected HandlerError variant"),
482        }
483    }
484
485    #[test]
486    fn test_plugin_error_with_traces_other_variants() {
487        let error = PluginError::PluginExecutionError("test".to_string());
488        let new_traces = vec![serde_json::json!({"trace": "1"})];
489
490        let result = error.with_traces(new_traces);
491
492        match result {
493            PluginError::PluginExecutionError(msg) => assert_eq!(msg, "test"),
494            _ => panic!("Expected PluginExecutionError variant"),
495        }
496    }
497
498    #[test]
499    fn test_derive_handler_message_with_message() {
500        let result = derive_handler_message("Custom error message", None);
501        assert_eq!(result, "Custom error message");
502    }
503
504    #[test]
505    fn test_derive_handler_message_with_error_log() {
506        let logs = vec![
507            LogEntry {
508                level: LogLevel::Log,
509                message: "info log".to_string(),
510            },
511            LogEntry {
512                level: LogLevel::Error,
513                message: "error log".to_string(),
514            },
515        ];
516        let result = derive_handler_message("", Some(&logs));
517        assert_eq!(result, "error log");
518    }
519
520    #[test]
521    fn test_derive_handler_message_with_warn_log() {
522        let logs = vec![
523            LogEntry {
524                level: LogLevel::Log,
525                message: "info log".to_string(),
526            },
527            LogEntry {
528                level: LogLevel::Warn,
529                message: "warn log".to_string(),
530            },
531        ];
532        let result = derive_handler_message("", Some(&logs));
533        assert_eq!(result, "warn log");
534    }
535
536    #[test]
537    fn test_derive_handler_message_with_only_info_logs() {
538        let logs = vec![
539            LogEntry {
540                level: LogLevel::Log,
541                message: "first log".to_string(),
542            },
543            LogEntry {
544                level: LogLevel::Info,
545                message: "last log".to_string(),
546            },
547        ];
548        let result = derive_handler_message("", Some(&logs));
549        assert_eq!(result, "last log");
550    }
551
552    #[test]
553    fn test_derive_handler_message_no_logs() {
554        let result = derive_handler_message("", None);
555        assert_eq!(result, "Plugin execution failed");
556    }
557
558    #[test]
559    fn test_build_metadata_with_logs_and_traces() {
560        let logs = vec![LogEntry {
561            level: LogLevel::Log,
562            message: "test".to_string(),
563        }];
564        let traces = vec![serde_json::json!({"trace": "1"})];
565
566        let result = build_metadata(Some(logs.clone()), Some(traces.clone()));
567
568        assert!(result.is_some());
569        let metadata = result.unwrap();
570        assert_eq!(metadata.logs.unwrap(), logs);
571        assert_eq!(metadata.traces.unwrap(), traces);
572    }
573
574    #[test]
575    fn test_build_metadata_with_only_logs() {
576        let logs = vec![LogEntry {
577            level: LogLevel::Log,
578            message: "test".to_string(),
579        }];
580
581        let result = build_metadata(Some(logs.clone()), None);
582
583        assert!(result.is_some());
584        let metadata = result.unwrap();
585        assert_eq!(metadata.logs.unwrap(), logs);
586        assert!(metadata.traces.is_none());
587    }
588
589    #[test]
590    fn test_build_metadata_with_only_traces() {
591        let traces = vec![serde_json::json!({"trace": "1"})];
592
593        let result = build_metadata(None, Some(traces.clone()));
594
595        assert!(result.is_some());
596        let metadata = result.unwrap();
597        assert!(metadata.logs.is_none());
598        assert_eq!(metadata.traces.unwrap(), traces);
599    }
600
601    #[test]
602    fn test_build_metadata_with_neither() {
603        let result = build_metadata(None, None);
604        assert!(result.is_none());
605    }
606
607    #[test]
608    fn test_plugin_handler_payload_append_traces_to_existing() {
609        let mut payload = PluginHandlerPayload {
610            status: 400,
611            message: "test".to_string(),
612            code: None,
613            details: None,
614            logs: None,
615            traces: Some(vec![serde_json::json!({"trace": "1"})]),
616        };
617
618        payload.append_traces(vec![serde_json::json!({"trace": "2"})]);
619
620        let traces = payload.traces.unwrap();
621        assert_eq!(traces.len(), 2);
622        assert_eq!(traces[0], serde_json::json!({"trace": "1"}));
623        assert_eq!(traces[1], serde_json::json!({"trace": "2"}));
624    }
625
626    #[test]
627    fn test_plugin_handler_payload_append_traces_to_none() {
628        let mut payload = PluginHandlerPayload {
629            status: 400,
630            message: "test".to_string(),
631            code: None,
632            details: None,
633            logs: None,
634            traces: None,
635        };
636
637        payload.append_traces(vec![serde_json::json!({"trace": "1"})]);
638
639        let traces = payload.traces.unwrap();
640        assert_eq!(traces.len(), 1);
641        assert_eq!(traces[0], serde_json::json!({"trace": "1"}));
642    }
643
644    #[test]
645    fn test_plugin_handler_payload_into_response_with_logs_and_traces() {
646        let logs = vec![LogEntry {
647            level: LogLevel::Error,
648            message: "error message".to_string(),
649        }];
650        let payload = PluginHandlerPayload {
651            status: 400,
652            message: "".to_string(),
653            code: Some("ERR_CODE".to_string()),
654            details: Some(serde_json::json!({"key": "value"})),
655            logs: Some(logs.clone()),
656            traces: Some(vec![serde_json::json!({"trace": "1"})]),
657        };
658
659        let response = payload.into_response(true, true);
660
661        assert_eq!(response.status, 400);
662        assert_eq!(response.message, "error message"); // Derived from error log
663        assert_eq!(response.error.code, Some("ERR_CODE".to_string()));
664        assert!(response.metadata.is_some());
665        let metadata = response.metadata.unwrap();
666        assert_eq!(metadata.logs.unwrap(), logs);
667        assert_eq!(metadata.traces.unwrap().len(), 1);
668    }
669
670    #[test]
671    fn test_plugin_handler_payload_into_response_without_logs() {
672        let logs = vec![LogEntry {
673            level: LogLevel::Log,
674            message: "test log".to_string(),
675        }];
676        let payload = PluginHandlerPayload {
677            status: 500,
678            message: "explicit message".to_string(),
679            code: None,
680            details: None,
681            logs: Some(logs),
682            traces: None,
683        };
684
685        let response = payload.into_response(false, false);
686
687        assert_eq!(response.status, 500);
688        assert_eq!(response.message, "explicit message");
689        assert!(response.metadata.is_none()); // emit_logs=false, emit_traces=false
690    }
691
692    #[tokio::test]
693    async fn test_call_plugin_handler_error() {
694        let plugin = PluginModel {
695            id: "test-plugin".to_string(),
696            path: "test-path".to_string(),
697            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
698            emit_logs: true,
699            emit_traces: true,
700        };
701        let app_state =
702            create_mock_app_state(None, None, None, None, Some(vec![plugin.clone()]), None).await;
703
704        let mut plugin_runner = MockPluginRunnerTrait::default();
705
706        plugin_runner
707            .expect_run::<MockJobProducerTrait, RelayerRepositoryStorage, TransactionRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, ApiKeyRepositoryStorage>()
708            .returning(move |_, _, _, _, _, _, _| {
709                Err(PluginError::HandlerError(Box::new(PluginHandlerPayload {
710                    status: 400,
711                    message: "Plugin handler error".to_string(),
712                    code: Some("VALIDATION_ERROR".to_string()),
713                    details: Some(serde_json::json!({"field": "email"})),
714                    logs: Some(vec![LogEntry {
715                        level: LogLevel::Error,
716                        message: "Invalid email".to_string(),
717                    }]),
718                    traces: Some(vec![serde_json::json!({"step": "validation"})]),
719                })))
720            });
721
722        let plugin_service = PluginService::<MockPluginRunnerTrait>::new(plugin_runner);
723        let outcome = plugin_service
724            .call_plugin(
725                plugin,
726                PluginCallRequest {
727                    params: serde_json::Value::Null,
728                },
729                Arc::new(web::ThinData(app_state)),
730            )
731            .await;
732
733        match outcome {
734            PluginCallResult::Handler(response) => {
735                assert_eq!(response.status, 400);
736                assert_eq!(response.error.code, Some("VALIDATION_ERROR".to_string()));
737                assert!(response.metadata.is_some());
738                let metadata = response.metadata.unwrap();
739                assert!(metadata.logs.is_some());
740                assert!(metadata.traces.is_some());
741            }
742            _ => panic!("Expected Handler result"),
743        }
744    }
745
746    #[tokio::test]
747    async fn test_call_plugin_fatal_error() {
748        let plugin = PluginModel {
749            id: "test-plugin".to_string(),
750            path: "test-path".to_string(),
751            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
752            emit_logs: false,
753            emit_traces: false,
754        };
755        let app_state =
756            create_mock_app_state(None, None, None, None, Some(vec![plugin.clone()]), None).await;
757
758        let mut plugin_runner = MockPluginRunnerTrait::default();
759
760        plugin_runner
761            .expect_run::<MockJobProducerTrait, RelayerRepositoryStorage, TransactionRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, ApiKeyRepositoryStorage>()
762            .returning(|_, _, _, _, _, _, _| {
763                Err(PluginError::PluginExecutionError("Fatal error".to_string()))
764            });
765
766        let plugin_service = PluginService::<MockPluginRunnerTrait>::new(plugin_runner);
767        let outcome = plugin_service
768            .call_plugin(
769                plugin,
770                PluginCallRequest {
771                    params: serde_json::Value::Null,
772                },
773                Arc::new(web::ThinData(app_state)),
774            )
775            .await;
776
777        match outcome {
778            PluginCallResult::Fatal(error) => {
779                assert!(matches!(error, PluginError::PluginExecutionError(_)));
780            }
781            _ => panic!("Expected Fatal result"),
782        }
783    }
784
785    #[tokio::test]
786    async fn test_call_plugin_success_with_json_result() {
787        let plugin = PluginModel {
788            id: "test-plugin".to_string(),
789            path: "test-path".to_string(),
790            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
791            emit_logs: true,
792            emit_traces: true,
793        };
794        let app_state =
795            create_mock_app_state(None, None, None, None, Some(vec![plugin.clone()]), None).await;
796
797        let mut plugin_runner = MockPluginRunnerTrait::default();
798
799        plugin_runner
800            .expect_run::<MockJobProducerTrait, RelayerRepositoryStorage, TransactionRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, ApiKeyRepositoryStorage>()
801            .returning(|_, _, _, _, _, _, _| {
802                Ok(ScriptResult {
803                    logs: vec![LogEntry {
804                        level: LogLevel::Log,
805                        message: "test-log".to_string(),
806                    }],
807                    error: "".to_string(),
808                    return_value: r#"{"result": "success"}"#.to_string(),
809                    trace: vec![serde_json::json!({"step": "1"})],
810                })
811            });
812
813        let plugin_service = PluginService::<MockPluginRunnerTrait>::new(plugin_runner);
814        let outcome = plugin_service
815            .call_plugin(
816                plugin,
817                PluginCallRequest {
818                    params: serde_json::Value::Null,
819                },
820                Arc::new(web::ThinData(app_state)),
821            )
822            .await;
823
824        match outcome {
825            PluginCallResult::Success(result) => {
826                // Should be parsed as JSON object
827                assert_eq!(result.result, serde_json::json!({"result": "success"}));
828                assert!(result.metadata.is_some());
829                let metadata = result.metadata.unwrap();
830                assert!(metadata.logs.is_some());
831                assert!(metadata.traces.is_some());
832            }
833            _ => panic!("Expected Success result"),
834        }
835    }
836
837    #[tokio::test]
838    async fn test_call_plugin_success_with_undefined_result() {
839        let plugin = PluginModel {
840            id: "test-plugin".to_string(),
841            path: "test-path".to_string(),
842            timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
843            emit_logs: false,
844            emit_traces: false,
845        };
846        let app_state =
847            create_mock_app_state(None, None, None, None, Some(vec![plugin.clone()]), None).await;
848
849        let mut plugin_runner = MockPluginRunnerTrait::default();
850
851        plugin_runner
852            .expect_run::<MockJobProducerTrait, RelayerRepositoryStorage, TransactionRepositoryStorage, NetworkRepositoryStorage, NotificationRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, PluginRepositoryStorage, ApiKeyRepositoryStorage>()
853            .returning(|_, _, _, _, _, _, _| {
854                Ok(ScriptResult {
855                    logs: vec![],
856                    error: "".to_string(),
857                    return_value: "undefined".to_string(),
858                    trace: vec![],
859                })
860            });
861
862        let plugin_service = PluginService::<MockPluginRunnerTrait>::new(plugin_runner);
863        let outcome = plugin_service
864            .call_plugin(
865                plugin,
866                PluginCallRequest {
867                    params: serde_json::Value::Null,
868                },
869                Arc::new(web::ThinData(app_state)),
870            )
871            .await;
872
873        match outcome {
874            PluginCallResult::Success(result) => {
875                // "undefined" should be converted to null
876                assert_eq!(result.result, serde_json::Value::Null);
877                // emit_logs=false, emit_traces=false -> no metadata
878                assert!(result.metadata.is_none());
879            }
880            _ => panic!("Expected Success result"),
881        }
882    }
883}