openzeppelin_relayer/repositories/notification/
notification_redis.rs

1//! Redis-backed implementation of the NotificationRepository.
2
3use crate::models::{NotificationRepoModel, PaginationQuery, RepositoryError};
4use crate::repositories::redis_base::RedisRepository;
5use crate::repositories::{BatchRetrievalResult, PaginatedResult, Repository};
6use crate::utils::{EncryptionContext, RedisConnections};
7use async_trait::async_trait;
8use redis::AsyncCommands;
9use std::fmt;
10use std::sync::Arc;
11use tracing::{debug, error, warn};
12
13const NOTIFICATION_PREFIX: &str = "notification";
14const NOTIFICATION_LIST_KEY: &str = "notification_list";
15
16#[derive(Clone)]
17pub struct RedisNotificationRepository {
18    pub connections: Arc<RedisConnections>,
19    pub key_prefix: String,
20}
21
22impl RedisRepository for RedisNotificationRepository {}
23
24impl RedisNotificationRepository {
25    pub fn new(
26        connections: Arc<RedisConnections>,
27        key_prefix: String,
28    ) -> Result<Self, RepositoryError> {
29        if key_prefix.is_empty() {
30            return Err(RepositoryError::InvalidData(
31                "Redis key prefix cannot be empty".to_string(),
32            ));
33        }
34
35        Ok(Self {
36            connections,
37            key_prefix,
38        })
39    }
40
41    /// Generate key for notification data: notification:{notification_id}
42    fn notification_key(&self, notification_id: &str) -> String {
43        format!(
44            "{}:{}:{}",
45            self.key_prefix, NOTIFICATION_PREFIX, notification_id
46        )
47    }
48
49    /// Generate key for notification list: notification_list (set of all notification IDs)
50    fn notification_list_key(&self) -> String {
51        format!("{}:{}", self.key_prefix, NOTIFICATION_LIST_KEY)
52    }
53
54    /// Batch fetch notifications by IDs
55    async fn get_notifications_by_ids(
56        &self,
57        ids: &[String],
58    ) -> Result<BatchRetrievalResult<NotificationRepoModel>, RepositoryError> {
59        if ids.is_empty() {
60            debug!("no notification IDs provided for batch fetch");
61            return Ok(BatchRetrievalResult {
62                results: vec![],
63                failed_ids: vec![],
64            });
65        }
66
67        let mut conn = self
68            .get_connection(self.connections.reader(), "get_by_ids")
69            .await?;
70        let keys: Vec<String> = ids.iter().map(|id| self.notification_key(id)).collect();
71
72        debug!(count = %keys.len(), "batch fetching notification data");
73
74        let values: Vec<Option<String>> = conn
75            .mget(&keys)
76            .await
77            .map_err(|e| self.map_redis_error(e, "batch_fetch_notifications"))?;
78
79        let mut notifications = Vec::new();
80        let mut failed_count = 0;
81        let mut failed_ids = Vec::new();
82        for (i, value) in values.into_iter().enumerate() {
83            match value {
84                Some(json) => {
85                    // Deserialize with AAD context (decryption bound to storage key)
86                    let key = keys[i].clone();
87                    match EncryptionContext::with_aad_sync(key, || {
88                        self.deserialize_entity::<NotificationRepoModel>(
89                            &json,
90                            &ids[i],
91                            "notification",
92                        )
93                    }) {
94                        Ok(notification) => notifications.push(notification),
95                        Err(e) => {
96                            failed_count += 1;
97                            error!(error = %e, "failed to deserialize notification");
98                            failed_ids.push(ids[i].clone());
99                            // Continue processing other notifications
100                        }
101                    }
102                }
103                None => {
104                    warn!("notification not found in batch fetch");
105                }
106            }
107        }
108
109        if failed_count > 0 {
110            warn!(failed_count = %failed_count, total_count = %ids.len(), failed_ids = ?failed_ids, "failed to deserialize notifications in batch");
111        }
112
113        debug!(count = %notifications.len(), "successfully fetched notifications");
114        Ok(BatchRetrievalResult {
115            results: notifications,
116            failed_ids,
117        })
118    }
119}
120
121impl fmt::Debug for RedisNotificationRepository {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.debug_struct("RedisNotificationRepository")
124            .field("pool", &"<Pool>")
125            .field("key_prefix", &self.key_prefix)
126            .finish()
127    }
128}
129
130#[async_trait]
131impl Repository<NotificationRepoModel, String> for RedisNotificationRepository {
132    async fn create(
133        &self,
134        entity: NotificationRepoModel,
135    ) -> Result<NotificationRepoModel, RepositoryError> {
136        if entity.id.is_empty() {
137            return Err(RepositoryError::InvalidData(
138                "Notification ID cannot be empty".to_string(),
139            ));
140        }
141
142        if entity.url.is_empty() {
143            return Err(RepositoryError::InvalidData(
144                "Notification URL cannot be empty".to_string(),
145            ));
146        }
147
148        let key = self.notification_key(&entity.id);
149        let notification_list_key = self.notification_list_key();
150        let mut conn = self
151            .get_connection(self.connections.primary(), "create")
152            .await?;
153
154        debug!("creating notification");
155
156        // Serialize notification with AAD context (encryption bound to storage key)
157        let value = EncryptionContext::with_aad_sync(key.clone(), || {
158            self.serialize_entity(&entity, |n| &n.id, "notification")
159        })?;
160
161        // Check if notification already exists
162        let existing: Option<String> = conn
163            .get(&key)
164            .await
165            .map_err(|e| self.map_redis_error(e, "create_notification_check"))?;
166
167        if existing.is_some() {
168            return Err(RepositoryError::ConstraintViolation(format!(
169                "Notification with ID '{}' already exists",
170                entity.id
171            )));
172        }
173
174        // Use atomic pipeline for consistency
175        let mut pipe = redis::pipe();
176        pipe.atomic();
177        pipe.set(&key, &value);
178        pipe.sadd(&notification_list_key, &entity.id);
179
180        pipe.exec_async(&mut conn)
181            .await
182            .map_err(|e| self.map_redis_error(e, "create_notification"))?;
183
184        debug!("successfully created notification");
185        Ok(entity)
186    }
187
188    async fn get_by_id(&self, id: String) -> Result<NotificationRepoModel, RepositoryError> {
189        if id.is_empty() {
190            return Err(RepositoryError::InvalidData(
191                "Notification ID cannot be empty".to_string(),
192            ));
193        }
194
195        let mut conn = self
196            .get_connection(self.connections.reader(), "get_by_id")
197            .await?;
198        let key = self.notification_key(&id);
199
200        debug!("fetching notification");
201
202        let value: Option<String> = conn
203            .get(&key)
204            .await
205            .map_err(|e| self.map_redis_error(e, "get_notification_by_id"))?;
206
207        match value {
208            Some(json) => {
209                // Deserialize notification with AAD context (decryption bound to storage key)
210                let notification = EncryptionContext::with_aad_sync(key, || {
211                    self.deserialize_entity::<NotificationRepoModel>(&json, &id, "notification")
212                })?;
213                debug!("successfully fetched notification");
214                Ok(notification)
215            }
216            None => {
217                debug!("notification not found");
218                Err(RepositoryError::NotFound(format!(
219                    "Notification with ID '{id}' not found"
220                )))
221            }
222        }
223    }
224
225    async fn list_all(&self) -> Result<Vec<NotificationRepoModel>, RepositoryError> {
226        let notification_ids = {
227            let mut conn = self
228                .get_connection(self.connections.reader(), "list_all")
229                .await?;
230            let notification_list_key = self.notification_list_key();
231
232            debug!("fetching all notification IDs");
233
234            let ids: Vec<String> = conn
235                .smembers(&notification_list_key)
236                .await
237                .map_err(|e| self.map_redis_error(e, "list_all_notification_ids"))?;
238
239            debug!(count = %ids.len(), "found notification IDs");
240            ids
241            // Connection dropped here before nested call to avoid connection doubling
242        };
243
244        let notifications = self.get_notifications_by_ids(&notification_ids).await?;
245        Ok(notifications.results)
246    }
247
248    async fn list_paginated(
249        &self,
250        query: PaginationQuery,
251    ) -> Result<PaginatedResult<NotificationRepoModel>, RepositoryError> {
252        if query.per_page == 0 {
253            return Err(RepositoryError::InvalidData(
254                "per_page must be greater than 0".to_string(),
255            ));
256        }
257
258        let (total, page_ids) = {
259            let mut conn = self
260                .get_connection(self.connections.reader(), "list_paginated")
261                .await?;
262            let notification_list_key = self.notification_list_key();
263
264            debug!(page = %query.page, per_page = %query.per_page, "fetching paginated notifications");
265
266            let all_notification_ids: Vec<String> = conn
267                .smembers(&notification_list_key)
268                .await
269                .map_err(|e| self.map_redis_error(e, "list_paginated_notification_ids"))?;
270
271            let total = all_notification_ids.len() as u64;
272            let start = ((query.page - 1) * query.per_page) as usize;
273            let end = (start + query.per_page as usize).min(all_notification_ids.len());
274
275            if start >= all_notification_ids.len() {
276                debug!(page = %query.page, total = %total, "page is beyond available data");
277                return Ok(PaginatedResult {
278                    items: vec![],
279                    total,
280                    page: query.page,
281                    per_page: query.per_page,
282                });
283            }
284
285            (total, all_notification_ids[start..end].to_vec())
286            // Connection dropped here before nested call to avoid connection doubling
287        };
288
289        let items = self.get_notifications_by_ids(&page_ids).await?;
290
291        debug!(count = %items.results.len(), page = %query.page, "successfully fetched notifications for page");
292
293        Ok(PaginatedResult {
294            items: items.results.clone(),
295            total,
296            page: query.page,
297            per_page: query.per_page,
298        })
299    }
300
301    async fn update(
302        &self,
303        id: String,
304        entity: NotificationRepoModel,
305    ) -> Result<NotificationRepoModel, RepositoryError> {
306        if id.is_empty() {
307            return Err(RepositoryError::InvalidData(
308                "Notification ID cannot be empty".to_string(),
309            ));
310        }
311
312        if id != entity.id {
313            return Err(RepositoryError::InvalidData(
314                "Notification ID in URL does not match entity ID".to_string(),
315            ));
316        }
317
318        let key = self.notification_key(&id);
319        let mut conn = self
320            .get_connection(self.connections.primary(), "update")
321            .await?;
322
323        debug!("updating notification");
324
325        // Check if notification exists
326        let existing: Option<String> = conn
327            .get(&key)
328            .await
329            .map_err(|e| self.map_redis_error(e, "update_notification_check"))?;
330
331        if existing.is_none() {
332            return Err(RepositoryError::NotFound(format!(
333                "Notification with ID '{id}' not found"
334            )));
335        }
336
337        // Serialize notification with AAD context (encryption bound to storage key)
338        let value = EncryptionContext::with_aad_sync(key.clone(), || {
339            self.serialize_entity(&entity, |n| &n.id, "notification")
340        })?;
341
342        // Update notification data
343        let _: () = conn
344            .set(&key, value)
345            .await
346            .map_err(|e| self.map_redis_error(e, "update_notification"))?;
347
348        debug!("successfully updated notification");
349        Ok(entity)
350    }
351
352    async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError> {
353        if id.is_empty() {
354            return Err(RepositoryError::InvalidData(
355                "Notification ID cannot be empty".to_string(),
356            ));
357        }
358
359        let key = self.notification_key(&id);
360        let notification_list_key = self.notification_list_key();
361        let mut conn = self
362            .get_connection(self.connections.primary(), "delete_by_id")
363            .await?;
364
365        debug!("deleting notification");
366
367        // Check if notification exists
368        let existing: Option<String> = conn
369            .get(&key)
370            .await
371            .map_err(|e| self.map_redis_error(e, "delete_notification_check"))?;
372
373        if existing.is_none() {
374            return Err(RepositoryError::NotFound(format!(
375                "Notification with ID '{id}' not found"
376            )));
377        }
378
379        // Use atomic pipeline to ensure consistency
380        let mut pipe = redis::pipe();
381        pipe.atomic();
382        pipe.del(&key);
383        pipe.srem(&notification_list_key, &id);
384
385        pipe.exec_async(&mut conn)
386            .await
387            .map_err(|e| self.map_redis_error(e, "delete_notification"))?;
388
389        debug!("successfully deleted notification");
390        Ok(())
391    }
392
393    async fn count(&self) -> Result<usize, RepositoryError> {
394        let mut conn = self
395            .get_connection(self.connections.reader(), "count")
396            .await?;
397        let notification_list_key = self.notification_list_key();
398
399        debug!("counting notifications");
400
401        let count: u64 = conn
402            .scard(&notification_list_key)
403            .await
404            .map_err(|e| self.map_redis_error(e, "count_notifications"))?;
405
406        debug!(count = %count, "notification count");
407        Ok(count as usize)
408    }
409
410    async fn has_entries(&self) -> Result<bool, RepositoryError> {
411        let mut conn = self
412            .get_connection(self.connections.reader(), "has_entries")
413            .await?;
414        let notification_list_key = self.notification_list_key();
415
416        debug!("checking if notification entries exist");
417
418        let exists: bool = conn
419            .exists(&notification_list_key)
420            .await
421            .map_err(|e| self.map_redis_error(e, "has_entries_check"))?;
422
423        debug!(exists = %exists, "notification entries exist");
424        Ok(exists)
425    }
426
427    async fn drop_all_entries(&self) -> Result<(), RepositoryError> {
428        let mut conn = self
429            .get_connection(self.connections.primary(), "drop_all_entries")
430            .await?;
431        let notification_list_key = self.notification_list_key();
432
433        debug!("dropping all notification entries");
434
435        // Get all notification IDs first
436        let notification_ids: Vec<String> = conn
437            .smembers(&notification_list_key)
438            .await
439            .map_err(|e| self.map_redis_error(e, "drop_all_entries_get_ids"))?;
440
441        if notification_ids.is_empty() {
442            debug!("no notification entries to drop");
443            return Ok(());
444        }
445
446        // Use pipeline for atomic operations
447        let mut pipe = redis::pipe();
448        pipe.atomic();
449
450        // Delete all individual notification entries
451        for notification_id in &notification_ids {
452            let notification_key = self.notification_key(notification_id);
453            pipe.del(&notification_key);
454        }
455
456        // Delete the notification list key
457        pipe.del(&notification_list_key);
458
459        pipe.exec_async(&mut conn)
460            .await
461            .map_err(|e| self.map_redis_error(e, "drop_all_entries_pipeline"))?;
462
463        debug!(count = %notification_ids.len(), "dropped notification entries");
464        Ok(())
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::models::NotificationType;
472    use tokio;
473    use uuid::Uuid;
474
475    // Helper function to create test notifications
476    fn create_test_notification(id: &str) -> NotificationRepoModel {
477        NotificationRepoModel {
478            id: id.to_string(),
479            notification_type: NotificationType::Webhook,
480            url: "http://localhost:8080/webhook".to_string(),
481            signing_key: None,
482        }
483    }
484
485    fn create_test_notification_with_url(id: &str, url: &str) -> NotificationRepoModel {
486        NotificationRepoModel {
487            id: id.to_string(),
488            notification_type: NotificationType::Webhook,
489            url: url.to_string(),
490            signing_key: None,
491        }
492    }
493
494    async fn setup_test_repo() -> RedisNotificationRepository {
495        // Use a mock Redis URL - in real integration tests, this would connect to a test Redis instance
496        let redis_url = std::env::var("REDIS_TEST_URL")
497            .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
498
499        let cfg = deadpool_redis::Config::from_url(&redis_url);
500        let pool = Arc::new(
501            cfg.builder()
502                .expect("Failed to create pool builder")
503                .max_size(16)
504                .runtime(deadpool_redis::Runtime::Tokio1)
505                .build()
506                .expect("Failed to build Redis pool"),
507        );
508        let connections = Arc::new(RedisConnections::new_single_pool(pool));
509
510        let random_id = uuid::Uuid::new_v4().to_string();
511        let key_prefix = format!("test_prefix:{random_id}");
512
513        RedisNotificationRepository::new(connections, key_prefix)
514            .expect("Failed to create RedisNotificationRepository")
515    }
516
517    #[tokio::test]
518    #[ignore = "Requires active Redis instance"]
519    async fn test_new_repository_creation() {
520        let repo = setup_test_repo().await;
521        assert!(repo.key_prefix.contains("test_prefix"));
522    }
523
524    #[tokio::test]
525    #[ignore = "Requires active Redis instance"]
526    async fn test_new_repository_empty_prefix_fails() {
527        let redis_url = std::env::var("REDIS_TEST_URL")
528            .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
529        let cfg = deadpool_redis::Config::from_url(&redis_url);
530        let pool = Arc::new(
531            cfg.builder()
532                .expect("Failed to create pool builder")
533                .max_size(16)
534                .runtime(deadpool_redis::Runtime::Tokio1)
535                .build()
536                .expect("Failed to build Redis pool"),
537        );
538        let connections = Arc::new(RedisConnections::new_single_pool(pool));
539
540        let result = RedisNotificationRepository::new(connections, "".to_string());
541        assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
542    }
543
544    #[tokio::test]
545    #[ignore = "Requires active Redis instance"]
546    async fn test_key_generation() {
547        let repo = setup_test_repo().await;
548
549        let notification_key = repo.notification_key("test-id");
550        assert!(notification_key.contains(":notification:test-id"));
551
552        let list_key = repo.notification_list_key();
553        assert!(list_key.contains(":notification_list"));
554    }
555
556    #[tokio::test]
557    #[ignore = "Requires active Redis instance"]
558    async fn test_serialize_deserialize_notification() {
559        let repo = setup_test_repo().await;
560        let random_id = Uuid::new_v4().to_string();
561        let notification = create_test_notification(&random_id);
562        let test_key = format!("test_prefix:notification:{random_id}");
563
564        let serialized = EncryptionContext::with_aad_sync(test_key.clone(), || {
565            repo.serialize_entity(&notification, |n| &n.id, "notification")
566        })
567        .expect("Serialization should succeed");
568
569        let deserialized: NotificationRepoModel =
570            EncryptionContext::with_aad_sync(test_key, || {
571                repo.deserialize_entity(&serialized, &random_id, "notification")
572            })
573            .expect("Deserialization should succeed");
574
575        assert_eq!(notification.id, deserialized.id);
576        assert_eq!(
577            notification.notification_type,
578            deserialized.notification_type
579        );
580        assert_eq!(notification.url, deserialized.url);
581    }
582
583    #[tokio::test]
584    #[ignore = "Requires active Redis instance"]
585    async fn test_create_notification() {
586        let repo = setup_test_repo().await;
587        let random_id = Uuid::new_v4().to_string();
588        let notification = create_test_notification(&random_id);
589
590        let result = repo.create(notification.clone()).await.unwrap();
591        assert_eq!(result.id, notification.id);
592        assert_eq!(result.url, notification.url);
593    }
594
595    #[tokio::test]
596    #[ignore = "Requires active Redis instance"]
597    async fn test_get_notification() {
598        let repo = setup_test_repo().await;
599        let random_id = Uuid::new_v4().to_string();
600        let notification = create_test_notification(&random_id);
601
602        repo.create(notification.clone()).await.unwrap();
603        let stored = repo.get_by_id(random_id.to_string()).await.unwrap();
604        assert_eq!(stored.id, notification.id);
605        assert_eq!(stored.url, notification.url);
606    }
607
608    #[tokio::test]
609    #[ignore = "Requires active Redis instance"]
610    async fn test_list_all_notifications() {
611        let repo = setup_test_repo().await;
612        let random_id = Uuid::new_v4().to_string();
613        let random_id2 = Uuid::new_v4().to_string();
614
615        let notification1 = create_test_notification(&random_id);
616        let notification2 = create_test_notification(&random_id2);
617
618        repo.create(notification1).await.unwrap();
619        repo.create(notification2).await.unwrap();
620
621        let notifications = repo.list_all().await.unwrap();
622        assert!(notifications.len() >= 2);
623    }
624
625    #[tokio::test]
626    #[ignore = "Requires active Redis instance"]
627    async fn test_count_notifications() {
628        let repo = setup_test_repo().await;
629        let random_id = Uuid::new_v4().to_string();
630        let notification = create_test_notification(&random_id);
631
632        let count = repo.count().await.unwrap();
633        repo.create(notification).await.unwrap();
634        assert!(repo.count().await.unwrap() > count);
635    }
636
637    #[tokio::test]
638    #[ignore = "Requires active Redis instance"]
639    async fn test_get_nonexistent_notification() {
640        let repo = setup_test_repo().await;
641        let result = repo.get_by_id("nonexistent".to_string()).await;
642        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
643    }
644
645    #[tokio::test]
646    #[ignore = "Requires active Redis instance"]
647    async fn test_duplicate_notification_creation() {
648        let repo = setup_test_repo().await;
649        let random_id = Uuid::new_v4().to_string();
650
651        let notification = create_test_notification(&random_id);
652
653        repo.create(notification.clone()).await.unwrap();
654        let result = repo.create(notification).await;
655
656        assert!(matches!(
657            result,
658            Err(RepositoryError::ConstraintViolation(_))
659        ));
660    }
661
662    #[tokio::test]
663    #[ignore = "Requires active Redis instance"]
664    async fn test_update_notification() {
665        let repo = setup_test_repo().await;
666        let random_id = Uuid::new_v4().to_string();
667        let mut notification = create_test_notification(&random_id);
668
669        // Create the notification first
670        repo.create(notification.clone()).await.unwrap();
671
672        // Update the notification
673        notification.url = "http://updated.example.com/webhook".to_string();
674        let result = repo
675            .update(random_id.to_string(), notification.clone())
676            .await
677            .unwrap();
678        assert_eq!(result.url, "http://updated.example.com/webhook");
679
680        // Verify the update by fetching the notification
681        let stored = repo.get_by_id(random_id.to_string()).await.unwrap();
682        assert_eq!(stored.url, "http://updated.example.com/webhook");
683    }
684
685    #[tokio::test]
686    #[ignore = "Requires active Redis instance"]
687    async fn test_delete_notification() {
688        let repo = setup_test_repo().await;
689        let random_id = Uuid::new_v4().to_string();
690        let notification = create_test_notification(&random_id);
691
692        // Create the notification first
693        repo.create(notification).await.unwrap();
694
695        // Verify it exists
696        let stored = repo.get_by_id(random_id.to_string()).await.unwrap();
697        assert_eq!(stored.id, random_id);
698
699        // Delete the notification
700        repo.delete_by_id(random_id.to_string()).await.unwrap();
701
702        // Verify it's gone
703        let result = repo.get_by_id(random_id.to_string()).await;
704        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
705    }
706
707    #[tokio::test]
708    #[ignore = "Requires active Redis instance"]
709    async fn test_list_paginated() {
710        let repo = setup_test_repo().await;
711
712        // Create multiple notifications
713        for i in 1..=10 {
714            let random_id = Uuid::new_v4().to_string();
715            let notification =
716                create_test_notification_with_url(&random_id, &format!("http://test{i}.com"));
717            repo.create(notification).await.unwrap();
718        }
719
720        // Test first page with 3 items per page
721        let query = PaginationQuery {
722            page: 1,
723            per_page: 3,
724        };
725        let result = repo.list_paginated(query).await.unwrap();
726        assert_eq!(result.items.len(), 3);
727        assert!(result.total >= 10);
728        assert_eq!(result.page, 1);
729        assert_eq!(result.per_page, 3);
730
731        // Test empty page (beyond total items)
732        let query = PaginationQuery {
733            page: 1000,
734            per_page: 3,
735        };
736        let result = repo.list_paginated(query).await.unwrap();
737        assert_eq!(result.items.len(), 0);
738    }
739
740    #[tokio::test]
741    #[ignore = "Requires active Redis instance"]
742    async fn test_debug_implementation() {
743        let repo = setup_test_repo().await;
744        let debug_str = format!("{repo:?}");
745        assert!(debug_str.contains("RedisNotificationRepository"));
746        assert!(debug_str.contains("test_prefix"));
747    }
748
749    #[tokio::test]
750    #[ignore = "Requires active Redis instance"]
751    async fn test_error_handling_empty_id() {
752        let repo = setup_test_repo().await;
753
754        let result = repo.get_by_id("".to_string()).await;
755        assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
756    }
757
758    #[tokio::test]
759    #[ignore = "Requires active Redis instance"]
760    async fn test_pagination_validation() {
761        let repo = setup_test_repo().await;
762
763        let query = PaginationQuery {
764            page: 1,
765            per_page: 0,
766        };
767        let result = repo.list_paginated(query).await;
768        assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
769    }
770
771    #[tokio::test]
772    #[ignore = "Requires active Redis instance"]
773    async fn test_update_nonexistent_notification() {
774        let repo = setup_test_repo().await;
775        let random_id = Uuid::new_v4().to_string();
776        let notification = create_test_notification(&random_id);
777
778        let result = repo.update(random_id.to_string(), notification).await;
779        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
780    }
781
782    #[tokio::test]
783    #[ignore = "Requires active Redis instance"]
784    async fn test_delete_nonexistent_notification() {
785        let repo = setup_test_repo().await;
786        let random_id = Uuid::new_v4().to_string();
787
788        let result = repo.delete_by_id(random_id.to_string()).await;
789        assert!(matches!(result, Err(RepositoryError::NotFound(_))));
790    }
791
792    #[tokio::test]
793    #[ignore = "Requires active Redis instance"]
794    async fn test_update_with_empty_id() {
795        let repo = setup_test_repo().await;
796        let notification = create_test_notification("test-id");
797
798        let result = repo.update("".to_string(), notification).await;
799        assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
800    }
801
802    #[tokio::test]
803    #[ignore = "Requires active Redis instance"]
804    async fn test_delete_with_empty_id() {
805        let repo = setup_test_repo().await;
806
807        let result = repo.delete_by_id("".to_string()).await;
808        assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
809    }
810
811    #[tokio::test]
812    #[ignore = "Requires active Redis instance"]
813    async fn test_update_with_mismatched_id() {
814        let repo = setup_test_repo().await;
815        let random_id = Uuid::new_v4().to_string();
816        let notification = create_test_notification(&random_id);
817
818        // Create the notification first
819        repo.create(notification.clone()).await.unwrap();
820
821        // Try to update with mismatched ID
822        let result = repo.update("different-id".to_string(), notification).await;
823        assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
824    }
825
826    #[tokio::test]
827    #[ignore = "Requires active Redis instance"]
828    async fn test_delete_maintains_list_consistency() {
829        let repo = setup_test_repo().await;
830        let random_id = Uuid::new_v4().to_string();
831        let notification = create_test_notification(&random_id);
832
833        // Create the notification
834        repo.create(notification).await.unwrap();
835
836        // Verify it's in the list
837        let all_notifications = repo.list_all().await.unwrap();
838        assert!(all_notifications.iter().any(|n| n.id == random_id));
839
840        // Delete the notification
841        repo.delete_by_id(random_id.to_string()).await.unwrap();
842
843        // Verify it's no longer in the list
844        let all_notifications = repo.list_all().await.unwrap();
845        assert!(!all_notifications.iter().any(|n| n.id == random_id));
846    }
847
848    // test has_entries
849    #[tokio::test]
850    #[ignore = "Requires active Redis instance"]
851    async fn test_has_entries() {
852        let repo = setup_test_repo().await;
853        assert!(!repo.has_entries().await.unwrap());
854
855        let notification = create_test_notification("test");
856        repo.create(notification.clone()).await.unwrap();
857        assert!(repo.has_entries().await.unwrap());
858    }
859
860    #[tokio::test]
861    #[ignore = "Requires active Redis instance"]
862    async fn test_drop_all_entries() {
863        let repo = setup_test_repo().await;
864        let notification = create_test_notification("test");
865
866        repo.create(notification.clone()).await.unwrap();
867        assert!(repo.has_entries().await.unwrap());
868
869        repo.drop_all_entries().await.unwrap();
870        assert!(!repo.has_entries().await.unwrap());
871    }
872}