openzeppelin_relayer/api/controllers/
notification.rs

1//! # Notifications Controller
2//!
3//! Handles HTTP endpoints for notification operations including:
4//! - Listing notifications
5//! - Getting notification details
6//! - Creating notifications
7//! - Updating notifications
8//! - Deleting notifications
9
10use crate::{
11    jobs::JobProducerTrait,
12    models::{
13        ApiError, ApiResponse, NetworkRepoModel, Notification, NotificationCreateRequest,
14        NotificationRepoModel, NotificationResponse, NotificationUpdateRequest, PaginationMeta,
15        PaginationQuery, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel,
16    },
17    repositories::{
18        ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository,
19        Repository, TransactionCounterTrait, TransactionRepository,
20    },
21};
22
23use actix_web::HttpResponse;
24use eyre::Result;
25
26/// Lists all notifications with pagination support.
27///
28/// # Arguments
29///
30/// * `query` - The pagination query parameters.
31/// * `state` - The application state containing the notification repository.
32///
33/// # Returns
34///
35/// A paginated list of notifications.
36pub async fn list_notifications<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
37    query: PaginationQuery,
38    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
39) -> Result<HttpResponse, ApiError>
40where
41    J: JobProducerTrait + Send + Sync + 'static,
42    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
43    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
44    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
45    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
46    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
47    TCR: TransactionCounterTrait + Send + Sync + 'static,
48    PR: PluginRepositoryTrait + Send + Sync + 'static,
49    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
50{
51    let notifications = state.notification_repository.list_paginated(query).await?;
52
53    let mapped_notifications: Vec<NotificationResponse> =
54        notifications.items.into_iter().map(|n| n.into()).collect();
55
56    Ok(HttpResponse::Ok().json(ApiResponse::paginated(
57        mapped_notifications,
58        PaginationMeta {
59            total_items: notifications.total,
60            current_page: notifications.page,
61            per_page: notifications.per_page,
62        },
63    )))
64}
65
66/// Retrieves details of a specific notification by ID.
67///
68/// # Arguments
69///
70/// * `notification_id` - The ID of the notification to retrieve.
71/// * `state` - The application state containing the notification repository.
72///
73/// # Returns
74///
75/// The notification details or an error if not found.
76pub async fn get_notification<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
77    notification_id: String,
78    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
79) -> Result<HttpResponse, ApiError>
80where
81    J: JobProducerTrait + Send + Sync + 'static,
82    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
83    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
84    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
85    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
86    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
87    TCR: TransactionCounterTrait + Send + Sync + 'static,
88    PR: PluginRepositoryTrait + Send + Sync + 'static,
89    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
90{
91    let notification = state
92        .notification_repository
93        .get_by_id(notification_id)
94        .await?;
95
96    let response = NotificationResponse::from(notification);
97    Ok(HttpResponse::Ok().json(ApiResponse::success(response)))
98}
99
100/// Creates a new notification.
101///
102/// # Arguments
103///
104/// * `request` - The notification creation request.
105/// * `state` - The application state containing the notification repository.
106///
107/// # Returns
108///
109/// The created notification or an error if creation fails.
110pub async fn create_notification<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
111    request: NotificationCreateRequest,
112    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
113) -> Result<HttpResponse, ApiError>
114where
115    J: JobProducerTrait + Send + Sync + 'static,
116    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
117    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
118    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
119    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
120    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
121    TCR: TransactionCounterTrait + Send + Sync + 'static,
122    PR: PluginRepositoryTrait + Send + Sync + 'static,
123    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
124{
125    // Convert request to core notification (validates automatically)
126    let notification = Notification::try_from(request)?;
127
128    // Convert to repository model
129    let notification_model = NotificationRepoModel::from(notification);
130    let created_notification = state
131        .notification_repository
132        .create(notification_model)
133        .await?;
134
135    let response = NotificationResponse::from(created_notification);
136    Ok(HttpResponse::Created().json(ApiResponse::success(response)))
137}
138
139/// Updates an existing notification.
140///
141/// # Arguments
142///
143/// * `notification_id` - The ID of the notification to update.
144/// * `request` - The notification update request.
145/// * `state` - The application state containing the notification repository.
146///
147/// # Returns
148///
149/// The updated notification or an error if update fails.
150pub async fn update_notification<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
151    notification_id: String,
152    request: NotificationUpdateRequest,
153    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
154) -> Result<HttpResponse, ApiError>
155where
156    J: JobProducerTrait + Send + Sync + 'static,
157    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
158    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
159    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
160    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
161    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
162    TCR: TransactionCounterTrait + Send + Sync + 'static,
163    PR: PluginRepositoryTrait + Send + Sync + 'static,
164    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
165{
166    // Get the existing notification from repository
167    let existing_repo_model = state
168        .notification_repository
169        .get_by_id(notification_id.clone())
170        .await?;
171
172    // Apply update (with validation)
173    let updated = Notification::from(existing_repo_model).apply_update(&request)?;
174
175    let saved_notification = state
176        .notification_repository
177        .update(notification_id, NotificationRepoModel::from(updated))
178        .await?;
179
180    let response = NotificationResponse::from(saved_notification);
181    Ok(HttpResponse::Ok().json(ApiResponse::success(response)))
182}
183
184/// Deletes a notification by ID.
185///
186/// # Arguments
187///
188/// * `notification_id` - The ID of the notification to delete.
189/// * `state` - The application state containing the notification repository.
190///
191/// # Returns
192///
193/// A success response or an error if deletion fails.
194///
195/// # Security
196///
197/// This endpoint ensures that notifications cannot be deleted if they are still being
198/// used by any relayers. This prevents breaking existing relayer configurations
199/// and maintains system integrity.
200pub async fn delete_notification<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
201    notification_id: String,
202    state: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
203) -> Result<HttpResponse, ApiError>
204where
205    J: JobProducerTrait + Send + Sync + 'static,
206    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
207    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
208    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
209    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
210    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
211    TCR: TransactionCounterTrait + Send + Sync + 'static,
212    PR: PluginRepositoryTrait + Send + Sync + 'static,
213    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
214{
215    // First check if the notification exists
216    let _notification = state
217        .notification_repository
218        .get_by_id(notification_id.clone())
219        .await?;
220
221    // Check if any relayers are using this notification
222    let connected_relayers = state
223        .relayer_repository
224        .list_by_notification_id(&notification_id)
225        .await?;
226
227    if !connected_relayers.is_empty() {
228        let relayer_names: Vec<String> =
229            connected_relayers.iter().map(|r| r.name.clone()).collect();
230        return Err(ApiError::BadRequest(format!(
231            "Cannot delete notification '{}' because it is being used by {} relayer(s): {}. Please remove or reconfigure these relayers before deleting the notification.",
232            notification_id,
233            connected_relayers.len(),
234            relayer_names.join(", ")
235        )));
236    }
237
238    // Safe to delete - no relayers are using this notification
239    state
240        .notification_repository
241        .delete_by_id(notification_id)
242        .await?;
243
244    Ok(HttpResponse::Ok().json(ApiResponse::success("Notification deleted successfully")))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::{
251        models::{ApiError, NotificationType, SecretString},
252        utils::mocks::mockutils::create_mock_app_state,
253    };
254    use actix_web::web::ThinData;
255
256    /// Helper function to create a test notification model
257    fn create_test_notification_model(id: &str) -> NotificationRepoModel {
258        NotificationRepoModel {
259            id: id.to_string(),
260            notification_type: NotificationType::Webhook,
261            url: "https://example.com/webhook".to_string(),
262            signing_key: Some(SecretString::new("a".repeat(32).as_str())), // 32 chars minimum
263        }
264    }
265
266    /// Helper function to create a test notification create request
267    fn create_test_notification_create_request(id: &str) -> NotificationCreateRequest {
268        NotificationCreateRequest {
269            id: Some(id.to_string()),
270            r#type: Some(NotificationType::Webhook),
271            url: "https://example.com/webhook".to_string(),
272            signing_key: Some("a".repeat(32)), // 32 chars minimum
273        }
274    }
275
276    /// Helper function to create a test notification update request
277    fn create_test_notification_update_request() -> NotificationUpdateRequest {
278        NotificationUpdateRequest {
279            r#type: Some(NotificationType::Webhook),
280            url: Some("https://updated.example.com/webhook".to_string()),
281            signing_key: Some("b".repeat(32)), // 32 chars minimum
282        }
283    }
284
285    #[actix_web::test]
286    async fn test_list_notifications_empty() {
287        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
288        let query = PaginationQuery {
289            page: 1,
290            per_page: 10,
291        };
292
293        let result = list_notifications(query, ThinData(app_state)).await;
294
295        assert!(result.is_ok());
296        let response = result.unwrap();
297        assert_eq!(response.status(), 200);
298
299        let body = actix_web::body::to_bytes(response.into_body())
300            .await
301            .unwrap();
302        let api_response: ApiResponse<Vec<NotificationResponse>> =
303            serde_json::from_slice(&body).unwrap();
304
305        assert!(api_response.success);
306        let data = api_response.data.unwrap();
307        assert_eq!(data.len(), 0);
308    }
309
310    #[actix_web::test]
311    async fn test_list_notifications_with_data() {
312        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
313
314        // Create test notifications
315        let notification1 = create_test_notification_model("test-1");
316        let notification2 = create_test_notification_model("test-2");
317
318        app_state
319            .notification_repository
320            .create(notification1)
321            .await
322            .unwrap();
323        app_state
324            .notification_repository
325            .create(notification2)
326            .await
327            .unwrap();
328
329        let query = PaginationQuery {
330            page: 1,
331            per_page: 10,
332        };
333
334        let result = list_notifications(query, ThinData(app_state)).await;
335
336        assert!(result.is_ok());
337        let response = result.unwrap();
338        assert_eq!(response.status(), 200);
339
340        let body = actix_web::body::to_bytes(response.into_body())
341            .await
342            .unwrap();
343        let api_response: ApiResponse<Vec<NotificationResponse>> =
344            serde_json::from_slice(&body).unwrap();
345
346        assert!(api_response.success);
347        let data = api_response.data.unwrap();
348        assert_eq!(data.len(), 2);
349
350        // Check that both notifications are present (order not guaranteed)
351        let ids: Vec<&String> = data.iter().map(|n| &n.id).collect();
352        assert!(ids.contains(&&"test-1".to_string()));
353        assert!(ids.contains(&&"test-2".to_string()));
354    }
355
356    #[actix_web::test]
357    async fn test_list_notifications_pagination() {
358        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
359
360        // Create multiple test notifications
361        for i in 1..=5 {
362            let notification = create_test_notification_model(&format!("test-{}", i));
363            app_state
364                .notification_repository
365                .create(notification)
366                .await
367                .unwrap();
368        }
369
370        let query = PaginationQuery {
371            page: 2,
372            per_page: 2,
373        };
374
375        let result = list_notifications(query, ThinData(app_state)).await;
376
377        assert!(result.is_ok());
378        let response = result.unwrap();
379        assert_eq!(response.status(), 200);
380
381        let body = actix_web::body::to_bytes(response.into_body())
382            .await
383            .unwrap();
384        let api_response: ApiResponse<Vec<NotificationResponse>> =
385            serde_json::from_slice(&body).unwrap();
386
387        assert!(api_response.success);
388        let data = api_response.data.unwrap();
389        assert_eq!(data.len(), 2);
390    }
391
392    #[actix_web::test]
393    async fn test_get_notification_success() {
394        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
395
396        // Create a test notification
397        let notification = create_test_notification_model("test-notification");
398        app_state
399            .notification_repository
400            .create(notification.clone())
401            .await
402            .unwrap();
403
404        let result = get_notification("test-notification".to_string(), ThinData(app_state)).await;
405
406        assert!(result.is_ok());
407        let response = result.unwrap();
408        assert_eq!(response.status(), 200);
409
410        let body = actix_web::body::to_bytes(response.into_body())
411            .await
412            .unwrap();
413        let api_response: ApiResponse<NotificationResponse> =
414            serde_json::from_slice(&body).unwrap();
415
416        assert!(api_response.success);
417        let data = api_response.data.unwrap();
418        assert_eq!(data.id, "test-notification");
419        assert_eq!(data.r#type, NotificationType::Webhook);
420        assert_eq!(data.url, "https://example.com/webhook");
421        assert!(data.has_signing_key); // Should have signing key (32 chars)
422    }
423
424    #[actix_web::test]
425    async fn test_get_notification_not_found() {
426        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
427
428        let result = get_notification("non-existent".to_string(), ThinData(app_state)).await;
429
430        assert!(result.is_err());
431        let error = result.unwrap_err();
432        assert!(matches!(error, ApiError::NotFound(_)));
433    }
434
435    #[actix_web::test]
436    async fn test_create_notification_success() {
437        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
438
439        let request = create_test_notification_create_request("new-notification");
440
441        let result = create_notification(request, ThinData(app_state)).await;
442
443        assert!(result.is_ok());
444        let response = result.unwrap();
445        assert_eq!(response.status(), 201);
446
447        let body = actix_web::body::to_bytes(response.into_body())
448            .await
449            .unwrap();
450        let api_response: ApiResponse<NotificationResponse> =
451            serde_json::from_slice(&body).unwrap();
452
453        assert!(api_response.success);
454        let data = api_response.data.unwrap();
455        assert_eq!(data.id, "new-notification");
456        assert_eq!(data.r#type, NotificationType::Webhook);
457        assert_eq!(data.url, "https://example.com/webhook");
458        assert!(data.has_signing_key); // Should have signing key (32 chars)
459    }
460
461    #[actix_web::test]
462    async fn test_create_notification_without_signing_key() {
463        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
464
465        let request = NotificationCreateRequest {
466            id: Some("new-notification".to_string()),
467            r#type: Some(NotificationType::Webhook),
468            url: "https://example.com/webhook".to_string(),
469            signing_key: None,
470        };
471
472        let result = create_notification(request, ThinData(app_state)).await;
473
474        assert!(result.is_ok());
475        let response = result.unwrap();
476        assert_eq!(response.status(), 201);
477
478        let body = actix_web::body::to_bytes(response.into_body())
479            .await
480            .unwrap();
481        let api_response: ApiResponse<NotificationResponse> =
482            serde_json::from_slice(&body).unwrap();
483
484        assert!(api_response.success);
485        let data = api_response.data.unwrap();
486        assert_eq!(data.id, "new-notification");
487        assert_eq!(data.r#type, NotificationType::Webhook);
488        assert_eq!(data.url, "https://example.com/webhook");
489        assert!(!data.has_signing_key); // Should not have signing key
490    }
491
492    #[actix_web::test]
493    async fn test_update_notification_success() {
494        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
495
496        // Create a test notification
497        let notification = create_test_notification_model("test-notification");
498        app_state
499            .notification_repository
500            .create(notification)
501            .await
502            .unwrap();
503
504        let update_request = create_test_notification_update_request();
505
506        let result = update_notification(
507            "test-notification".to_string(),
508            update_request,
509            ThinData(app_state),
510        )
511        .await;
512
513        assert!(result.is_ok());
514        let response = result.unwrap();
515        assert_eq!(response.status(), 200);
516
517        let body = actix_web::body::to_bytes(response.into_body())
518            .await
519            .unwrap();
520        let api_response: ApiResponse<NotificationResponse> =
521            serde_json::from_slice(&body).unwrap();
522
523        assert!(api_response.success);
524        let data = api_response.data.unwrap();
525        assert_eq!(data.id, "test-notification");
526        assert_eq!(data.url, "https://updated.example.com/webhook");
527        assert!(data.has_signing_key); // Should have updated signing key
528    }
529
530    #[actix_web::test]
531    async fn test_update_notification_not_found() {
532        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
533
534        let update_request = create_test_notification_update_request();
535
536        let result = update_notification(
537            "non-existent".to_string(),
538            update_request,
539            ThinData(app_state),
540        )
541        .await;
542
543        assert!(result.is_err());
544        let error = result.unwrap_err();
545        assert!(matches!(error, ApiError::NotFound(_)));
546    }
547
548    #[actix_web::test]
549    async fn test_delete_notification_success() {
550        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
551
552        // Create a test notification
553        let notification = create_test_notification_model("test-notification");
554        app_state
555            .notification_repository
556            .create(notification)
557            .await
558            .unwrap();
559
560        let result =
561            delete_notification("test-notification".to_string(), ThinData(app_state)).await;
562
563        assert!(result.is_ok());
564        let response = result.unwrap();
565        assert_eq!(response.status(), 200);
566
567        let body = actix_web::body::to_bytes(response.into_body())
568            .await
569            .unwrap();
570        let api_response: ApiResponse<&str> = serde_json::from_slice(&body).unwrap();
571
572        assert!(api_response.success);
573        assert_eq!(
574            api_response.data.unwrap(),
575            "Notification deleted successfully"
576        );
577    }
578
579    #[actix_web::test]
580    async fn test_delete_notification_not_found() {
581        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
582
583        let result = delete_notification("non-existent".to_string(), ThinData(app_state)).await;
584
585        assert!(result.is_err());
586        let error = result.unwrap_err();
587        assert!(matches!(error, ApiError::NotFound(_)));
588    }
589
590    #[actix_web::test]
591    async fn test_notification_response_conversion() {
592        let notification_model = NotificationRepoModel {
593            id: "test-id".to_string(),
594            notification_type: NotificationType::Webhook,
595            url: "https://example.com/webhook".to_string(),
596            signing_key: Some(SecretString::new("secret-key")),
597        };
598
599        let response = NotificationResponse::from(notification_model);
600
601        assert_eq!(response.id, "test-id");
602        assert_eq!(response.r#type, NotificationType::Webhook);
603        assert_eq!(response.url, "https://example.com/webhook");
604        assert!(response.has_signing_key);
605    }
606
607    #[actix_web::test]
608    async fn test_notification_response_conversion_without_signing_key() {
609        let notification_model = NotificationRepoModel {
610            id: "test-id".to_string(),
611            notification_type: NotificationType::Webhook,
612            url: "https://example.com/webhook".to_string(),
613            signing_key: None,
614        };
615
616        let response = NotificationResponse::from(notification_model);
617
618        assert_eq!(response.id, "test-id");
619        assert_eq!(response.r#type, NotificationType::Webhook);
620        assert_eq!(response.url, "https://example.com/webhook");
621        assert!(!response.has_signing_key);
622    }
623
624    #[actix_web::test]
625    async fn test_create_notification_validates_repository_creation() {
626        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
627        let app_state_2 = create_mock_app_state(None, None, None, None, None, None).await;
628
629        let request = create_test_notification_create_request("new-notification");
630        let result = create_notification(request, ThinData(app_state)).await;
631
632        assert!(result.is_ok());
633        let response = result.unwrap();
634        assert_eq!(response.status(), 201);
635
636        let body = actix_web::body::to_bytes(response.into_body())
637            .await
638            .unwrap();
639        let api_response: ApiResponse<NotificationResponse> =
640            serde_json::from_slice(&body).unwrap();
641
642        assert!(api_response.success);
643        let data = api_response.data.unwrap();
644        assert_eq!(data.id, "new-notification");
645        assert_eq!(data.r#type, NotificationType::Webhook);
646        assert_eq!(data.url, "https://example.com/webhook");
647        assert!(data.has_signing_key);
648
649        let request_2 = create_test_notification_create_request("new-notification");
650        let result_2 = create_notification(request_2, ThinData(app_state_2)).await;
651
652        assert!(result_2.is_ok());
653        let response_2 = result_2.unwrap();
654        assert_eq!(response_2.status(), 201);
655    }
656
657    #[actix_web::test]
658    async fn test_create_notification_validation_error() {
659        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
660
661        // Create a request with only invalid ID to make test deterministic
662        let request = NotificationCreateRequest {
663            id: Some("invalid@id".to_string()), // Invalid characters
664            r#type: Some(NotificationType::Webhook),
665            url: "https://valid.example.com/webhook".to_string(), // Valid URL
666            signing_key: Some("a".repeat(32)),                    // Valid signing key
667        };
668
669        let result = create_notification(request, ThinData(app_state)).await;
670
671        // Should fail with validation error
672        assert!(result.is_err());
673        if let Err(ApiError::BadRequest(msg)) = result {
674            // The validator returns the first validation error it encounters
675            // In this case, ID validation fails first
676            assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores"));
677        } else {
678            panic!("Expected BadRequest error with validation messages");
679        }
680    }
681
682    #[actix_web::test]
683    async fn test_update_notification_validation_error() {
684        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
685
686        // Create a test notification
687        let notification = create_test_notification_model("test-notification");
688        app_state
689            .notification_repository
690            .create(notification)
691            .await
692            .unwrap();
693
694        // Create an update request with invalid signing key but valid URL
695        let update_request = NotificationUpdateRequest {
696            r#type: Some(NotificationType::Webhook),
697            url: Some("https://valid.example.com/webhook".to_string()), // Valid URL
698            signing_key: Some("short".to_string()),                     // Too short
699        };
700
701        let result = update_notification(
702            "test-notification".to_string(),
703            update_request,
704            ThinData(app_state),
705        )
706        .await;
707
708        // Should fail with validation error
709        assert!(result.is_err());
710        if let Err(ApiError::BadRequest(msg)) = result {
711            // The validator returns the first error it encounters
712            // In this case, signing key validation fails first
713            assert!(
714                msg.contains("Signing key must be at least") && msg.contains("characters long")
715            );
716        } else {
717            panic!("Expected BadRequest error with validation messages");
718        }
719    }
720
721    #[actix_web::test]
722    async fn test_delete_notification_blocked_by_connected_relayers() {
723        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
724
725        // Create a test notification
726        let notification = create_test_notification_model("connected-notification");
727        app_state
728            .notification_repository
729            .create(notification)
730            .await
731            .unwrap();
732
733        // Create a relayer that uses this notification
734        let relayer = crate::models::RelayerRepoModel {
735            id: "test-relayer".to_string(),
736            name: "Test Relayer".to_string(),
737            network: "ethereum".to_string(),
738            paused: false,
739            network_type: crate::models::NetworkType::Evm,
740            signer_id: "test-signer".to_string(),
741            policies: crate::models::RelayerNetworkPolicy::Evm(
742                crate::models::RelayerEvmPolicy::default(),
743            ),
744            address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(),
745            notification_id: Some("connected-notification".to_string()), // References our notification
746            system_disabled: false,
747            custom_rpc_urls: None,
748            ..Default::default()
749        };
750        app_state.relayer_repository.create(relayer).await.unwrap();
751
752        // Try to delete the notification - should fail
753        let result =
754            delete_notification("connected-notification".to_string(), ThinData(app_state)).await;
755
756        assert!(result.is_err());
757        let error = result.unwrap_err();
758        if let ApiError::BadRequest(msg) = error {
759            assert!(msg.contains("Cannot delete notification"));
760            assert!(msg.contains("being used by"));
761            assert!(msg.contains("Test Relayer"));
762            assert!(msg.contains("remove or reconfigure"));
763        } else {
764            panic!("Expected BadRequest error");
765        }
766    }
767
768    #[actix_web::test]
769    async fn test_delete_notification_after_relayer_removed() {
770        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
771
772        // Create a test notification
773        let notification = create_test_notification_model("cleanup-notification");
774        app_state
775            .notification_repository
776            .create(notification)
777            .await
778            .unwrap();
779
780        // Create a relayer that uses this notification
781        let relayer = crate::models::RelayerRepoModel {
782            id: "temp-relayer".to_string(),
783            name: "Temporary Relayer".to_string(),
784            network: "ethereum".to_string(),
785            paused: false,
786            network_type: crate::models::NetworkType::Evm,
787            signer_id: "test-signer".to_string(),
788            policies: crate::models::RelayerNetworkPolicy::Evm(
789                crate::models::RelayerEvmPolicy::default(),
790            ),
791            address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(),
792            notification_id: Some("cleanup-notification".to_string()),
793            system_disabled: false,
794            custom_rpc_urls: None,
795            ..Default::default()
796        };
797        app_state.relayer_repository.create(relayer).await.unwrap();
798
799        // First deletion attempt should fail
800        let result =
801            delete_notification("cleanup-notification".to_string(), ThinData(app_state)).await;
802        assert!(result.is_err());
803
804        // Create new app state for second test (since app_state was consumed)
805        let app_state2 = create_mock_app_state(None, None, None, None, None, None).await;
806
807        // Re-create the notification in the new state
808        let notification2 = create_test_notification_model("cleanup-notification");
809        app_state2
810            .notification_repository
811            .create(notification2)
812            .await
813            .unwrap();
814
815        // Now notification deletion should succeed (no relayers in new state)
816        let result =
817            delete_notification("cleanup-notification".to_string(), ThinData(app_state2)).await;
818
819        assert!(result.is_ok());
820        let response = result.unwrap();
821        assert_eq!(response.status(), 200);
822    }
823
824    #[actix_web::test]
825    async fn test_delete_notification_with_multiple_relayers() {
826        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
827
828        // Create a test notification
829        let notification = create_test_notification_model("multi-relayer-notification");
830        app_state
831            .notification_repository
832            .create(notification)
833            .await
834            .unwrap();
835
836        // Create multiple relayers that use this notification
837        let relayers = vec![
838            crate::models::RelayerRepoModel {
839                id: "relayer-1".to_string(),
840                name: "EVM Relayer".to_string(),
841                network: "ethereum".to_string(),
842                paused: false,
843                network_type: crate::models::NetworkType::Evm,
844                signer_id: "test-signer".to_string(),
845                policies: crate::models::RelayerNetworkPolicy::Evm(
846                    crate::models::RelayerEvmPolicy::default(),
847                ),
848                address: "0x1111111111111111111111111111111111111111".to_string(),
849                notification_id: Some("multi-relayer-notification".to_string()),
850                system_disabled: false,
851                custom_rpc_urls: None,
852                ..Default::default()
853            },
854            crate::models::RelayerRepoModel {
855                id: "relayer-2".to_string(),
856                name: "Solana Relayer".to_string(),
857                network: "solana".to_string(),
858                paused: true, // Even paused relayers should block deletion
859                network_type: crate::models::NetworkType::Solana,
860                signer_id: "test-signer".to_string(),
861                policies: crate::models::RelayerNetworkPolicy::Solana(
862                    crate::models::RelayerSolanaPolicy::default(),
863                ),
864                address: "solana-address".to_string(),
865                notification_id: Some("multi-relayer-notification".to_string()),
866                system_disabled: false,
867                custom_rpc_urls: None,
868                ..Default::default()
869            },
870            crate::models::RelayerRepoModel {
871                id: "relayer-3".to_string(),
872                name: "Stellar Relayer".to_string(),
873                network: "stellar".to_string(),
874                paused: false,
875                network_type: crate::models::NetworkType::Stellar,
876                signer_id: "test-signer".to_string(),
877                policies: crate::models::RelayerNetworkPolicy::Stellar(
878                    crate::models::RelayerStellarPolicy::default(),
879                ),
880                address: "stellar-address".to_string(),
881                notification_id: Some("multi-relayer-notification".to_string()),
882                system_disabled: true, // Even disabled relayers should block deletion
883                custom_rpc_urls: None,
884                ..Default::default()
885            },
886        ];
887
888        // Create all relayers
889        for relayer in relayers {
890            app_state.relayer_repository.create(relayer).await.unwrap();
891        }
892
893        // Try to delete the notification - should fail with detailed error
894        let result = delete_notification(
895            "multi-relayer-notification".to_string(),
896            ThinData(app_state),
897        )
898        .await;
899
900        assert!(result.is_err());
901        let error = result.unwrap_err();
902        if let ApiError::BadRequest(msg) = error {
903            assert!(msg.contains("Cannot delete notification 'multi-relayer-notification'"));
904            assert!(msg.contains("being used by 3 relayer(s)"));
905            assert!(msg.contains("EVM Relayer"));
906            assert!(msg.contains("Solana Relayer"));
907            assert!(msg.contains("Stellar Relayer"));
908            assert!(msg.contains("remove or reconfigure"));
909        } else {
910            panic!("Expected BadRequest error, got: {:?}", error);
911        }
912    }
913
914    #[actix_web::test]
915    async fn test_delete_notification_with_some_relayers_using_different_notification() {
916        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
917
918        // Create two test notifications
919        let notification1 = create_test_notification_model("notification-to-delete");
920        let notification2 = create_test_notification_model("other-notification");
921        app_state
922            .notification_repository
923            .create(notification1)
924            .await
925            .unwrap();
926        app_state
927            .notification_repository
928            .create(notification2)
929            .await
930            .unwrap();
931
932        // Create relayers - only one uses the notification we want to delete
933        let relayer1 = crate::models::RelayerRepoModel {
934            id: "blocking-relayer".to_string(),
935            name: "Blocking Relayer".to_string(),
936            network: "ethereum".to_string(),
937            paused: false,
938            network_type: crate::models::NetworkType::Evm,
939            signer_id: "test-signer".to_string(),
940            policies: crate::models::RelayerNetworkPolicy::Evm(
941                crate::models::RelayerEvmPolicy::default(),
942            ),
943            address: "0x1111111111111111111111111111111111111111".to_string(),
944            notification_id: Some("notification-to-delete".to_string()), // This one blocks deletion
945            system_disabled: false,
946            custom_rpc_urls: None,
947            ..Default::default()
948        };
949
950        let relayer2 = crate::models::RelayerRepoModel {
951            id: "non-blocking-relayer".to_string(),
952            name: "Non-blocking Relayer".to_string(),
953            network: "polygon".to_string(),
954            paused: false,
955            network_type: crate::models::NetworkType::Evm,
956            signer_id: "test-signer".to_string(),
957            policies: crate::models::RelayerNetworkPolicy::Evm(
958                crate::models::RelayerEvmPolicy::default(),
959            ),
960            address: "0x2222222222222222222222222222222222222222".to_string(),
961            notification_id: Some("other-notification".to_string()), // This one uses different notification
962            system_disabled: false,
963            custom_rpc_urls: None,
964            ..Default::default()
965        };
966
967        let relayer3 = crate::models::RelayerRepoModel {
968            id: "no-notification-relayer".to_string(),
969            name: "No Notification Relayer".to_string(),
970            network: "bsc".to_string(),
971            paused: false,
972            network_type: crate::models::NetworkType::Evm,
973            signer_id: "test-signer".to_string(),
974            policies: crate::models::RelayerNetworkPolicy::Evm(
975                crate::models::RelayerEvmPolicy::default(),
976            ),
977            address: "0x3333333333333333333333333333333333333333".to_string(),
978            notification_id: None, // This one has no notification
979            system_disabled: false,
980            custom_rpc_urls: None,
981            ..Default::default()
982        };
983
984        app_state.relayer_repository.create(relayer1).await.unwrap();
985        app_state.relayer_repository.create(relayer2).await.unwrap();
986        app_state.relayer_repository.create(relayer3).await.unwrap();
987
988        // Try to delete the first notification - should fail because of one relayer
989        let result =
990            delete_notification("notification-to-delete".to_string(), ThinData(app_state)).await;
991
992        assert!(result.is_err());
993        let error = result.unwrap_err();
994        if let ApiError::BadRequest(msg) = error {
995            assert!(msg.contains("being used by 1 relayer(s)"));
996            assert!(msg.contains("Blocking Relayer"));
997            assert!(!msg.contains("Non-blocking Relayer")); // Should not mention the other relayer
998            assert!(!msg.contains("No Notification Relayer")); // Should not mention relayer with no notification
999        } else {
1000            panic!("Expected BadRequest error");
1001        }
1002
1003        // Try to delete the second notification - should succeed (no relayers using it in our test)
1004        let app_state2 = create_mock_app_state(None, None, None, None, None, None).await;
1005        let notification2_recreated = create_test_notification_model("other-notification");
1006        app_state2
1007            .notification_repository
1008            .create(notification2_recreated)
1009            .await
1010            .unwrap();
1011
1012        let result =
1013            delete_notification("other-notification".to_string(), ThinData(app_state2)).await;
1014
1015        assert!(result.is_ok());
1016    }
1017}