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