openzeppelin_relayer/config/config_file/
mod.rs

1//! This module provides functionality for loading and validating configuration files
2//! for a blockchain relayer application. It includes definitions for configuration
3//! structures, error handling, and validation logic to ensure that the configuration
4//! is correct and complete before use.
5//!
6//! The module supports configuration for different network types, including EVM, Solana,
7//! and Stellar, and ensures that test signers are only used with test networks.
8//!
9//! # Modules
10//! - `relayer`: Handles relayer-specific configuration.
11//! - `signer`: Manages signer-specific configuration.
12//! - `notification`: Deals with notification-specific configuration.
13//! - `network`: Handles network configuration, including network overrides and custom networks.
14//!
15//! # Errors
16//! The module defines a comprehensive set of errors to handle various issues that might
17//! arise during configuration loading and validation, such as missing fields, invalid
18//! formats, and invalid references.
19//!
20//! # Usage
21//! To use this module, load a configuration file using `load_config`, which will parse
22//! the file and validate its contents. If the configuration is valid, it can be used
23//! to initialize the application components.
24use crate::{
25    config::ConfigFileError,
26    models::{
27        relayer::{RelayerFileConfig, RelayersFileConfig},
28        signer::{SignerFileConfig, SignersFileConfig},
29        NotificationConfig, NotificationConfigs,
30    },
31};
32use serde::{Deserialize, Serialize};
33use std::{
34    collections::HashSet,
35    fs::{self},
36};
37
38mod plugin;
39pub use plugin::*;
40
41pub mod network;
42pub use network::{
43    EvmNetworkConfig, GasPriceCacheConfig, NetworkConfigCommon, NetworkFileConfig,
44    NetworksFileConfig, SolanaNetworkConfig, StellarNetworkConfig,
45};
46
47#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
48#[serde(rename_all = "lowercase")]
49pub enum ConfigFileNetworkType {
50    Evm,
51    Stellar,
52    Solana,
53}
54
55#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct Config {
57    pub relayers: Vec<RelayerFileConfig>,
58    pub signers: Vec<SignerFileConfig>,
59    pub notifications: Vec<NotificationConfig>,
60    pub networks: NetworksFileConfig,
61    pub plugins: Option<Vec<PluginFileConfig>>,
62}
63
64impl Config {
65    /// Validates the configuration by checking the validity of relayers, signers, and
66    /// notifications.
67    ///
68    /// This method ensures that all references between relayers, signers, and notifications are
69    /// valid. It also checks that test signers are only used with test networks.
70    ///
71    /// # Errors
72    /// Returns a `ConfigFileError` if any validation checks fail.
73    pub fn validate(&self) -> Result<(), ConfigFileError> {
74        self.validate_networks()?;
75        self.validate_relayers(&self.networks)?;
76        self.validate_signers()?;
77        self.validate_notifications()?;
78        self.validate_plugins()?;
79
80        self.validate_relayer_signer_refs()?;
81        self.validate_relayer_notification_refs()?;
82
83        Ok(())
84    }
85
86    /// Validates that all relayer references to signers are valid.
87    ///
88    /// This method checks that each relayer references an existing signer and that test signers
89    /// are only used with test networks.
90    ///
91    /// # Errors
92    /// Returns a `ConfigFileError::InvalidReference` if a relayer references a non-existent signer.
93    /// Returns a `ConfigFileError::TestSigner` if a test signer is used on a production network.
94    fn validate_relayer_signer_refs(&self) -> Result<(), ConfigFileError> {
95        let signer_ids: HashSet<_> = self.signers.iter().map(|s| &s.id).collect();
96
97        for relayer in &self.relayers {
98            if !signer_ids.contains(&relayer.signer_id) {
99                return Err(ConfigFileError::InvalidReference(format!(
100                    "Relayer '{}' references non-existent signer '{}'",
101                    relayer.id, relayer.signer_id
102                )));
103            }
104        }
105
106        Ok(())
107    }
108
109    /// Validates that all relayer references to notifications are valid.
110    ///
111    /// This method checks that each relayer references an existing notification, if specified.
112    ///
113    /// # Errors
114    /// Returns a `ConfigFileError::InvalidReference` if a relayer references a non-existent
115    /// notification.
116    fn validate_relayer_notification_refs(&self) -> Result<(), ConfigFileError> {
117        let notification_ids: HashSet<_> = self.notifications.iter().map(|s| &s.id).collect();
118
119        for relayer in &self.relayers {
120            if let Some(notification_id) = &relayer.notification_id {
121                if !notification_ids.contains(notification_id) {
122                    return Err(ConfigFileError::InvalidReference(format!(
123                        "Relayer '{}' references non-existent notification '{}'",
124                        relayer.id, notification_id
125                    )));
126                }
127            }
128        }
129
130        Ok(())
131    }
132
133    /// Validates that all relayers are valid and have unique IDs.
134    fn validate_relayers(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> {
135        RelayersFileConfig::new(self.relayers.clone()).validate(networks)
136    }
137
138    /// Validates that all signers are valid and have unique IDs.
139    fn validate_signers(&self) -> Result<(), ConfigFileError> {
140        SignersFileConfig::new(self.signers.clone()).validate()
141    }
142
143    /// Validates that all notifications are valid and have unique IDs.
144    fn validate_notifications(&self) -> Result<(), ConfigFileError> {
145        NotificationConfigs::new(self.notifications.clone()).validate()
146    }
147
148    /// Validates that all networks are valid and have unique IDs.
149    fn validate_networks(&self) -> Result<(), ConfigFileError> {
150        if self.networks.is_empty() {
151            return Ok(()); // No networks to validate
152        }
153
154        self.networks.validate()
155    }
156
157    /// Validates that all plugins are valid and have unique IDs.
158    fn validate_plugins(&self) -> Result<(), ConfigFileError> {
159        if let Some(plugins) = &self.plugins {
160            PluginsFileConfig::new(plugins.clone()).validate()
161        } else {
162            Ok(())
163        }
164    }
165}
166
167/// Loads and validates a configuration file from the specified path.
168///
169/// This function reads the configuration file, parses it as JSON, and validates its contents.
170/// If the configuration is valid, it returns a `Config` object.
171///
172/// # Arguments
173/// * `config_file_path` - A string slice that holds the path to the configuration file.
174///
175/// # Errors
176/// Returns a `ConfigFileError` if the file cannot be read, parsed, or if the configuration is
177/// invalid.
178pub fn load_config(config_file_path: &str) -> Result<Config, ConfigFileError> {
179    let config_str = fs::read_to_string(config_file_path)?;
180    let config: Config = serde_json::from_str(&config_str)?;
181    config.validate()?;
182    Ok(config)
183}
184
185#[cfg(test)]
186mod tests {
187    use crate::models::{
188        signer::{LocalSignerFileConfig, SignerFileConfig, SignerFileConfigEnum},
189        ConfigFileRelayerNetworkPolicy, ConfigFileRelayerStellarPolicy,
190        ConfigFileStellarFeePaymentStrategy, NotificationType, PlainOrEnvValue, RpcConfig,
191        SecretString,
192    };
193    use std::path::Path;
194
195    use super::*;
196
197    fn create_valid_config() -> Config {
198        Config {
199            relayers: vec![RelayerFileConfig {
200                id: "test-1".to_string(),
201                name: "Test Relayer".to_string(),
202                network: "test-network".to_string(),
203                paused: false,
204                network_type: ConfigFileNetworkType::Evm,
205                policies: None,
206                signer_id: "test-1".to_string(),
207                notification_id: Some("test-1".to_string()),
208                custom_rpc_urls: None,
209            }],
210            signers: vec![SignerFileConfig {
211                id: "test-1".to_string(),
212                config: SignerFileConfigEnum::Local(LocalSignerFileConfig {
213                    path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(),
214                    passphrase: PlainOrEnvValue::Plain {
215                        value: SecretString::new("test"),
216                    },
217                }),
218            }],
219            notifications: vec![NotificationConfig {
220                id: "test-1".to_string(),
221                r#type: NotificationType::Webhook,
222                url: "https://api.example.com/notifications".to_string(),
223                signing_key: None,
224            }],
225            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
226                common: NetworkConfigCommon {
227                    network: "test-network".to_string(),
228                    from: None,
229                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
230                        "https://rpc.test.example.com".to_string(),
231                    )]),
232                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
233                    average_blocktime_ms: Some(12000),
234                    is_testnet: Some(true),
235                    tags: Some(vec!["test".to_string()]),
236                },
237                chain_id: Some(31337),
238                required_confirmations: Some(1),
239                features: None,
240                symbol: Some("ETH".to_string()),
241                gas_price_cache: None,
242            })])
243            .expect("Failed to create NetworksFileConfig for test"),
244            plugins: Some(vec![PluginFileConfig {
245                id: "test-1".to_string(),
246                path: "/app/plugins/test-plugin.ts".to_string(),
247                timeout: None,
248                emit_logs: false,
249                allow_get_invocation: false,
250                emit_traces: false,
251                config: None,
252                raw_response: false,
253                forward_logs: false,
254            }]),
255        }
256    }
257
258    #[test]
259    fn test_valid_config_validation() {
260        let config = create_valid_config();
261        assert!(config.validate().is_ok());
262    }
263
264    #[test]
265    fn test_empty_relayers() {
266        let config = Config {
267            relayers: Vec::new(),
268            signers: Vec::new(),
269            notifications: Vec::new(),
270            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
271                common: NetworkConfigCommon {
272                    network: "test-network".to_string(),
273                    from: None,
274                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
275                        "https://rpc.test.example.com".to_string(),
276                    )]),
277                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
278                    average_blocktime_ms: Some(12000),
279                    is_testnet: Some(true),
280                    tags: Some(vec!["test".to_string()]),
281                },
282                chain_id: Some(31337),
283                required_confirmations: Some(1),
284                features: None,
285                symbol: Some("ETH".to_string()),
286                gas_price_cache: None,
287            })])
288            .unwrap(),
289            plugins: Some(vec![]),
290        };
291        assert!(config.validate().is_ok());
292    }
293
294    #[test]
295    fn test_empty_signers() {
296        let config = Config {
297            relayers: Vec::new(),
298            signers: Vec::new(),
299            notifications: Vec::new(),
300            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
301                common: NetworkConfigCommon {
302                    network: "test-network".to_string(),
303                    from: None,
304                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
305                        "https://rpc.test.example.com".to_string(),
306                    )]),
307                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
308                    average_blocktime_ms: Some(12000),
309                    is_testnet: Some(true),
310                    tags: Some(vec!["test".to_string()]),
311                },
312                chain_id: Some(31337),
313                required_confirmations: Some(1),
314                features: None,
315                symbol: Some("ETH".to_string()),
316                gas_price_cache: None,
317            })])
318            .unwrap(),
319            plugins: Some(vec![]),
320        };
321        assert!(config.validate().is_ok());
322    }
323
324    #[test]
325    fn test_invalid_id_format() {
326        let mut config = create_valid_config();
327        config.relayers[0].id = "invalid@id".to_string();
328        assert!(matches!(
329            config.validate(),
330            Err(ConfigFileError::InvalidIdFormat(_))
331        ));
332    }
333
334    #[test]
335    fn test_id_too_long() {
336        let mut config = create_valid_config();
337        config.relayers[0].id = "a".repeat(37);
338        assert!(matches!(
339            config.validate(),
340            Err(ConfigFileError::InvalidIdLength(_))
341        ));
342    }
343
344    #[test]
345    fn test_relayers_duplicate_ids() {
346        let mut config = create_valid_config();
347        config.relayers.push(config.relayers[0].clone());
348        assert!(matches!(
349            config.validate(),
350            Err(ConfigFileError::DuplicateId(_))
351        ));
352    }
353
354    #[test]
355    fn test_signers_duplicate_ids() {
356        let mut config = create_valid_config();
357        config.signers.push(config.signers[0].clone());
358
359        assert!(matches!(
360            config.validate(),
361            Err(ConfigFileError::DuplicateId(_))
362        ));
363    }
364
365    #[test]
366    fn test_missing_name() {
367        let mut config = create_valid_config();
368        config.relayers[0].name = "".to_string();
369        assert!(matches!(
370            config.validate(),
371            Err(ConfigFileError::MissingField(_))
372        ));
373    }
374
375    #[test]
376    fn test_missing_network() {
377        let mut config = create_valid_config();
378        config.relayers[0].network = "".to_string();
379        assert!(matches!(
380            config.validate(),
381            Err(ConfigFileError::InvalidFormat(_))
382        ));
383    }
384
385    #[test]
386    fn test_invalid_signer_id_reference() {
387        let mut config = create_valid_config();
388        config.relayers[0].signer_id = "invalid@id".to_string();
389        assert!(matches!(
390            config.validate(),
391            Err(ConfigFileError::InvalidReference(_))
392        ));
393    }
394
395    #[test]
396    fn test_invalid_notification_id_reference() {
397        let mut config = create_valid_config();
398        config.relayers[0].notification_id = Some("invalid@id".to_string());
399        assert!(matches!(
400            config.validate(),
401            Err(ConfigFileError::InvalidReference(_))
402        ));
403    }
404
405    #[test]
406    fn test_config_with_networks() {
407        let mut config = create_valid_config();
408        config.relayers[0].network = "custom-evm".to_string();
409
410        let network_items = vec![serde_json::from_value(serde_json::json!({
411            "type": "evm",
412            "network": "custom-evm",
413            "required_confirmations": 1,
414            "chain_id": 1234,
415            "rpc_urls": ["https://rpc.example.com"],
416            "symbol": "ETH"
417        }))
418        .unwrap()];
419        config.networks = NetworksFileConfig::new(network_items).unwrap();
420
421        assert!(
422            config.validate().is_ok(),
423            "Error validating config: {:?}",
424            config.validate().err()
425        );
426    }
427
428    #[test]
429    fn test_config_with_invalid_networks() {
430        let mut config = create_valid_config();
431        let network_items = vec![serde_json::from_value(serde_json::json!({
432            "type": "evm",
433            "network": "invalid-network",
434            "rpc_urls": ["https://rpc.example.com"]
435        }))
436        .unwrap()];
437        config.networks = NetworksFileConfig::new(network_items.clone())
438            .expect("Should allow creation, validation happens later or should fail here");
439
440        let result = config.validate();
441        assert!(result.is_err());
442        assert!(matches!(
443            result,
444            Err(ConfigFileError::MissingField(_)) | Err(ConfigFileError::InvalidFormat(_))
445        ));
446    }
447
448    #[test]
449    fn test_config_with_duplicate_network_names() {
450        let mut config = create_valid_config();
451        let network_items = vec![
452            serde_json::from_value(serde_json::json!({
453                "type": "evm",
454                "network": "custom-evm",
455                "chain_id": 1234,
456                "rpc_urls": ["https://rpc1.example.com"]
457            }))
458            .unwrap(),
459            serde_json::from_value(serde_json::json!({
460                "type": "evm",
461                "network": "custom-evm",
462                "chain_id": 5678,
463                "rpc_urls": ["https://rpc2.example.com"]
464            }))
465            .unwrap(),
466        ];
467        let networks_config_result = NetworksFileConfig::new(network_items);
468        assert!(
469            networks_config_result.is_err(),
470            "NetworksFileConfig::new should detect duplicate IDs"
471        );
472
473        if let Ok(parsed_networks) = networks_config_result {
474            config.networks = parsed_networks;
475            let result = config.validate();
476            assert!(result.is_err());
477            assert!(matches!(result, Err(ConfigFileError::DuplicateId(_))));
478        } else if let Err(e) = networks_config_result {
479            assert!(matches!(e, ConfigFileError::DuplicateId(_)));
480        }
481    }
482
483    #[test]
484    fn test_config_with_invalid_network_inheritance() {
485        let mut config = create_valid_config();
486        let network_items = vec![serde_json::from_value(serde_json::json!({
487            "type": "evm",
488            "network": "custom-evm",
489            "from": "non-existent-network",
490            "rpc_urls": ["https://rpc.example.com"]
491        }))
492        .unwrap()];
493        let networks_config_result = NetworksFileConfig::new(network_items);
494
495        match networks_config_result {
496            Ok(parsed_networks) => {
497                config.networks = parsed_networks;
498                let validation_result = config.validate();
499                assert!(
500                    validation_result.is_err(),
501                    "Validation should fail due to invalid inheritance reference"
502                );
503                assert!(matches!(
504                    validation_result,
505                    Err(ConfigFileError::InvalidReference(_))
506                ));
507            }
508            Err(e) => {
509                assert!(
510                    matches!(e, ConfigFileError::InvalidReference(_)),
511                    "Expected InvalidReference from new or flatten"
512                );
513            }
514        }
515    }
516
517    #[test]
518    fn test_deserialize_config_with_evm_network() {
519        let config_str = r#"
520        {
521            "relayers": [],
522            "signers": [],
523            "notifications": [],
524            "plugins": [],
525            "networks": [
526                {
527                    "type": "evm",
528                    "network": "custom-evm",
529                    "chain_id": 1234,
530                    "required_confirmations": 1,
531                    "symbol": "ETH",
532                    "rpc_urls": ["https://rpc.example.com"]
533                }
534            ]
535        }
536        "#;
537        let result: Result<Config, _> = serde_json::from_str(config_str);
538        assert!(result.is_ok());
539        let config = result.unwrap();
540        assert_eq!(config.networks.len(), 1);
541
542        let network_config = config.networks.first().expect("Should have one network");
543        assert!(matches!(network_config, NetworkFileConfig::Evm(_)));
544        if let NetworkFileConfig::Evm(evm_config) = network_config {
545            assert_eq!(evm_config.common.network, "custom-evm");
546            assert_eq!(evm_config.chain_id, Some(1234));
547        }
548    }
549
550    #[test]
551    fn test_deserialize_config_with_solana_network() {
552        let config_str = r#"
553        {
554            "relayers": [],
555            "signers": [],
556            "notifications": [],
557            "plugins": [],
558            "networks": [
559                {
560                    "type": "solana",
561                    "network": "custom-solana",
562                    "rpc_urls": ["https://rpc.solana.example.com"]
563                }
564            ]
565        }
566        "#;
567        let result: Result<Config, _> = serde_json::from_str(config_str);
568        assert!(result.is_ok());
569        let config = result.unwrap();
570        assert_eq!(config.networks.len(), 1);
571
572        let network_config = config.networks.first().expect("Should have one network");
573        assert!(matches!(network_config, NetworkFileConfig::Solana(_)));
574        if let NetworkFileConfig::Solana(sol_config) = network_config {
575            assert_eq!(sol_config.common.network, "custom-solana");
576        }
577    }
578
579    #[test]
580    fn test_deserialize_config_with_stellar_network() {
581        let config_str = r#"
582        {
583            "relayers": [],
584            "signers": [],
585            "notifications": [],
586            "plugins": [],
587            "networks": [
588                {
589                    "type": "stellar",
590                    "network": "custom-stellar",
591                    "rpc_urls": ["https://rpc.stellar.example.com"]
592                }
593            ]
594        }
595        "#;
596        let result: Result<Config, _> = serde_json::from_str(config_str);
597        assert!(result.is_ok());
598        let config = result.unwrap();
599        assert_eq!(config.networks.len(), 1);
600
601        let network_config = config.networks.first().expect("Should have one network");
602        assert!(matches!(network_config, NetworkFileConfig::Stellar(_)));
603        if let NetworkFileConfig::Stellar(stl_config) = network_config {
604            assert_eq!(stl_config.common.network, "custom-stellar");
605        }
606    }
607
608    #[test]
609    fn test_deserialize_config_with_mixed_networks() {
610        let config_str = r#"
611        {
612            "relayers": [],
613            "signers": [],
614            "notifications": [],
615            "plugins": [],
616            "networks": [
617                {
618                    "type": "evm",
619                    "network": "custom-evm",
620                    "chain_id": 1234,
621                    "required_confirmations": 1,
622                    "symbol": "ETH",
623                    "rpc_urls": ["https://rpc.example.com"]
624                },
625                {
626                    "type": "solana",
627                    "network": "custom-solana",
628                    "rpc_urls": ["https://rpc.solana.example.com"]
629                }
630            ]
631        }
632        "#;
633        let result: Result<Config, _> = serde_json::from_str(config_str);
634        assert!(result.is_ok());
635        let config = result.unwrap();
636        assert_eq!(config.networks.len(), 2);
637    }
638
639    #[test]
640    #[should_panic(
641        expected = "NetworksFileConfig cannot be empty - networks must contain at least one network configuration"
642    )]
643    fn test_deserialize_config_with_empty_networks_array() {
644        let config_str = r#"
645        {
646            "relayers": [],
647            "signers": [],
648            "notifications": [],
649            "networks": []
650        }
651        "#;
652        let _result: Config = serde_json::from_str(config_str).unwrap();
653    }
654
655    #[test]
656    fn test_deserialize_config_without_networks_field() {
657        let config_str = r#"
658        {
659            "relayers": [],
660            "signers": [],
661            "notifications": []
662        }
663        "#;
664        let result: Result<Config, _> = serde_json::from_str(config_str);
665        assert!(result.is_ok());
666    }
667
668    use std::fs::File;
669    use std::io::Write;
670    use tempfile::tempdir;
671
672    fn setup_network_file(dir_path: &Path, file_name: &str, content: &str) {
673        let file_path = dir_path.join(file_name);
674        let mut file = File::create(&file_path).expect("Failed to create temp network file");
675        writeln!(file, "{content}").expect("Failed to write to temp network file");
676    }
677
678    #[test]
679    fn test_deserialize_config_with_networks_from_directory() {
680        let dir = tempdir().expect("Failed to create temp dir");
681        let network_dir_path = dir.path();
682
683        setup_network_file(
684            network_dir_path,
685            "evm_net.json",
686            r#"{"networks": [{"type": "evm", "network": "custom-evm-file", "required_confirmations": 1, "symbol": "ETH", "chain_id": 5678, "rpc_urls": ["https://rpc.file-evm.com"]}]}"#,
687        );
688        setup_network_file(
689            network_dir_path,
690            "sol_net.json",
691            r#"{"networks": [{"type": "solana", "network": "custom-solana-file", "rpc_urls": ["https://rpc.file-solana.com"]}]}"#,
692        );
693
694        let config_json = serde_json::json!({
695            "relayers": [],
696            "signers": [],
697            "notifications": [],
698            "plugins": [],
699            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
700        });
701        let config_str =
702            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
703
704        let result: Result<Config, _> = serde_json::from_str(&config_str);
705        assert!(result.is_ok(), "Deserialization failed: {:?}", result.err());
706
707        if let Ok(config) = result {
708            assert_eq!(
709                config.networks.len(),
710                2,
711                "Incorrect number of networks loaded"
712            );
713            let has_evm = config.networks.iter().any(|n| matches!(n, NetworkFileConfig::Evm(evm) if evm.common.network == "custom-evm-file"));
714            let has_solana = config.networks.iter().any(|n| matches!(n, NetworkFileConfig::Solana(sol) if sol.common.network == "custom-solana-file"));
715            assert!(has_evm, "EVM network from file not found or incorrect");
716            assert!(
717                has_solana,
718                "Solana network from file not found or incorrect"
719            );
720        }
721    }
722
723    #[test]
724    fn test_deserialize_config_with_empty_networks_directory() {
725        let dir = tempdir().expect("Failed to create temp dir");
726        let network_dir_path = dir.path();
727
728        let config_json = serde_json::json!({
729            "relayers": [],
730            "signers": [],
731            "notifications": [],
732            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
733        });
734        let config_str =
735            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
736
737        let result: Result<Config, _> = serde_json::from_str(&config_str);
738        assert!(
739            result.is_err(),
740            "Deserialization should fail for empty directory"
741        );
742    }
743
744    #[test]
745    fn test_deserialize_config_with_non_existent_networks_directory() {
746        let dir = tempdir().expect("Failed to create temp dir");
747        let non_existent_path = dir.path().join("non_existent_sub_dir");
748
749        let config_json = serde_json::json!({
750            "relayers": [],
751            "signers": [],
752            "notifications": [],
753            "networks": non_existent_path.to_str().expect("Path should be valid UTF-8")
754        });
755        let config_str =
756            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
757
758        let result: Result<Config, _> = serde_json::from_str(&config_str);
759        assert!(
760            result.is_err(),
761            "Deserialization should fail for non-existent directory"
762        );
763    }
764
765    #[test]
766    fn test_deserialize_config_with_networks_path_as_file() {
767        let dir = tempdir().expect("Failed to create temp dir");
768        let network_file_path = dir.path().join("im_a_file.json");
769        File::create(&network_file_path).expect("Failed to create temp file");
770
771        let config_json = serde_json::json!({
772            "relayers": [],
773            "signers": [],
774            "notifications": [],
775            "networks": network_file_path.to_str().expect("Path should be valid UTF-8")
776        });
777        let config_str =
778            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
779
780        let result: Result<Config, _> = serde_json::from_str(&config_str);
781        assert!(
782            result.is_err(),
783            "Deserialization should fail if path is a file, not a directory"
784        );
785    }
786
787    #[test]
788    fn test_deserialize_config_network_dir_with_invalid_json_file() {
789        let dir = tempdir().expect("Failed to create temp dir");
790        let network_dir_path = dir.path();
791        setup_network_file(
792            network_dir_path,
793            "invalid.json",
794            r#"{"networks": [{"type": "evm", "network": "broken""#,
795        ); // Malformed JSON
796
797        let config_json = serde_json::json!({
798            "relayers": [], "signers": [], "notifications": [],
799            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
800        });
801        let config_str = serde_json::to_string(&config_json).expect("Failed to serialize");
802
803        let result: Result<Config, _> = serde_json::from_str(&config_str);
804        assert!(
805            result.is_err(),
806            "Deserialization should fail with invalid JSON in network file"
807        );
808    }
809
810    #[test]
811    fn test_deserialize_config_network_dir_with_non_network_config_json_file() {
812        let dir = tempdir().expect("Failed to create temp dir");
813        let network_dir_path = dir.path();
814        setup_network_file(network_dir_path, "not_a_network.json", r#"{"foo": "bar"}"#); // Valid JSON, but not NetworkFileConfig
815
816        let config_json = serde_json::json!({
817            "relayers": [], "signers": [], "notifications": [],
818            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
819        });
820        let config_str = serde_json::to_string(&config_json).expect("Failed to serialize");
821
822        let result: Result<Config, _> = serde_json::from_str(&config_str);
823        assert!(
824            result.is_err(),
825            "Deserialization should fail if file is not a valid NetworkFileConfig"
826        );
827    }
828
829    #[test]
830    fn test_deserialize_config_still_works_with_array_of_networks() {
831        let config_str = r#"
832        {
833            "relayers": [],
834            "signers": [],
835            "notifications": [],
836            "plugins": [],
837            "networks": [
838                {
839                    "type": "evm",
840                    "network": "custom-evm-array",
841                    "chain_id": 1234,
842                    "required_confirmations": 1,
843                    "symbol": "ETH",
844                    "rpc_urls": ["https://rpc.example.com"]
845                }
846            ]
847        }
848        "#;
849        let result: Result<Config, _> = serde_json::from_str(config_str);
850        assert!(
851            result.is_ok(),
852            "Deserialization with array failed: {:?}",
853            result.err()
854        );
855        if let Ok(config) = result {
856            assert_eq!(config.networks.len(), 1);
857
858            let network_config = config.networks.first().expect("Should have one network");
859            assert!(matches!(network_config, NetworkFileConfig::Evm(_)));
860            if let NetworkFileConfig::Evm(evm_config) = network_config {
861                assert_eq!(evm_config.common.network, "custom-evm-array");
862            }
863        }
864    }
865
866    #[test]
867    fn test_create_valid_networks_file_config_works() {
868        let networks = vec![NetworkFileConfig::Evm(EvmNetworkConfig {
869            common: NetworkConfigCommon {
870                network: "test-network".to_string(),
871                from: None,
872                rpc_urls: Some(vec![RpcConfig::new(
873                    "https://rpc.test.example.com".to_string(),
874                )]),
875                explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
876                average_blocktime_ms: Some(12000),
877                is_testnet: Some(true),
878                tags: Some(vec!["test".to_string()]),
879            },
880            chain_id: Some(31337),
881            required_confirmations: Some(1),
882            features: None,
883            symbol: Some("ETH".to_string()),
884            gas_price_cache: None,
885        })];
886
887        let config = NetworksFileConfig::new(networks).unwrap();
888        assert_eq!(config.len(), 1);
889        assert_eq!(config.first().unwrap().network_name(), "test-network");
890    }
891
892    fn setup_config_file(dir_path: &Path, file_name: &str, content: &str) {
893        let file_path = dir_path.join(file_name);
894        let mut file = File::create(&file_path).expect("Failed to create temp config file");
895        write!(file, "{content}").expect("Failed to write to temp config file");
896    }
897
898    #[test]
899    fn test_load_config_success() {
900        let dir = tempdir().expect("Failed to create temp dir");
901        let config_path = dir.path().join("valid_config.json");
902
903        let config_content = serde_json::json!({
904            "relayers": [{
905                "id": "test-relayer",
906                "name": "Test Relayer",
907                "network": "test-network",
908                "paused": false,
909                "network_type": "evm",
910                "signer_id": "test-signer"
911            }],
912            "signers": [{
913                "id": "test-signer",
914                "type": "local",
915                "config": {
916                    "path": "tests/utils/test_keys/unit-test-local-signer.json",
917                    "passphrase": {
918                        "value": "test",
919                        "type": "plain"
920                    }
921                }
922            }],
923            "notifications": [{
924                "id": "test-notification",
925                "type": "webhook",
926                "url": "https://api.example.com/notifications"
927            }],
928            "networks": [{
929                "type": "evm",
930                "network": "test-network",
931                "chain_id": 31337,
932                "required_confirmations": 1,
933                "symbol": "ETH",
934                "rpc_urls": ["https://rpc.test.example.com"],
935                "is_testnet": true
936            }],
937            "plugins": [{
938                "id": "plugin-id",
939                "path": "/app/plugins/plugin.ts",
940                "timeout": 12
941            }],
942        });
943
944        setup_config_file(dir.path(), "valid_config.json", &config_content.to_string());
945
946        let result = load_config(config_path.to_str().unwrap());
947        assert!(result.is_ok());
948
949        let config = result.unwrap();
950        assert_eq!(config.relayers.len(), 1);
951        assert_eq!(config.signers.len(), 1);
952        assert_eq!(config.networks.len(), 1);
953        assert_eq!(config.plugins.unwrap().len(), 1);
954    }
955
956    #[test]
957    fn test_load_config_file_not_found() {
958        let result = load_config("non_existent_file.json");
959        assert!(result.is_err());
960        assert!(matches!(result.unwrap_err(), ConfigFileError::IoError(_)));
961    }
962
963    #[test]
964    fn test_load_config_invalid_json() {
965        let dir = tempdir().expect("Failed to create temp dir");
966        let config_path = dir.path().join("invalid.json");
967
968        setup_config_file(dir.path(), "invalid.json", "{ invalid json }");
969
970        let result = load_config(config_path.to_str().unwrap());
971        assert!(result.is_err());
972        assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
973    }
974
975    #[test]
976    fn test_load_config_invalid_config_structure() {
977        let dir = tempdir().expect("Failed to create temp dir");
978        let config_path = dir.path().join("invalid_structure.json");
979
980        let invalid_config = serde_json::json!({
981            "relayers": "not_an_array",
982            "signers": [],
983            "notifications": [],
984            "networks": [{
985                "type": "evm",
986                "network": "test-network",
987                "chain_id": 31337,
988                "required_confirmations": 1,
989                "symbol": "ETH",
990                "rpc_urls": ["https://rpc.test.example.com"]
991            }]
992        });
993
994        setup_config_file(
995            dir.path(),
996            "invalid_structure.json",
997            &invalid_config.to_string(),
998        );
999
1000        let result = load_config(config_path.to_str().unwrap());
1001        assert!(result.is_err());
1002        assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
1003    }
1004
1005    #[test]
1006    fn test_load_config_with_unicode_content() {
1007        let dir = tempdir().expect("Failed to create temp dir");
1008        let config_path = dir.path().join("unicode_config.json");
1009
1010        // Use ASCII-compatible IDs since the validation might reject Unicode in IDs
1011        let config_content = serde_json::json!({
1012            "relayers": [{
1013                "id": "test-relayer-unicode",
1014                "name": "Test Relayer 测试",
1015                "network": "test-network-unicode",
1016                "paused": false,
1017                "network_type": "evm",
1018                "signer_id": "test-signer-unicode"
1019            }],
1020            "signers": [{
1021                "id": "test-signer-unicode",
1022                "type": "local",
1023                "config": {
1024                    "path": "tests/utils/test_keys/unit-test-local-signer.json",
1025                    "passphrase": {
1026                        "value": "test",
1027                        "type": "plain"
1028                    }
1029                }
1030            }],
1031            "notifications": [{
1032                "id": "test-notification-unicode",
1033                "type": "webhook",
1034                "url": "https://api.example.com/notifications"
1035            }],
1036            "networks": [{
1037                "type": "evm",
1038                "network": "test-network-unicode",
1039                "chain_id": 31337,
1040                "required_confirmations": 1,
1041                "symbol": "ETH",
1042                "rpc_urls": ["https://rpc.test.example.com"],
1043                "is_testnet": true
1044            }],
1045            "plugins": []
1046        });
1047
1048        setup_config_file(
1049            dir.path(),
1050            "unicode_config.json",
1051            &config_content.to_string(),
1052        );
1053
1054        let result = load_config(config_path.to_str().unwrap());
1055        assert!(result.is_ok());
1056
1057        let config = result.unwrap();
1058        assert_eq!(config.relayers[0].id, "test-relayer-unicode");
1059        assert_eq!(config.signers[0].id, "test-signer-unicode");
1060    }
1061
1062    #[test]
1063    fn test_load_config_with_empty_file() {
1064        let dir = tempdir().expect("Failed to create temp dir");
1065        let config_path = dir.path().join("empty.json");
1066
1067        setup_config_file(dir.path(), "empty.json", "");
1068
1069        let result = load_config(config_path.to_str().unwrap());
1070        assert!(result.is_err());
1071        assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
1072    }
1073
1074    #[test]
1075    fn test_config_serialization_works() {
1076        let config = create_valid_config();
1077
1078        let serialized = serde_json::to_string(&config);
1079        assert!(serialized.is_ok());
1080
1081        // Just test that serialization works, not round-trip due to complex serde structure
1082        let serialized_str = serialized.unwrap();
1083        assert!(!serialized_str.is_empty());
1084        assert!(serialized_str.contains("relayers"));
1085        assert!(serialized_str.contains("signers"));
1086        assert!(serialized_str.contains("networks"));
1087    }
1088
1089    #[test]
1090    fn test_config_serialization_contains_expected_fields() {
1091        let config = create_valid_config();
1092
1093        let serialized = serde_json::to_string(&config);
1094        assert!(serialized.is_ok());
1095
1096        let serialized_str = serialized.unwrap();
1097
1098        // Check that important fields are present in serialized JSON
1099        assert!(serialized_str.contains("\"id\":\"test-1\""));
1100        assert!(serialized_str.contains("\"name\":\"Test Relayer\""));
1101        assert!(serialized_str.contains("\"network\":\"test-network\""));
1102        assert!(serialized_str.contains("\"type\":\"evm\""));
1103    }
1104
1105    #[test]
1106    fn test_validate_relayers_method() {
1107        let config = create_valid_config();
1108        let result = config.validate_relayers(&config.networks);
1109        assert!(result.is_ok());
1110    }
1111
1112    #[test]
1113    fn test_validate_signers_method() {
1114        let config = create_valid_config();
1115        let result = config.validate_signers();
1116        assert!(result.is_ok());
1117    }
1118
1119    #[test]
1120    fn test_validate_notifications_method() {
1121        let config = create_valid_config();
1122        let result = config.validate_notifications();
1123        assert!(result.is_ok());
1124    }
1125
1126    #[test]
1127    fn test_validate_networks_method() {
1128        let config = create_valid_config();
1129        let result = config.validate_networks();
1130        assert!(result.is_ok());
1131    }
1132
1133    #[test]
1134    fn test_validate_plugins_method() {
1135        let config = create_valid_config();
1136        let result = config.validate_plugins();
1137        assert!(result.is_ok());
1138    }
1139
1140    #[test]
1141    fn test_validate_plugins_method_with_empty_plugins() {
1142        let config = Config {
1143            relayers: vec![],
1144            signers: vec![],
1145            notifications: vec![],
1146            networks: NetworksFileConfig::new(vec![]).unwrap(),
1147            plugins: Some(vec![]),
1148        };
1149        let result = config.validate_plugins();
1150        assert!(result.is_ok());
1151    }
1152
1153    #[test]
1154    fn test_validate_plugins_method_with_invalid_plugin_extension() {
1155        let config = Config {
1156            relayers: vec![],
1157            signers: vec![],
1158            notifications: vec![],
1159            networks: NetworksFileConfig::new(vec![]).unwrap(),
1160            plugins: Some(vec![PluginFileConfig {
1161                id: "id".to_string(),
1162                path: "/app/plugins/test-plugin.js".to_string(),
1163                timeout: None,
1164                emit_logs: false,
1165                emit_traces: false,
1166                allow_get_invocation: false,
1167                config: None,
1168                raw_response: false,
1169                forward_logs: false,
1170            }]),
1171        };
1172        let result = config.validate_plugins();
1173        assert!(result.is_err());
1174    }
1175
1176    #[test]
1177    fn test_config_with_maximum_length_ids() {
1178        let mut config = create_valid_config();
1179        let max_length_id = "a".repeat(36); // Maximum allowed length
1180        config.relayers[0].id = max_length_id.clone();
1181        config.relayers[0].signer_id = config.signers[0].id.clone();
1182
1183        let result = config.validate();
1184        assert!(result.is_ok());
1185    }
1186
1187    #[test]
1188    fn test_config_with_special_characters_in_names() {
1189        let mut config = create_valid_config();
1190        config.relayers[0].name = "Test-Relayer_123!@#$%^&*()".to_string();
1191
1192        let result = config.validate();
1193        assert!(result.is_ok());
1194    }
1195
1196    #[test]
1197    fn test_config_with_very_long_urls() {
1198        let mut config = create_valid_config();
1199        let long_url = format!(
1200            "https://very-long-domain-name-{}.example.com/api/v1/endpoint",
1201            "x".repeat(100)
1202        );
1203        config.notifications[0].url = long_url;
1204
1205        let result = config.validate();
1206        assert!(result.is_ok());
1207    }
1208
1209    #[test]
1210    fn test_config_with_only_signers_validation() {
1211        let config = Config {
1212            relayers: vec![],
1213            signers: vec![SignerFileConfig {
1214                id: "test-signer".to_string(),
1215                config: SignerFileConfigEnum::Local(LocalSignerFileConfig {
1216                    path: "test-path".to_string(),
1217                    passphrase: PlainOrEnvValue::Plain {
1218                        value: SecretString::new("test-passphrase"),
1219                    },
1220                }),
1221            }],
1222            notifications: vec![],
1223            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
1224                common: NetworkConfigCommon {
1225                    network: "test-network".to_string(),
1226                    from: None,
1227                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
1228                        "https://rpc.test.example.com".to_string(),
1229                    )]),
1230                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1231                    average_blocktime_ms: Some(12000),
1232                    is_testnet: Some(true),
1233                    tags: Some(vec!["test".to_string()]),
1234                },
1235                chain_id: Some(31337),
1236                required_confirmations: Some(1),
1237                features: None,
1238                symbol: Some("ETH".to_string()),
1239                gas_price_cache: None,
1240            })])
1241            .unwrap(),
1242            plugins: Some(vec![]),
1243        };
1244
1245        let result = config.validate();
1246        assert!(result.is_ok());
1247    }
1248
1249    #[test]
1250    fn test_config_with_only_notifications() {
1251        let config = Config {
1252            relayers: vec![],
1253            signers: vec![],
1254            notifications: vec![NotificationConfig {
1255                id: "test-notification".to_string(),
1256                r#type: NotificationType::Webhook,
1257                url: "https://api.example.com/notifications".to_string(),
1258                signing_key: None,
1259            }],
1260            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
1261                common: NetworkConfigCommon {
1262                    network: "test-network".to_string(),
1263                    from: None,
1264                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
1265                        "https://rpc.test.example.com".to_string(),
1266                    )]),
1267                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1268                    average_blocktime_ms: Some(12000),
1269                    is_testnet: Some(true),
1270                    tags: Some(vec!["test".to_string()]),
1271                },
1272                chain_id: Some(31337),
1273                required_confirmations: Some(1),
1274                features: None,
1275                symbol: Some("ETH".to_string()),
1276                gas_price_cache: None,
1277            })])
1278            .unwrap(),
1279            plugins: Some(vec![]),
1280        };
1281
1282        let result = config.validate();
1283        assert!(result.is_ok());
1284    }
1285
1286    #[test]
1287    fn test_config_with_mixed_network_types_in_relayers() {
1288        let mut config = create_valid_config();
1289
1290        // Add Solana relayer
1291        config.relayers.push(RelayerFileConfig {
1292            id: "solana-relayer".to_string(),
1293            name: "Solana Test Relayer".to_string(),
1294            network: "devnet".to_string(),
1295            paused: false,
1296            network_type: ConfigFileNetworkType::Solana,
1297            policies: None,
1298            signer_id: "test-1".to_string(),
1299            notification_id: None,
1300            custom_rpc_urls: None,
1301        });
1302
1303        // Add Stellar relayer
1304        config.relayers.push(RelayerFileConfig {
1305            id: "stellar-relayer".to_string(),
1306            name: "Stellar Test Relayer".to_string(),
1307            network: "testnet".to_string(),
1308            paused: true,
1309            network_type: ConfigFileNetworkType::Stellar,
1310            policies: Some(ConfigFileRelayerNetworkPolicy::Stellar(
1311                ConfigFileRelayerStellarPolicy {
1312                    fee_payment_strategy: Some(ConfigFileStellarFeePaymentStrategy::Relayer),
1313                    max_fee: None,
1314                    timeout_seconds: None,
1315                    min_balance: None,
1316                    concurrent_transactions: None,
1317                    slippage_percentage: None,
1318                    fee_margin_percentage: None,
1319                    allowed_tokens: None,
1320                    swap_config: None,
1321                },
1322            )),
1323            signer_id: "test-1".to_string(),
1324            notification_id: Some("test-1".to_string()),
1325            custom_rpc_urls: None,
1326        });
1327
1328        let devnet_network = NetworkFileConfig::Solana(SolanaNetworkConfig {
1329            common: NetworkConfigCommon {
1330                network: "devnet".to_string(),
1331                from: None,
1332                rpc_urls: Some(vec![crate::models::RpcConfig::new(
1333                    "https://api.devnet.solana.com".to_string(),
1334                )]),
1335                explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]),
1336                average_blocktime_ms: Some(400),
1337                is_testnet: Some(true),
1338                tags: Some(vec!["test".to_string()]),
1339            },
1340        });
1341
1342        let testnet_network = NetworkFileConfig::Stellar(StellarNetworkConfig {
1343            common: NetworkConfigCommon {
1344                network: "testnet".to_string(),
1345                from: None,
1346                rpc_urls: Some(vec![crate::models::RpcConfig::new(
1347                    "https://soroban-testnet.stellar.org".to_string(),
1348                )]),
1349                explorer_urls: Some(vec!["https://stellar.expert/explorer/testnet".to_string()]),
1350                average_blocktime_ms: Some(5000),
1351                is_testnet: Some(true),
1352                tags: Some(vec!["test".to_string()]),
1353            },
1354            passphrase: Some("Test SDF Network ; September 2015".to_string()),
1355            horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
1356        });
1357
1358        let mut networks = config.networks.networks;
1359        networks.push(devnet_network);
1360        networks.push(testnet_network);
1361        config.networks =
1362            NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig");
1363
1364        let result = config.validate();
1365        assert!(result.is_ok());
1366    }
1367
1368    #[test]
1369    fn test_config_with_all_network_types() {
1370        let mut config = create_valid_config();
1371
1372        // Add Solana network
1373        let solana_network = NetworkFileConfig::Solana(SolanaNetworkConfig {
1374            common: NetworkConfigCommon {
1375                network: "solana-test".to_string(),
1376                from: None,
1377                rpc_urls: Some(vec![crate::models::RpcConfig::new(
1378                    "https://api.devnet.solana.com".to_string(),
1379                )]),
1380                explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1381                average_blocktime_ms: Some(400),
1382                is_testnet: Some(true),
1383                tags: Some(vec!["solana".to_string()]),
1384            },
1385        });
1386
1387        // Add Stellar network
1388        let stellar_network = NetworkFileConfig::Stellar(StellarNetworkConfig {
1389            common: NetworkConfigCommon {
1390                network: "stellar-test".to_string(),
1391                from: None,
1392                rpc_urls: Some(vec![crate::models::RpcConfig::new(
1393                    "https://horizon-testnet.stellar.org".to_string(),
1394                )]),
1395                explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1396                average_blocktime_ms: Some(5000),
1397                is_testnet: Some(true),
1398                tags: Some(vec!["stellar".to_string()]),
1399            },
1400            passphrase: Some("Test Network ; September 2015".to_string()),
1401            horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
1402        });
1403
1404        // Get the existing networks and add new ones
1405        let mut existing_networks = Vec::new();
1406        for network in config.networks.iter() {
1407            existing_networks.push(network.clone());
1408        }
1409        existing_networks.push(solana_network);
1410        existing_networks.push(stellar_network);
1411
1412        config.networks = NetworksFileConfig::new(existing_networks).unwrap();
1413
1414        let result = config.validate();
1415        assert!(result.is_ok());
1416    }
1417
1418    #[test]
1419    fn test_config_error_propagation_from_relayers() {
1420        let mut config = create_valid_config();
1421        config.relayers[0].id = "".to_string(); // Invalid empty ID
1422
1423        let result = config.validate();
1424        assert!(result.is_err());
1425        assert!(matches!(
1426            result.unwrap_err(),
1427            ConfigFileError::MissingField(_)
1428        ));
1429    }
1430
1431    #[test]
1432    fn test_config_error_propagation_from_signers() {
1433        let mut config = create_valid_config();
1434        config.signers[0].id = "".to_string(); // Invalid empty ID
1435
1436        let result = config.validate();
1437        assert!(result.is_err());
1438        // The error should be InvalidIdLength since empty ID is caught by signer validation
1439        assert!(matches!(
1440            result.unwrap_err(),
1441            ConfigFileError::InvalidIdLength(_)
1442        ));
1443    }
1444
1445    #[test]
1446    fn test_config_error_propagation_from_notifications() {
1447        let mut config = create_valid_config();
1448        config.notifications[0].id = "".to_string(); // Invalid empty ID
1449
1450        let result = config.validate();
1451        assert!(result.is_err());
1452
1453        let error = result.unwrap_err();
1454        assert!(matches!(error, ConfigFileError::InvalidFormat(_)));
1455    }
1456
1457    #[test]
1458    fn test_config_with_paused_relayers() {
1459        let mut config = create_valid_config();
1460        config.relayers[0].paused = true;
1461
1462        let result = config.validate();
1463        assert!(result.is_ok()); // Paused relayers should still be valid
1464    }
1465
1466    #[test]
1467    fn test_config_with_none_notification_id() {
1468        let mut config = create_valid_config();
1469        config.relayers[0].notification_id = None;
1470
1471        let result = config.validate();
1472        assert!(result.is_ok()); // None notification_id should be valid
1473    }
1474
1475    #[test]
1476    fn test_config_file_network_type_display() {
1477        let evm = ConfigFileNetworkType::Evm;
1478        let solana = ConfigFileNetworkType::Solana;
1479        let stellar = ConfigFileNetworkType::Stellar;
1480
1481        // Test that Debug formatting works (which is what we have)
1482        let evm_str = format!("{evm:?}");
1483        let solana_str = format!("{solana:?}");
1484        let stellar_str = format!("{stellar:?}");
1485
1486        assert!(evm_str.contains("Evm"));
1487        assert!(solana_str.contains("Solana"));
1488        assert!(stellar_str.contains("Stellar"));
1489    }
1490
1491    #[test]
1492    fn test_config_file_plugins_validation_with_empty_plugins() {
1493        let config = Config {
1494            relayers: vec![],
1495            signers: vec![],
1496            notifications: vec![],
1497            networks: NetworksFileConfig::new(vec![]).unwrap(),
1498            plugins: None,
1499        };
1500        let result = config.validate_plugins();
1501        assert!(result.is_ok());
1502    }
1503
1504    #[test]
1505    fn test_config_file_without_plugins() {
1506        let dir = tempdir().expect("Failed to create temp dir");
1507        let config_path = dir.path().join("valid_config.json");
1508
1509        let config_content = serde_json::json!({
1510            "relayers": [{
1511                "id": "test-relayer",
1512                "name": "Test Relayer",
1513                "network": "test-network",
1514                "paused": false,
1515                "network_type": "evm",
1516                "signer_id": "test-signer"
1517            }],
1518            "signers": [{
1519                "id": "test-signer",
1520                "type": "local",
1521                "config": {
1522                    "path": "tests/utils/test_keys/unit-test-local-signer.json",
1523                    "passphrase": {
1524                        "value": "test",
1525                        "type": "plain"
1526                    }
1527                }
1528            }],
1529            "notifications": [{
1530                "id": "test-notification",
1531                "type": "webhook",
1532                "url": "https://api.example.com/notifications"
1533            }],
1534            "networks": [{
1535                "type": "evm",
1536                "network": "test-network",
1537                "chain_id": 31337,
1538                "required_confirmations": 1,
1539                "symbol": "ETH",
1540                "rpc_urls": ["https://rpc.test.example.com"],
1541                "is_testnet": true
1542            }]
1543        });
1544
1545        setup_config_file(dir.path(), "valid_config.json", &config_content.to_string());
1546
1547        let result = load_config(config_path.to_str().unwrap());
1548        assert!(result.is_ok());
1549
1550        let config = result.unwrap();
1551        assert_eq!(config.relayers.len(), 1);
1552        assert_eq!(config.signers.len(), 1);
1553        assert_eq!(config.networks.len(), 1);
1554        assert!(config.plugins.is_none());
1555    }
1556}