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