openzeppelin_relayer/repositories/plugin/
mod.rs

1//! Plugin Repository Module
2//!
3//! This module provides the plugin repository layer for the OpenZeppelin Relayer service.
4//! It implements a specialized repository pattern for managing plugin configurations,
5//! supporting both in-memory and Redis-backed storage implementations.
6//!
7//! ## Features
8//!
9//! - **Plugin Management**: Store and retrieve plugin configurations
10//! - **Path Resolution**: Manage plugin script paths for execution
11//! - **Duplicate Prevention**: Ensure unique plugin IDs
12//! - **Configuration Loading**: Convert from file configurations to repository models
13//! - **Compiled Code Caching**: Cache pre-compiled JavaScript code for performance
14//!
15//! ## Repository Implementations
16//!
17//! - [`InMemoryPluginRepository`]: Fast in-memory storage for testing/development
18//! - [`RedisPluginRepository`]: Redis-backed storage for production environments
19//!
20//! ## Plugin System
21//!
22//! The plugin system allows extending the relayer functionality through external scripts.
23//! Each plugin is identified by a unique ID and contains a path to the executable script.
24//!
25
26pub mod plugin_in_memory;
27pub mod plugin_redis;
28
29pub use plugin_in_memory::*;
30pub use plugin_redis::*;
31
32use crate::utils::RedisConnections;
33use async_trait::async_trait;
34use std::{sync::Arc, time::Duration};
35
36#[cfg(test)]
37use mockall::automock;
38
39use crate::{
40    config::PluginFileConfig,
41    constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS,
42    models::{PaginationQuery, PluginModel, RepositoryError},
43    repositories::{ConversionError, PaginatedResult},
44};
45
46#[async_trait]
47#[allow(dead_code)]
48#[cfg_attr(test, automock)]
49pub trait PluginRepositoryTrait {
50    // Plugin CRUD operations
51    async fn get_by_id(&self, id: &str) -> Result<Option<PluginModel>, RepositoryError>;
52    async fn add(&self, plugin: PluginModel) -> Result<(), RepositoryError>;
53    /// Update an existing plugin. Returns the updated plugin if found.
54    async fn update(&self, plugin: PluginModel) -> Result<PluginModel, RepositoryError>;
55    async fn list_paginated(
56        &self,
57        query: PaginationQuery,
58    ) -> Result<PaginatedResult<PluginModel>, RepositoryError>;
59    async fn count(&self) -> Result<usize, RepositoryError>;
60    async fn has_entries(&self) -> Result<bool, RepositoryError>;
61    async fn drop_all_entries(&self) -> Result<(), RepositoryError>;
62
63    // Compiled code cache operations
64    /// Get compiled JavaScript code for a plugin
65    async fn get_compiled_code(&self, plugin_id: &str) -> Result<Option<String>, RepositoryError>;
66    /// Store compiled JavaScript code for a plugin
67    async fn store_compiled_code(
68        &self,
69        plugin_id: &str,
70        compiled_code: &str,
71        source_hash: Option<&str>,
72    ) -> Result<(), RepositoryError>;
73    /// Invalidate cached code for a plugin
74    async fn invalidate_compiled_code(&self, plugin_id: &str) -> Result<(), RepositoryError>;
75    /// Invalidate all cached plugin code
76    async fn invalidate_all_compiled_code(&self) -> Result<(), RepositoryError>;
77    /// Check if a plugin has cached compiled code
78    async fn has_compiled_code(&self, plugin_id: &str) -> Result<bool, RepositoryError>;
79    /// Get the source hash for cache validation
80    async fn get_source_hash(&self, plugin_id: &str) -> Result<Option<String>, RepositoryError>;
81}
82
83/// Enum wrapper for different plugin repository implementations
84#[derive(Debug, Clone)]
85pub enum PluginRepositoryStorage {
86    InMemory(InMemoryPluginRepository),
87    Redis(RedisPluginRepository),
88}
89
90impl PluginRepositoryStorage {
91    pub fn new_in_memory() -> Self {
92        Self::InMemory(InMemoryPluginRepository::new())
93    }
94
95    pub fn new_redis(
96        connections: Arc<RedisConnections>,
97        key_prefix: String,
98    ) -> Result<Self, RepositoryError> {
99        let redis_repo = RedisPluginRepository::new(connections, key_prefix)?;
100        Ok(Self::Redis(redis_repo))
101    }
102}
103
104#[async_trait]
105impl PluginRepositoryTrait for PluginRepositoryStorage {
106    async fn get_by_id(&self, id: &str) -> Result<Option<PluginModel>, RepositoryError> {
107        match self {
108            PluginRepositoryStorage::InMemory(repo) => repo.get_by_id(id).await,
109            PluginRepositoryStorage::Redis(repo) => repo.get_by_id(id).await,
110        }
111    }
112
113    async fn add(&self, plugin: PluginModel) -> Result<(), RepositoryError> {
114        match self {
115            PluginRepositoryStorage::InMemory(repo) => repo.add(plugin).await,
116            PluginRepositoryStorage::Redis(repo) => repo.add(plugin).await,
117        }
118    }
119
120    async fn update(&self, plugin: PluginModel) -> Result<PluginModel, RepositoryError> {
121        match self {
122            PluginRepositoryStorage::InMemory(repo) => repo.update(plugin).await,
123            PluginRepositoryStorage::Redis(repo) => repo.update(plugin).await,
124        }
125    }
126
127    async fn list_paginated(
128        &self,
129        query: PaginationQuery,
130    ) -> Result<PaginatedResult<PluginModel>, RepositoryError> {
131        match self {
132            PluginRepositoryStorage::InMemory(repo) => repo.list_paginated(query).await,
133            PluginRepositoryStorage::Redis(repo) => repo.list_paginated(query).await,
134        }
135    }
136
137    async fn count(&self) -> Result<usize, RepositoryError> {
138        match self {
139            PluginRepositoryStorage::InMemory(repo) => repo.count().await,
140            PluginRepositoryStorage::Redis(repo) => repo.count().await,
141        }
142    }
143
144    async fn has_entries(&self) -> Result<bool, RepositoryError> {
145        match self {
146            PluginRepositoryStorage::InMemory(repo) => repo.has_entries().await,
147            PluginRepositoryStorage::Redis(repo) => repo.has_entries().await,
148        }
149    }
150
151    async fn drop_all_entries(&self) -> Result<(), RepositoryError> {
152        match self {
153            PluginRepositoryStorage::InMemory(repo) => repo.drop_all_entries().await,
154            PluginRepositoryStorage::Redis(repo) => repo.drop_all_entries().await,
155        }
156    }
157
158    async fn get_compiled_code(&self, plugin_id: &str) -> Result<Option<String>, RepositoryError> {
159        match self {
160            PluginRepositoryStorage::InMemory(repo) => repo.get_compiled_code(plugin_id).await,
161            PluginRepositoryStorage::Redis(repo) => repo.get_compiled_code(plugin_id).await,
162        }
163    }
164
165    async fn store_compiled_code(
166        &self,
167        plugin_id: &str,
168        compiled_code: &str,
169        source_hash: Option<&str>,
170    ) -> Result<(), RepositoryError> {
171        match self {
172            PluginRepositoryStorage::InMemory(repo) => {
173                repo.store_compiled_code(plugin_id, compiled_code, source_hash)
174                    .await
175            }
176            PluginRepositoryStorage::Redis(repo) => {
177                repo.store_compiled_code(plugin_id, compiled_code, source_hash)
178                    .await
179            }
180        }
181    }
182
183    async fn invalidate_compiled_code(&self, plugin_id: &str) -> Result<(), RepositoryError> {
184        match self {
185            PluginRepositoryStorage::InMemory(repo) => {
186                repo.invalidate_compiled_code(plugin_id).await
187            }
188            PluginRepositoryStorage::Redis(repo) => repo.invalidate_compiled_code(plugin_id).await,
189        }
190    }
191
192    async fn invalidate_all_compiled_code(&self) -> Result<(), RepositoryError> {
193        match self {
194            PluginRepositoryStorage::InMemory(repo) => repo.invalidate_all_compiled_code().await,
195            PluginRepositoryStorage::Redis(repo) => repo.invalidate_all_compiled_code().await,
196        }
197    }
198
199    async fn has_compiled_code(&self, plugin_id: &str) -> Result<bool, RepositoryError> {
200        match self {
201            PluginRepositoryStorage::InMemory(repo) => repo.has_compiled_code(plugin_id).await,
202            PluginRepositoryStorage::Redis(repo) => repo.has_compiled_code(plugin_id).await,
203        }
204    }
205
206    async fn get_source_hash(&self, plugin_id: &str) -> Result<Option<String>, RepositoryError> {
207        match self {
208            PluginRepositoryStorage::InMemory(repo) => repo.get_source_hash(plugin_id).await,
209            PluginRepositoryStorage::Redis(repo) => repo.get_source_hash(plugin_id).await,
210        }
211    }
212}
213
214impl TryFrom<PluginFileConfig> for PluginModel {
215    type Error = ConversionError;
216
217    fn try_from(config: PluginFileConfig) -> Result<Self, Self::Error> {
218        let timeout = Duration::from_secs(config.timeout.unwrap_or(DEFAULT_PLUGIN_TIMEOUT_SECONDS));
219
220        Ok(PluginModel {
221            id: config.id.clone(),
222            path: config.path.clone(),
223            timeout,
224            emit_logs: config.emit_logs,
225            emit_traces: config.emit_traces,
226            raw_response: config.raw_response,
227            allow_get_invocation: config.allow_get_invocation,
228            config: config.config,
229            forward_logs: config.forward_logs,
230        })
231    }
232}
233
234impl PartialEq for PluginModel {
235    fn eq(&self, other: &Self) -> bool {
236        self.id == other.id && self.path == other.path
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use crate::{config::PluginFileConfig, constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS};
243    use std::time::Duration;
244
245    use super::*;
246
247    // ============================================
248    // Helper functions
249    // ============================================
250
251    fn create_test_plugin(id: &str, path: &str) -> PluginModel {
252        PluginModel {
253            id: id.to_string(),
254            path: path.to_string(),
255            timeout: Duration::from_secs(30),
256            emit_logs: false,
257            emit_traces: false,
258            raw_response: false,
259            allow_get_invocation: false,
260            config: None,
261            forward_logs: false,
262        }
263    }
264
265    fn create_test_plugin_with_options(
266        id: &str,
267        path: &str,
268        emit_logs: bool,
269        emit_traces: bool,
270        raw_response: bool,
271    ) -> PluginModel {
272        PluginModel {
273            id: id.to_string(),
274            path: path.to_string(),
275            timeout: Duration::from_secs(30),
276            emit_logs,
277            emit_traces,
278            raw_response,
279            allow_get_invocation: false,
280            config: None,
281            forward_logs: false,
282        }
283    }
284
285    // ============================================
286    // PluginModel TryFrom tests
287    // ============================================
288
289    #[tokio::test]
290    async fn test_try_from_default_timeout() {
291        let config = PluginFileConfig {
292            id: "test-plugin".to_string(),
293            path: "test-path".to_string(),
294            timeout: None,
295            emit_logs: false,
296            emit_traces: false,
297            raw_response: false,
298            allow_get_invocation: false,
299            config: None,
300            forward_logs: false,
301        };
302
303        let result = PluginModel::try_from(config);
304        assert!(result.is_ok());
305
306        let plugin = result.unwrap();
307        assert_eq!(plugin.id, "test-plugin");
308        assert_eq!(plugin.path, "test-path");
309        assert_eq!(
310            plugin.timeout,
311            Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS)
312        );
313    }
314
315    #[tokio::test]
316    async fn test_try_from_custom_timeout() {
317        let config = PluginFileConfig {
318            id: "test-plugin".to_string(),
319            path: "test-path".to_string(),
320            timeout: Some(120),
321            emit_logs: false,
322            emit_traces: false,
323            raw_response: false,
324            allow_get_invocation: false,
325            config: None,
326            forward_logs: false,
327        };
328
329        let result = PluginModel::try_from(config);
330        assert!(result.is_ok());
331
332        let plugin = result.unwrap();
333        assert_eq!(plugin.timeout, Duration::from_secs(120));
334    }
335
336    #[tokio::test]
337    async fn test_try_from_all_options_enabled() {
338        let mut config_map = serde_json::Map::new();
339        config_map.insert("key".to_string(), serde_json::json!("value"));
340
341        let config = PluginFileConfig {
342            id: "full-plugin".to_string(),
343            path: "/scripts/full.js".to_string(),
344            timeout: Some(60),
345            emit_logs: true,
346            emit_traces: true,
347            raw_response: true,
348            allow_get_invocation: true,
349            config: Some(config_map),
350            forward_logs: true,
351        };
352
353        let result = PluginModel::try_from(config);
354        assert!(result.is_ok());
355
356        let plugin = result.unwrap();
357        assert_eq!(plugin.id, "full-plugin");
358        assert!(plugin.emit_logs);
359        assert!(plugin.emit_traces);
360        assert!(plugin.raw_response);
361        assert!(plugin.allow_get_invocation);
362        assert!(plugin.config.is_some());
363        assert!(plugin.forward_logs);
364    }
365
366    #[tokio::test]
367    async fn test_try_from_zero_timeout() {
368        let config = PluginFileConfig {
369            id: "test".to_string(),
370            path: "path".to_string(),
371            timeout: Some(0),
372            emit_logs: false,
373            emit_traces: false,
374            raw_response: false,
375            allow_get_invocation: false,
376            config: None,
377            forward_logs: false,
378        };
379
380        let result = PluginModel::try_from(config);
381        assert!(result.is_ok());
382        assert_eq!(result.unwrap().timeout, Duration::from_secs(0));
383    }
384
385    // ============================================
386    // PluginModel PartialEq tests
387    // ============================================
388
389    #[test]
390    fn test_plugin_model_equality_same_id_and_path() {
391        let plugin1 = create_test_plugin("plugin-1", "/path/script.js");
392        let plugin2 = create_test_plugin("plugin-1", "/path/script.js");
393
394        assert_eq!(plugin1, plugin2);
395    }
396
397    #[test]
398    fn test_plugin_model_equality_different_id() {
399        let plugin1 = create_test_plugin("plugin-1", "/path/script.js");
400        let plugin2 = create_test_plugin("plugin-2", "/path/script.js");
401
402        assert_ne!(plugin1, plugin2);
403    }
404
405    #[test]
406    fn test_plugin_model_equality_different_path() {
407        let plugin1 = create_test_plugin("plugin-1", "/path/script1.js");
408        let plugin2 = create_test_plugin("plugin-1", "/path/script2.js");
409
410        assert_ne!(plugin1, plugin2);
411    }
412
413    #[test]
414    fn test_plugin_model_equality_ignores_other_fields() {
415        // Same id and path, different other fields
416        let plugin1 =
417            create_test_plugin_with_options("plugin-1", "/path/script.js", false, false, false);
418        let plugin2 =
419            create_test_plugin_with_options("plugin-1", "/path/script.js", true, true, true);
420
421        // Should be equal because only id and path matter
422        assert_eq!(plugin1, plugin2);
423    }
424
425    #[test]
426    fn test_plugin_model_equality_different_timeout() {
427        let mut plugin1 = create_test_plugin("plugin-1", "/path/script.js");
428        plugin1.timeout = Duration::from_secs(30);
429
430        let mut plugin2 = create_test_plugin("plugin-1", "/path/script.js");
431        plugin2.timeout = Duration::from_secs(60);
432
433        // Should be equal because timeout is not part of equality
434        assert_eq!(plugin1, plugin2);
435    }
436
437    // ============================================
438    // PluginRepositoryStorage constructor tests
439    // ============================================
440
441    #[tokio::test]
442    async fn test_new_in_memory_creates_empty_storage() {
443        let storage = PluginRepositoryStorage::new_in_memory();
444
445        assert_eq!(storage.count().await.unwrap(), 0);
446        assert!(!storage.has_entries().await.unwrap());
447    }
448
449    #[test]
450    fn test_storage_enum_debug() {
451        let storage = PluginRepositoryStorage::new_in_memory();
452        let debug_str = format!("{storage:?}");
453        assert!(debug_str.contains("InMemory"));
454    }
455
456    // ============================================
457    // Basic CRUD tests
458    // ============================================
459
460    #[tokio::test]
461    async fn test_plugin_repository_storage_get_by_id_existing() {
462        let storage = PluginRepositoryStorage::new_in_memory();
463        let plugin = create_test_plugin("test-plugin", "/path/to/script.js");
464
465        storage.add(plugin.clone()).await.unwrap();
466
467        let result = storage.get_by_id("test-plugin").await.unwrap();
468        assert_eq!(result, Some(plugin));
469    }
470
471    #[tokio::test]
472    async fn test_plugin_repository_storage_get_by_id_non_existing() {
473        let storage = PluginRepositoryStorage::new_in_memory();
474
475        let result = storage.get_by_id("non-existent").await.unwrap();
476        assert_eq!(result, None);
477    }
478
479    #[tokio::test]
480    async fn test_plugin_repository_storage_add_success() {
481        let storage = PluginRepositoryStorage::new_in_memory();
482        let plugin = create_test_plugin("test-plugin", "/path/to/script.js");
483
484        let result = storage.add(plugin).await;
485        assert!(result.is_ok());
486    }
487
488    #[tokio::test]
489    async fn test_plugin_repository_storage_add_duplicate() {
490        let storage = PluginRepositoryStorage::new_in_memory();
491        let plugin = create_test_plugin("test-plugin", "/path/to/script.js");
492
493        storage.add(plugin.clone()).await.unwrap();
494
495        // Try to add the same plugin again - should succeed (overwrite)
496        let result = storage.add(plugin).await;
497        assert!(result.is_ok());
498    }
499
500    #[tokio::test]
501    async fn test_plugin_repository_storage_add_multiple() {
502        let storage = PluginRepositoryStorage::new_in_memory();
503
504        for i in 1..=10 {
505            let plugin = create_test_plugin(&format!("plugin-{i}"), &format!("/path/{i}.js"));
506            storage.add(plugin).await.unwrap();
507        }
508
509        assert_eq!(storage.count().await.unwrap(), 10);
510    }
511
512    // ============================================
513    // Update tests
514    // ============================================
515
516    #[tokio::test]
517    async fn test_plugin_repository_storage_update_existing() {
518        let storage = PluginRepositoryStorage::new_in_memory();
519
520        let plugin =
521            create_test_plugin_with_options("test-plugin", "/path/script.js", false, false, false);
522        storage.add(plugin).await.unwrap();
523
524        let updated =
525            create_test_plugin_with_options("test-plugin", "/path/script.js", true, true, true);
526        let result = storage.update(updated.clone()).await;
527
528        assert!(result.is_ok());
529        let returned = result.unwrap();
530        assert!(returned.emit_logs);
531        assert!(returned.emit_traces);
532        assert!(returned.raw_response);
533    }
534
535    #[tokio::test]
536    async fn test_plugin_repository_storage_update_nonexistent() {
537        let storage = PluginRepositoryStorage::new_in_memory();
538
539        let plugin = create_test_plugin("nonexistent", "/path/script.js");
540        let result = storage.update(plugin).await;
541
542        assert!(result.is_err());
543        match result {
544            Err(RepositoryError::NotFound(msg)) => {
545                assert!(msg.contains("nonexistent"));
546            }
547            _ => panic!("Expected NotFound error"),
548        }
549    }
550
551    #[tokio::test]
552    async fn test_plugin_repository_storage_update_persists_changes() {
553        let storage = PluginRepositoryStorage::new_in_memory();
554
555        let plugin = create_test_plugin("test-plugin", "/path/script.js");
556        storage.add(plugin).await.unwrap();
557
558        let mut updated = create_test_plugin("test-plugin", "/path/updated.js");
559        updated.emit_logs = true;
560        storage.update(updated).await.unwrap();
561
562        // Verify persisted changes
563        let retrieved = storage.get_by_id("test-plugin").await.unwrap().unwrap();
564        assert!(retrieved.emit_logs);
565        assert_eq!(retrieved.path, "/path/updated.js");
566    }
567
568    #[tokio::test]
569    async fn test_plugin_repository_storage_update_does_not_affect_others() {
570        let storage = PluginRepositoryStorage::new_in_memory();
571
572        storage
573            .add(create_test_plugin("plugin-1", "/path/1.js"))
574            .await
575            .unwrap();
576        storage
577            .add(create_test_plugin("plugin-2", "/path/2.js"))
578            .await
579            .unwrap();
580        storage
581            .add(create_test_plugin("plugin-3", "/path/3.js"))
582            .await
583            .unwrap();
584
585        let mut updated = create_test_plugin("plugin-2", "/path/updated.js");
586        updated.emit_logs = true;
587        storage.update(updated).await.unwrap();
588
589        // Others unchanged
590        let p1 = storage.get_by_id("plugin-1").await.unwrap().unwrap();
591        assert_eq!(p1.path, "/path/1.js");
592        assert!(!p1.emit_logs);
593
594        let p3 = storage.get_by_id("plugin-3").await.unwrap().unwrap();
595        assert_eq!(p3.path, "/path/3.js");
596        assert!(!p3.emit_logs);
597    }
598
599    // ============================================
600    // Count tests
601    // ============================================
602
603    #[tokio::test]
604    async fn test_plugin_repository_storage_count_empty() {
605        let storage = PluginRepositoryStorage::new_in_memory();
606
607        let count = storage.count().await.unwrap();
608        assert_eq!(count, 0);
609    }
610
611    #[tokio::test]
612    async fn test_plugin_repository_storage_count_with_plugins() {
613        let storage = PluginRepositoryStorage::new_in_memory();
614
615        storage
616            .add(create_test_plugin("plugin1", "/path/1.js"))
617            .await
618            .unwrap();
619        storage
620            .add(create_test_plugin("plugin2", "/path/2.js"))
621            .await
622            .unwrap();
623        storage
624            .add(create_test_plugin("plugin3", "/path/3.js"))
625            .await
626            .unwrap();
627
628        let count = storage.count().await.unwrap();
629        assert_eq!(count, 3);
630    }
631
632    #[tokio::test]
633    async fn test_plugin_repository_storage_count_after_drop() {
634        let storage = PluginRepositoryStorage::new_in_memory();
635
636        for i in 1..=5 {
637            storage
638                .add(create_test_plugin(&format!("p{i}"), &format!("/{i}.js")))
639                .await
640                .unwrap();
641        }
642
643        assert_eq!(storage.count().await.unwrap(), 5);
644
645        storage.drop_all_entries().await.unwrap();
646
647        assert_eq!(storage.count().await.unwrap(), 0);
648    }
649
650    // ============================================
651    // has_entries tests
652    // ============================================
653
654    #[tokio::test]
655    async fn test_plugin_repository_storage_has_entries_empty() {
656        let storage = PluginRepositoryStorage::new_in_memory();
657
658        let has_entries = storage.has_entries().await.unwrap();
659        assert!(!has_entries);
660    }
661
662    #[tokio::test]
663    async fn test_plugin_repository_storage_has_entries_with_plugins() {
664        let storage = PluginRepositoryStorage::new_in_memory();
665
666        storage
667            .add(create_test_plugin("plugin1", "/path/1.js"))
668            .await
669            .unwrap();
670
671        let has_entries = storage.has_entries().await.unwrap();
672        assert!(has_entries);
673    }
674
675    // ============================================
676    // drop_all_entries tests
677    // ============================================
678
679    #[tokio::test]
680    async fn test_plugin_repository_storage_drop_all_entries_empty() {
681        let storage = PluginRepositoryStorage::new_in_memory();
682
683        let result = storage.drop_all_entries().await;
684        assert!(result.is_ok());
685
686        let count = storage.count().await.unwrap();
687        assert_eq!(count, 0);
688    }
689
690    #[tokio::test]
691    async fn test_plugin_repository_storage_drop_all_entries_with_plugins() {
692        let storage = PluginRepositoryStorage::new_in_memory();
693
694        storage
695            .add(create_test_plugin("plugin1", "/path/1.js"))
696            .await
697            .unwrap();
698        storage
699            .add(create_test_plugin("plugin2", "/path/2.js"))
700            .await
701            .unwrap();
702
703        let result = storage.drop_all_entries().await;
704        assert!(result.is_ok());
705
706        let count = storage.count().await.unwrap();
707        assert_eq!(count, 0);
708
709        let has_entries = storage.has_entries().await.unwrap();
710        assert!(!has_entries);
711    }
712
713    // ============================================
714    // Pagination tests
715    // ============================================
716
717    #[tokio::test]
718    async fn test_plugin_repository_storage_list_paginated_empty() {
719        let storage = PluginRepositoryStorage::new_in_memory();
720
721        let query = PaginationQuery {
722            page: 1,
723            per_page: 10,
724        };
725        let result = storage.list_paginated(query).await.unwrap();
726
727        assert_eq!(result.items.len(), 0);
728        assert_eq!(result.total, 0);
729        assert_eq!(result.page, 1);
730        assert_eq!(result.per_page, 10);
731    }
732
733    #[tokio::test]
734    async fn test_plugin_repository_storage_list_paginated_first_page() {
735        let storage = PluginRepositoryStorage::new_in_memory();
736
737        for i in 1..=10 {
738            storage
739                .add(create_test_plugin(
740                    &format!("plugin{i}"),
741                    &format!("/{i}.js"),
742                ))
743                .await
744                .unwrap();
745        }
746
747        let query = PaginationQuery {
748            page: 1,
749            per_page: 3,
750        };
751        let result = storage.list_paginated(query).await.unwrap();
752
753        assert_eq!(result.items.len(), 3);
754        assert_eq!(result.total, 10);
755        assert_eq!(result.page, 1);
756        assert_eq!(result.per_page, 3);
757    }
758
759    #[tokio::test]
760    async fn test_plugin_repository_storage_list_paginated_middle_page() {
761        let storage = PluginRepositoryStorage::new_in_memory();
762
763        for i in 1..=10 {
764            storage
765                .add(create_test_plugin(
766                    &format!("plugin{i}"),
767                    &format!("/{i}.js"),
768                ))
769                .await
770                .unwrap();
771        }
772
773        let query = PaginationQuery {
774            page: 2,
775            per_page: 3,
776        };
777        let result = storage.list_paginated(query).await.unwrap();
778
779        assert_eq!(result.items.len(), 3);
780        assert_eq!(result.total, 10);
781        assert_eq!(result.page, 2);
782    }
783
784    #[tokio::test]
785    async fn test_plugin_repository_storage_list_paginated_last_partial_page() {
786        let storage = PluginRepositoryStorage::new_in_memory();
787
788        for i in 1..=10 {
789            storage
790                .add(create_test_plugin(
791                    &format!("plugin{i}"),
792                    &format!("/{i}.js"),
793                ))
794                .await
795                .unwrap();
796        }
797
798        // 10 items, 3 per page, page 4 should have 1 item
799        let query = PaginationQuery {
800            page: 4,
801            per_page: 3,
802        };
803        let result = storage.list_paginated(query).await.unwrap();
804
805        assert_eq!(result.items.len(), 1);
806        assert_eq!(result.total, 10);
807    }
808
809    #[tokio::test]
810    async fn test_plugin_repository_storage_list_paginated_beyond_data() {
811        let storage = PluginRepositoryStorage::new_in_memory();
812
813        for i in 1..=5 {
814            storage
815                .add(create_test_plugin(
816                    &format!("plugin{i}"),
817                    &format!("/{i}.js"),
818                ))
819                .await
820                .unwrap();
821        }
822
823        let query = PaginationQuery {
824            page: 100,
825            per_page: 10,
826        };
827        let result = storage.list_paginated(query).await.unwrap();
828
829        assert_eq!(result.items.len(), 0);
830        assert_eq!(result.total, 5);
831    }
832
833    #[tokio::test]
834    async fn test_plugin_repository_storage_list_paginated_large_per_page() {
835        let storage = PluginRepositoryStorage::new_in_memory();
836
837        for i in 1..=5 {
838            storage
839                .add(create_test_plugin(
840                    &format!("plugin{i}"),
841                    &format!("/{i}.js"),
842                ))
843                .await
844                .unwrap();
845        }
846
847        let query = PaginationQuery {
848            page: 1,
849            per_page: 100,
850        };
851        let result = storage.list_paginated(query).await.unwrap();
852
853        assert_eq!(result.items.len(), 5);
854        assert_eq!(result.total, 5);
855    }
856
857    // ============================================
858    // Compiled code cache tests
859    // ============================================
860
861    #[tokio::test]
862    async fn test_store_and_get_compiled_code() {
863        let storage = PluginRepositoryStorage::new_in_memory();
864
865        storage
866            .store_compiled_code("plugin-1", "compiled code", None)
867            .await
868            .unwrap();
869
870        let code = storage.get_compiled_code("plugin-1").await.unwrap();
871        assert_eq!(code, Some("compiled code".to_string()));
872    }
873
874    #[tokio::test]
875    async fn test_get_compiled_code_nonexistent() {
876        let storage = PluginRepositoryStorage::new_in_memory();
877
878        let code = storage.get_compiled_code("nonexistent").await.unwrap();
879        assert_eq!(code, None);
880    }
881
882    #[tokio::test]
883    async fn test_store_compiled_code_with_source_hash() {
884        let storage = PluginRepositoryStorage::new_in_memory();
885
886        storage
887            .store_compiled_code("plugin-1", "code", Some("sha256:abc123"))
888            .await
889            .unwrap();
890
891        let code = storage.get_compiled_code("plugin-1").await.unwrap();
892        assert_eq!(code, Some("code".to_string()));
893
894        let hash = storage.get_source_hash("plugin-1").await.unwrap();
895        assert_eq!(hash, Some("sha256:abc123".to_string()));
896    }
897
898    #[tokio::test]
899    async fn test_store_compiled_code_overwrites() {
900        let storage = PluginRepositoryStorage::new_in_memory();
901
902        storage
903            .store_compiled_code("plugin-1", "old code", Some("old-hash"))
904            .await
905            .unwrap();
906        storage
907            .store_compiled_code("plugin-1", "new code", Some("new-hash"))
908            .await
909            .unwrap();
910
911        let code = storage.get_compiled_code("plugin-1").await.unwrap();
912        assert_eq!(code, Some("new code".to_string()));
913
914        let hash = storage.get_source_hash("plugin-1").await.unwrap();
915        assert_eq!(hash, Some("new-hash".to_string()));
916    }
917
918    #[tokio::test]
919    async fn test_has_compiled_code() {
920        let storage = PluginRepositoryStorage::new_in_memory();
921
922        assert!(!storage.has_compiled_code("plugin-1").await.unwrap());
923
924        storage
925            .store_compiled_code("plugin-1", "code", None)
926            .await
927            .unwrap();
928
929        assert!(storage.has_compiled_code("plugin-1").await.unwrap());
930        assert!(!storage.has_compiled_code("plugin-2").await.unwrap());
931    }
932
933    #[tokio::test]
934    async fn test_invalidate_compiled_code() {
935        let storage = PluginRepositoryStorage::new_in_memory();
936
937        storage
938            .store_compiled_code("plugin-1", "code1", None)
939            .await
940            .unwrap();
941        storage
942            .store_compiled_code("plugin-2", "code2", None)
943            .await
944            .unwrap();
945
946        storage.invalidate_compiled_code("plugin-1").await.unwrap();
947
948        assert!(!storage.has_compiled_code("plugin-1").await.unwrap());
949        assert!(storage.has_compiled_code("plugin-2").await.unwrap());
950    }
951
952    #[tokio::test]
953    async fn test_invalidate_compiled_code_nonexistent() {
954        let storage = PluginRepositoryStorage::new_in_memory();
955
956        // Should not fail
957        let result = storage.invalidate_compiled_code("nonexistent").await;
958        assert!(result.is_ok());
959    }
960
961    #[tokio::test]
962    async fn test_invalidate_all_compiled_code() {
963        let storage = PluginRepositoryStorage::new_in_memory();
964
965        for i in 1..=5 {
966            storage
967                .store_compiled_code(&format!("plugin-{i}"), &format!("code-{i}"), None)
968                .await
969                .unwrap();
970        }
971
972        storage.invalidate_all_compiled_code().await.unwrap();
973
974        for i in 1..=5 {
975            assert!(!storage
976                .has_compiled_code(&format!("plugin-{i}"))
977                .await
978                .unwrap());
979        }
980    }
981
982    #[tokio::test]
983    async fn test_invalidate_all_compiled_code_empty() {
984        let storage = PluginRepositoryStorage::new_in_memory();
985
986        // Should not fail
987        let result = storage.invalidate_all_compiled_code().await;
988        assert!(result.is_ok());
989    }
990
991    #[tokio::test]
992    async fn test_get_source_hash() {
993        let storage = PluginRepositoryStorage::new_in_memory();
994
995        // No hash
996        storage
997            .store_compiled_code("plugin-1", "code", None)
998            .await
999            .unwrap();
1000        let hash = storage.get_source_hash("plugin-1").await.unwrap();
1001        assert_eq!(hash, None);
1002
1003        // With hash
1004        storage
1005            .store_compiled_code("plugin-2", "code", Some("hash123"))
1006            .await
1007            .unwrap();
1008        let hash = storage.get_source_hash("plugin-2").await.unwrap();
1009        assert_eq!(hash, Some("hash123".to_string()));
1010    }
1011
1012    #[tokio::test]
1013    async fn test_get_source_hash_nonexistent() {
1014        let storage = PluginRepositoryStorage::new_in_memory();
1015
1016        let hash = storage.get_source_hash("nonexistent").await.unwrap();
1017        assert_eq!(hash, None);
1018    }
1019
1020    // ============================================
1021    // Cache independence tests
1022    // ============================================
1023
1024    #[tokio::test]
1025    async fn test_compiled_cache_independent_of_plugin_store() {
1026        let storage = PluginRepositoryStorage::new_in_memory();
1027
1028        // Store compiled code without adding plugin
1029        storage
1030            .store_compiled_code("plugin-1", "code", None)
1031            .await
1032            .unwrap();
1033
1034        // Plugin doesn't exist
1035        assert!(storage.get_by_id("plugin-1").await.unwrap().is_none());
1036
1037        // But compiled code does
1038        assert!(storage.has_compiled_code("plugin-1").await.unwrap());
1039    }
1040
1041    #[tokio::test]
1042    async fn test_drop_all_does_not_clear_compiled_cache() {
1043        let storage = PluginRepositoryStorage::new_in_memory();
1044
1045        storage
1046            .add(create_test_plugin("plugin-1", "/path.js"))
1047            .await
1048            .unwrap();
1049        storage
1050            .store_compiled_code("plugin-1", "code", None)
1051            .await
1052            .unwrap();
1053
1054        storage.drop_all_entries().await.unwrap();
1055
1056        // Plugin gone
1057        assert!(storage.get_by_id("plugin-1").await.unwrap().is_none());
1058
1059        // Compiled cache still has entry
1060        assert!(storage.has_compiled_code("plugin-1").await.unwrap());
1061    }
1062
1063    #[tokio::test]
1064    async fn test_invalidate_all_compiled_does_not_clear_store() {
1065        let storage = PluginRepositoryStorage::new_in_memory();
1066
1067        storage
1068            .add(create_test_plugin("plugin-1", "/path.js"))
1069            .await
1070            .unwrap();
1071        storage
1072            .store_compiled_code("plugin-1", "code", None)
1073            .await
1074            .unwrap();
1075
1076        storage.invalidate_all_compiled_code().await.unwrap();
1077
1078        // Compiled cache cleared
1079        assert!(!storage.has_compiled_code("plugin-1").await.unwrap());
1080
1081        // Plugin still exists
1082        assert!(storage.get_by_id("plugin-1").await.unwrap().is_some());
1083    }
1084
1085    // ============================================
1086    // Workflow/integration tests
1087    // ============================================
1088
1089    #[tokio::test]
1090    async fn test_plugin_repository_storage_workflow() {
1091        let storage = PluginRepositoryStorage::new_in_memory();
1092
1093        // Initially empty
1094        assert!(!storage.has_entries().await.unwrap());
1095        assert_eq!(storage.count().await.unwrap(), 0);
1096
1097        // Add plugins
1098        let plugin1 = create_test_plugin("auth-plugin", "/scripts/auth.js");
1099        let plugin2 = create_test_plugin("email-plugin", "/scripts/email.js");
1100
1101        storage.add(plugin1.clone()).await.unwrap();
1102        storage.add(plugin2.clone()).await.unwrap();
1103
1104        // Check state
1105        assert!(storage.has_entries().await.unwrap());
1106        assert_eq!(storage.count().await.unwrap(), 2);
1107
1108        // Retrieve specific plugin
1109        let retrieved = storage.get_by_id("auth-plugin").await.unwrap();
1110        assert_eq!(retrieved, Some(plugin1));
1111
1112        // Update plugin
1113        let mut updated = create_test_plugin("auth-plugin", "/scripts/auth_v2.js");
1114        updated.emit_logs = true;
1115        storage.update(updated).await.unwrap();
1116
1117        let after_update = storage.get_by_id("auth-plugin").await.unwrap().unwrap();
1118        assert_eq!(after_update.path, "/scripts/auth_v2.js");
1119        assert!(after_update.emit_logs);
1120
1121        // List all plugins
1122        let query = PaginationQuery {
1123            page: 1,
1124            per_page: 10,
1125        };
1126        let result = storage.list_paginated(query).await.unwrap();
1127        assert_eq!(result.items.len(), 2);
1128        assert_eq!(result.total, 2);
1129
1130        // Clear all plugins
1131        storage.drop_all_entries().await.unwrap();
1132        assert!(!storage.has_entries().await.unwrap());
1133        assert_eq!(storage.count().await.unwrap(), 0);
1134    }
1135
1136    #[tokio::test]
1137    async fn test_compiled_code_workflow() {
1138        let storage = PluginRepositoryStorage::new_in_memory();
1139
1140        // Add plugin
1141        storage
1142            .add(create_test_plugin("my-plugin", "/scripts/plugin.js"))
1143            .await
1144            .unwrap();
1145
1146        // Initially no compiled code
1147        assert!(!storage.has_compiled_code("my-plugin").await.unwrap());
1148
1149        // Store compiled code
1150        storage
1151            .store_compiled_code("my-plugin", "compiled JS", Some("hash-v1"))
1152            .await
1153            .unwrap();
1154
1155        // Verify
1156        assert!(storage.has_compiled_code("my-plugin").await.unwrap());
1157        assert_eq!(
1158            storage.get_compiled_code("my-plugin").await.unwrap(),
1159            Some("compiled JS".to_string())
1160        );
1161        assert_eq!(
1162            storage.get_source_hash("my-plugin").await.unwrap(),
1163            Some("hash-v1".to_string())
1164        );
1165
1166        // Update compiled code
1167        storage
1168            .store_compiled_code("my-plugin", "updated JS", Some("hash-v2"))
1169            .await
1170            .unwrap();
1171
1172        assert_eq!(
1173            storage.get_compiled_code("my-plugin").await.unwrap(),
1174            Some("updated JS".to_string())
1175        );
1176        assert_eq!(
1177            storage.get_source_hash("my-plugin").await.unwrap(),
1178            Some("hash-v2".to_string())
1179        );
1180
1181        // Invalidate
1182        storage.invalidate_compiled_code("my-plugin").await.unwrap();
1183
1184        assert!(!storage.has_compiled_code("my-plugin").await.unwrap());
1185        assert_eq!(storage.get_compiled_code("my-plugin").await.unwrap(), None);
1186    }
1187
1188    #[tokio::test]
1189    async fn test_multiple_plugins_compiled_code() {
1190        let storage = PluginRepositoryStorage::new_in_memory();
1191
1192        // Store compiled code for multiple plugins
1193        for i in 1..=5 {
1194            storage
1195                .store_compiled_code(
1196                    &format!("plugin-{i}"),
1197                    &format!("code for plugin {i}"),
1198                    Some(&format!("hash-{i}")),
1199                )
1200                .await
1201                .unwrap();
1202        }
1203
1204        // Verify all
1205        for i in 1..=5 {
1206            assert!(storage
1207                .has_compiled_code(&format!("plugin-{i}"))
1208                .await
1209                .unwrap());
1210            assert_eq!(
1211                storage
1212                    .get_compiled_code(&format!("plugin-{i}"))
1213                    .await
1214                    .unwrap(),
1215                Some(format!("code for plugin {i}"))
1216            );
1217            assert_eq!(
1218                storage
1219                    .get_source_hash(&format!("plugin-{i}"))
1220                    .await
1221                    .unwrap(),
1222                Some(format!("hash-{i}"))
1223            );
1224        }
1225
1226        // Invalidate one
1227        storage.invalidate_compiled_code("plugin-3").await.unwrap();
1228
1229        // Verify selective invalidation
1230        assert!(storage.has_compiled_code("plugin-1").await.unwrap());
1231        assert!(storage.has_compiled_code("plugin-2").await.unwrap());
1232        assert!(!storage.has_compiled_code("plugin-3").await.unwrap());
1233        assert!(storage.has_compiled_code("plugin-4").await.unwrap());
1234        assert!(storage.has_compiled_code("plugin-5").await.unwrap());
1235    }
1236}