1use std::collections::HashMap;
9use std::time::Instant;
10
11use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
12use a2a_protocol_types::push::TaskPushNotificationConfig;
13use a2a_protocol_types::task::TaskId;
14
15use crate::error::{ServerError, ServerResult};
16
17use super::RequestHandler;
18use super::helpers::build_call_context;
19
20impl RequestHandler {
21 pub(super) async fn validate_and_store_push_config(
41 &self,
42 config: TaskPushNotificationConfig,
43 ) -> ServerResult<TaskPushNotificationConfig> {
44 self.ensure_push_supported()?;
47 let Some(ref sender) = self.push_sender else {
48 return Err(ServerError::PushNotSupported);
49 };
50
51 let target_task = TaskId::new(config.task_id.clone().unwrap_or_default());
54 if self.task_store.get(&target_task).await?.is_none() {
55 return Err(ServerError::TaskNotFound(target_task));
56 }
57
58 if !sender.allows_private_urls() {
70 crate::push::sender::validate_webhook_url(&config.url)?;
71 }
72
73 let task_key = config.task_id.clone().unwrap_or_default();
78
79 let cap_lock = self.keyed_lock(&format!("push:{task_key}")).await;
92 let _cap_guard = cap_lock.lock().await;
93
94 let existing = self.push_config_store.list(&task_key).await?;
95 let is_update = config
96 .id
97 .as_deref()
98 .is_some_and(|id| existing.iter().any(|c| c.id.as_deref() == Some(id)));
99 if !is_update && existing.len() >= self.limits.max_push_configs_per_task {
100 return Err(ServerError::InvalidParams(format!(
101 "task {task_key} already has the maximum of {} push notification configs",
102 self.limits.max_push_configs_per_task
103 )));
104 }
105
106 if !is_update
122 && let Some(total) = self.push_config_store.count().await?
123 && total >= self.limits.max_total_push_configs
124 {
125 return Err(ServerError::Overloaded(format!(
126 "server is at the maximum of {} push notification configs; \
127 delete unused configs before creating more",
128 self.limits.max_total_push_configs
129 )));
130 }
131
132 Ok(self.push_config_store.set(config).await?)
133 }
134
135 #[allow(clippy::too_many_lines)]
141 pub async fn on_set_push_config(
142 &self,
143 config: TaskPushNotificationConfig,
144 headers: Option<&HashMap<String, String>>,
145 ) -> ServerResult<TaskPushNotificationConfig> {
146 let start = Instant::now();
147 self.metrics.on_request("CreateTaskPushNotificationConfig");
148
149 let tenant = self
150 .resolve_tenant(
151 "CreateTaskPushNotificationConfig",
152 headers,
153 config.tenant.as_deref(),
154 )
155 .await?;
156 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
157 if config.task_id.as_deref().unwrap_or("").is_empty() {
162 return Err(ServerError::InvalidParams(
163 "taskId is required for CreateTaskPushNotificationConfig".into(),
164 ));
165 }
166
167 let call_ctx = build_call_context("CreateTaskPushNotificationConfig", headers);
168 self.interceptors.run_before(&call_ctx).await?;
169 self.ensure_required_extensions(&call_ctx)?;
172
173 let result = self.validate_and_store_push_config(config).await?;
174 self.interceptors.run_after(&call_ctx).await?;
175 Ok(result)
176 })
177 .await;
178
179 let elapsed = start.elapsed();
180 match &result {
181 Ok(_) => {
182 self.metrics.on_response("CreateTaskPushNotificationConfig");
183 self.metrics
184 .on_latency("CreateTaskPushNotificationConfig", elapsed);
185 }
186 Err(e) => {
187 self.metrics
188 .on_error("CreateTaskPushNotificationConfig", e.metric_label());
189 self.metrics
190 .on_latency("CreateTaskPushNotificationConfig", elapsed);
191 }
192 }
193 result
194 }
195
196 pub async fn on_get_push_config(
204 &self,
205 params: GetPushConfigParams,
206 headers: Option<&HashMap<String, String>>,
207 ) -> ServerResult<TaskPushNotificationConfig> {
208 let start = Instant::now();
209 self.metrics.on_request("GetTaskPushNotificationConfig");
210
211 let tenant = self
212 .resolve_tenant(
213 "GetTaskPushNotificationConfig",
214 headers,
215 params.tenant.as_deref(),
216 )
217 .await?;
218 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
219 self.ensure_push_supported()?;
221 let call_ctx = build_call_context("GetTaskPushNotificationConfig", headers);
222 self.interceptors.run_before(&call_ctx).await?;
223 self.ensure_required_extensions(&call_ctx)?;
226
227 let config = self
230 .push_config_store
231 .get(¶ms.task_id, ¶ms.id)
232 .await?
233 .ok_or_else(|| ServerError::TaskNotFound(TaskId::new(¶ms.task_id)))?;
234
235 self.interceptors.run_after(&call_ctx).await?;
236 Ok(config)
237 })
238 .await;
239
240 let elapsed = start.elapsed();
241 match &result {
242 Ok(_) => {
243 self.metrics.on_response("GetTaskPushNotificationConfig");
244 self.metrics
245 .on_latency("GetTaskPushNotificationConfig", elapsed);
246 }
247 Err(e) => {
248 self.metrics
249 .on_error("GetTaskPushNotificationConfig", e.metric_label());
250 self.metrics
251 .on_latency("GetTaskPushNotificationConfig", elapsed);
252 }
253 }
254 result
255 }
256
257 pub async fn on_list_push_configs(
263 &self,
264 task_id: &str,
265 tenant: Option<&str>,
266 headers: Option<&HashMap<String, String>>,
267 ) -> ServerResult<Vec<TaskPushNotificationConfig>> {
268 let start = Instant::now();
269 self.metrics.on_request("ListTaskPushNotificationConfigs");
270
271 let tenant_owned = self
272 .resolve_tenant("ListTaskPushNotificationConfigs", headers, tenant)
273 .await?;
274 let result: ServerResult<_> =
275 crate::store::tenant::TenantContext::scope(tenant_owned, async {
276 self.ensure_push_supported()?;
278 let call_ctx = build_call_context("ListTaskPushNotificationConfigs", headers);
279 self.interceptors.run_before(&call_ctx).await?;
280 self.ensure_required_extensions(&call_ctx)?;
283 let configs = self.push_config_store.list(task_id).await?;
284 self.interceptors.run_after(&call_ctx).await?;
285 Ok(configs)
286 })
287 .await;
288
289 let elapsed = start.elapsed();
290 match &result {
291 Ok(_) => {
292 self.metrics.on_response("ListTaskPushNotificationConfigs");
293 self.metrics
294 .on_latency("ListTaskPushNotificationConfigs", elapsed);
295 }
296 Err(e) => {
297 self.metrics
298 .on_error("ListTaskPushNotificationConfigs", e.metric_label());
299 self.metrics
300 .on_latency("ListTaskPushNotificationConfigs", elapsed);
301 }
302 }
303 result
304 }
305
306 pub async fn on_delete_push_config(
312 &self,
313 params: DeletePushConfigParams,
314 headers: Option<&HashMap<String, String>>,
315 ) -> ServerResult<()> {
316 let start = Instant::now();
317 self.metrics.on_request("DeleteTaskPushNotificationConfig");
318
319 let tenant = self
320 .resolve_tenant(
321 "DeleteTaskPushNotificationConfig",
322 headers,
323 params.tenant.as_deref(),
324 )
325 .await?;
326 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
327 self.ensure_push_supported()?;
329 let call_ctx = build_call_context("DeleteTaskPushNotificationConfig", headers);
330 self.interceptors.run_before(&call_ctx).await?;
331 self.ensure_required_extensions(&call_ctx)?;
334 self.push_config_store
335 .delete(¶ms.task_id, ¶ms.id)
336 .await?;
337 self.interceptors.run_after(&call_ctx).await?;
338 Ok(())
339 })
340 .await;
341
342 let elapsed = start.elapsed();
343 match &result {
344 Ok(()) => {
345 self.metrics.on_response("DeleteTaskPushNotificationConfig");
346 self.metrics
347 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
348 }
349 Err(e) => {
350 self.metrics
351 .on_error("DeleteTaskPushNotificationConfig", e.metric_label());
352 self.metrics
353 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
354 }
355 }
356 result
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::agent_executor;
364 use crate::builder::RequestHandlerBuilder;
365
366 struct DummyExecutor;
367 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
368
369 fn make_handler() -> RequestHandler {
370 RequestHandlerBuilder::new(DummyExecutor).build().unwrap()
371 }
372
373 fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
374 TaskPushNotificationConfig {
375 tenant: None,
376 id: Some("cfg-1".to_owned()),
377 task_id: Some(task_id.to_owned()),
378 url: "https://example.com/webhook".to_owned(),
379 token: None,
380 authentication: None,
381 }
382 }
383
384 async fn save_task(handler: &RequestHandler, id: &str) {
387 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
388 let task = Task {
389 id: TaskId::new(id),
390 context_id: ContextId::new("ctx"),
391 status: TaskStatus::new(TaskState::Submitted),
392 history: None,
393 artifacts: None,
394 metadata: None,
395 };
396 handler.task_store.save(&task).await.unwrap();
397 }
398
399 #[derive(Debug)]
404 struct CapTestSender;
405
406 impl crate::push::PushSender for CapTestSender {
407 fn send<'a>(
408 &'a self,
409 _url: &'a str,
410 _event: &'a a2a_protocol_types::events::StreamResponse,
411 _config: &'a TaskPushNotificationConfig,
412 ) -> std::pin::Pin<
413 Box<
414 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
415 + Send
416 + 'a,
417 >,
418 > {
419 Box::pin(async { Ok(()) })
420 }
421 }
422
423 #[derive(Debug, Default)]
431 struct SlowListStore(crate::push::InMemoryPushConfigStore);
432
433 type StoreFuture<'a, T> = std::pin::Pin<
434 Box<dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<T>> + Send + 'a>,
435 >;
436
437 impl crate::push::PushConfigStore for SlowListStore {
438 fn set(
439 &self,
440 config: TaskPushNotificationConfig,
441 ) -> StoreFuture<'_, TaskPushNotificationConfig> {
442 self.0.set(config)
443 }
444 fn get<'a>(
445 &'a self,
446 task_id: &'a str,
447 id: &'a str,
448 ) -> StoreFuture<'a, Option<TaskPushNotificationConfig>> {
449 self.0.get(task_id, id)
450 }
451 fn list<'a>(
452 &'a self,
453 task_id: &'a str,
454 ) -> StoreFuture<'a, Vec<TaskPushNotificationConfig>> {
455 Box::pin(async move {
456 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
457 self.0.list(task_id).await
458 })
459 }
460 fn delete<'a>(&'a self, task_id: &'a str, id: &'a str) -> StoreFuture<'a, ()> {
461 self.0.delete(task_id, id)
462 }
463 }
464
465 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
490 async fn concurrent_creates_cannot_exceed_the_per_task_cap() {
491 use std::sync::Arc;
492
493 const CAP: usize = 5;
494 const WRITERS: usize = 32;
495
496 let handler = Arc::new(
497 RequestHandlerBuilder::new(DummyExecutor)
498 .with_push_sender(CapTestSender)
499 .with_push_config_store(SlowListStore::default())
500 .with_handler_limits(
501 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(CAP),
502 )
503 .build()
504 .unwrap(),
505 );
506 save_task(&handler, "task-1").await;
507
508 let mut creates = Vec::new();
509 for i in 0..WRITERS {
510 let each = Arc::clone(&handler);
511 creates.push(tokio::spawn(async move {
512 each.on_set_push_config(
513 TaskPushNotificationConfig {
514 tenant: None,
515 id: Some(format!("cfg-{i}")),
516 task_id: Some("task-1".to_owned()),
517 url: format!("https://example.com/hook/{i}"),
518 token: None,
519 authentication: None,
520 },
521 None,
522 )
523 .await
524 .is_ok()
525 }));
526 }
527 let mut accepted = 0usize;
528 for create in creates {
529 if create.await.unwrap_or(false) {
530 accepted += 1;
531 }
532 }
533
534 let stored = handler
535 .on_list_push_configs("task-1", None, None)
536 .await
537 .expect("list")
538 .len();
539
540 assert_eq!(
541 stored, CAP,
542 "the store must hold exactly the cap; {WRITERS} concurrent creates stored {stored}"
543 );
544 assert_eq!(
545 accepted, CAP,
546 "and exactly {CAP} callers must have been told they succeeded, not {accepted} — \
547 a caller handed Ok for a config that breaks the cap was lied to"
548 );
549 }
550
551 #[tokio::test]
554 async fn set_push_config_without_sender_returns_push_not_supported() {
555 let handler = make_handler();
556 let config = make_push_config("task-1");
557 let result = handler.on_set_push_config(config, None).await;
558 assert!(
559 matches!(result, Err(crate::error::ServerError::PushNotSupported)),
560 "expected PushNotSupported, got: {result:?}"
561 );
562 }
563
564 #[tokio::test]
569 async fn set_push_config_without_task_id_returns_invalid_params() {
570 use crate::push::PushSender;
571 use a2a_protocol_types::events::StreamResponse;
572 use std::future::Future;
573 use std::pin::Pin;
574
575 struct NoopSender;
576 impl PushSender for NoopSender {
577 fn send<'a>(
578 &'a self,
579 _url: &'a str,
580 _event: &'a StreamResponse,
581 _config: &'a TaskPushNotificationConfig,
582 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
583 {
584 Box::pin(async { Ok(()) })
585 }
586 fn allows_private_urls(&self) -> bool {
587 true
588 }
589 }
590
591 let handler = RequestHandlerBuilder::new(DummyExecutor)
592 .with_push_sender(NoopSender)
593 .build()
594 .unwrap();
595
596 let config = TaskPushNotificationConfig {
597 tenant: None,
598 id: None,
599 task_id: None,
600 url: "https://example.com/webhook".to_owned(),
601 token: None,
602 authentication: None,
603 };
604 let result = handler.on_set_push_config(config, None).await;
605 match result {
606 Err(crate::error::ServerError::InvalidParams(msg)) => {
607 assert!(msg.contains("taskId"), "got: {msg}");
608 }
609 other => panic!("expected InvalidParams for missing taskId, got: {other:?}"),
610 }
611 }
612
613 #[tokio::test]
627 async fn set_push_config_rejects_private_url_under_the_default_sender_policy() {
628 use crate::push::PushSender;
629 use a2a_protocol_types::events::StreamResponse;
630 use std::future::Future;
631 use std::pin::Pin;
632
633 struct DefaultPolicySender;
636 impl PushSender for DefaultPolicySender {
637 fn send<'a>(
638 &'a self,
639 _url: &'a str,
640 _event: &'a StreamResponse,
641 _config: &'a TaskPushNotificationConfig,
642 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
643 {
644 Box::pin(async { Ok(()) })
645 }
646 }
647
648 let handler = RequestHandlerBuilder::new(DummyExecutor)
649 .with_push_sender(DefaultPolicySender)
650 .build()
651 .unwrap();
652 save_task(&handler, "task-ssrf").await;
653
654 let config = TaskPushNotificationConfig {
655 tenant: None,
656 id: Some("cfg-ssrf".to_owned()),
657 task_id: Some("task-ssrf".to_owned()),
658 url: "http://127.0.0.1:9000/webhook".to_owned(),
659 token: None,
660 authentication: None,
661 };
662
663 match handler.on_set_push_config(config, None).await {
668 Err(e) => {
669 let msg = e.to_string();
670 assert!(
671 msg.contains("private/loopback"),
672 "expected the SSRF rejection, got: {msg}"
673 );
674 }
675 Ok(v) => panic!(
676 "a sender taking the default policy must refuse a loopback webhook URL, got: Ok({v:?})"
677 ),
678 }
679 }
680
681 #[tokio::test]
685 async fn set_push_config_enforces_global_cap() {
686 use crate::push::PushSender;
687 use a2a_protocol_types::events::StreamResponse;
688 use std::future::Future;
689 use std::pin::Pin;
690
691 struct NoopSender;
692 impl PushSender for NoopSender {
693 fn send<'a>(
694 &'a self,
695 _url: &'a str,
696 _event: &'a StreamResponse,
697 _config: &'a TaskPushNotificationConfig,
698 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
699 {
700 Box::pin(async { Ok(()) })
701 }
702 }
703
704 let handler = RequestHandlerBuilder::new(DummyExecutor)
705 .with_push_sender(NoopSender)
706 .with_handler_limits(
707 crate::handler::HandlerLimits::default().with_max_total_push_configs(2),
708 )
709 .build()
710 .unwrap();
711
712 for i in 0..2 {
714 save_task(&handler, &format!("task-{i}")).await;
715 let cfg = TaskPushNotificationConfig {
716 tenant: None,
717 id: Some(format!("cfg-{i}")),
718 task_id: Some(format!("task-{i}")),
719 url: "https://example.com/webhook".to_owned(),
720 token: None,
721 authentication: None,
722 };
723 handler
724 .on_set_push_config(cfg, None)
725 .await
726 .expect("creates under the global cap should succeed");
727 }
728
729 save_task(&handler, "task-x").await;
731 let cfg = TaskPushNotificationConfig {
732 tenant: None,
733 id: Some("cfg-x".to_owned()),
734 task_id: Some("task-x".to_owned()),
735 url: "https://example.com/webhook".to_owned(),
736 token: None,
737 authentication: None,
738 };
739 let result = handler.on_set_push_config(cfg, None).await;
740 assert!(
741 matches!(result, Err(crate::error::ServerError::Overloaded(_))),
742 "global push-config cap must reject, got {result:?}"
743 );
744 }
745
746 #[tokio::test]
750 async fn set_push_config_update_allowed_at_per_task_cap() {
751 use crate::push::PushSender;
752 use a2a_protocol_types::events::StreamResponse;
753 use std::future::Future;
754 use std::pin::Pin;
755
756 struct NoopSender;
757 impl PushSender for NoopSender {
758 fn send<'a>(
759 &'a self,
760 _url: &'a str,
761 _event: &'a StreamResponse,
762 _config: &'a TaskPushNotificationConfig,
763 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
764 {
765 Box::pin(async { Ok(()) })
766 }
767 }
768
769 let handler = RequestHandlerBuilder::new(DummyExecutor)
771 .with_push_sender(NoopSender)
772 .with_handler_limits(
773 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(1),
774 )
775 .build()
776 .unwrap();
777
778 save_task(&handler, "task-1").await;
779 let make = |url: &str| TaskPushNotificationConfig {
780 tenant: None,
781 id: Some("cfg-1".to_owned()),
782 task_id: Some("task-1".to_owned()),
783 url: url.to_owned(),
784 token: None,
785 authentication: None,
786 };
787
788 handler
790 .on_set_push_config(make("https://example.com/a"), None)
791 .await
792 .expect("first create should succeed");
793
794 handler
796 .on_set_push_config(make("https://example.com/b"), None)
797 .await
798 .expect("updating an existing config at the cap must be allowed");
799
800 let mut newcfg = make("https://example.com/c");
802 newcfg.id = Some("cfg-2".to_owned());
803 let rejected = handler.on_set_push_config(newcfg, None).await;
804 assert!(
805 matches!(rejected, Err(crate::error::ServerError::InvalidParams(_))),
806 "a new config beyond the per-task cap must be rejected, got {rejected:?}"
807 );
808 }
809
810 struct NoopSender;
813 impl crate::push::PushSender for NoopSender {
814 fn send<'a>(
815 &'a self,
816 _url: &'a str,
817 _event: &'a a2a_protocol_types::events::StreamResponse,
818 _config: &'a TaskPushNotificationConfig,
819 ) -> std::pin::Pin<
820 Box<
821 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
822 + Send
823 + 'a,
824 >,
825 > {
826 Box::pin(async { Ok(()) })
827 }
828 fn allows_private_urls(&self) -> bool {
829 true
830 }
831 }
832
833 fn card_with(
835 caps: a2a_protocol_types::agent_card::AgentCapabilities,
836 ) -> a2a_protocol_types::agent_card::AgentCard {
837 use a2a_protocol_types::agent_card::{AgentCard, AgentInterface};
838 AgentCard {
839 url: None,
840 name: "Test Agent".into(),
841 description: "A test agent".into(),
842 version: "1.0.0".into(),
843 supported_interfaces: vec![AgentInterface {
844 url: "http://localhost:8080".into(),
845 protocol_binding: "JSONRPC".into(),
846 protocol_version: "1.0.0".into(),
847 tenant: None,
848 }],
849 default_input_modes: vec![],
850 default_output_modes: vec![],
851 skills: vec![],
852 capabilities: caps,
853 provider: None,
854 icon_url: None,
855 documentation_url: None,
856 security_schemes: None,
857 security_requirements: None,
858 signatures: None,
859 }
860 }
861
862 #[tokio::test]
865 async fn set_push_config_for_missing_task_returns_task_not_found() {
866 let handler = RequestHandlerBuilder::new(DummyExecutor)
867 .with_push_sender(NoopSender)
868 .build()
869 .unwrap();
870 let config = make_push_config("ghost-task");
871 let result = handler.on_set_push_config(config, None).await;
872 assert!(
873 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
874 "expected TaskNotFound for a config targeting a missing task, got: {result:?}"
875 );
876 }
877
878 #[tokio::test]
882 async fn push_ops_rejected_when_card_lacks_capability() {
883 use a2a_protocol_types::agent_card::AgentCapabilities;
884 use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
885
886 let handler = RequestHandlerBuilder::new(DummyExecutor)
887 .with_push_sender(NoopSender)
888 .with_agent_card(card_with(AgentCapabilities::none()))
889 .build()
890 .unwrap();
891
892 let set = handler
893 .on_set_push_config(make_push_config("t1"), None)
894 .await;
895 assert!(
896 matches!(set, Err(crate::error::ServerError::PushNotSupported)),
897 "set must be rejected, got: {set:?}"
898 );
899
900 let get = handler
901 .on_get_push_config(
902 GetPushConfigParams {
903 tenant: None,
904 task_id: "t1".into(),
905 id: "cfg-1".into(),
906 },
907 None,
908 )
909 .await;
910 assert!(
911 matches!(get, Err(crate::error::ServerError::PushNotSupported)),
912 "get must be rejected, got: {get:?}"
913 );
914
915 let list = handler.on_list_push_configs("t1", None, None).await;
916 assert!(
917 matches!(list, Err(crate::error::ServerError::PushNotSupported)),
918 "list must be rejected, got: {list:?}"
919 );
920
921 let delete = handler
922 .on_delete_push_config(
923 DeletePushConfigParams {
924 tenant: None,
925 task_id: "t1".into(),
926 id: "cfg-1".into(),
927 },
928 None,
929 )
930 .await;
931 assert!(
932 matches!(delete, Err(crate::error::ServerError::PushNotSupported)),
933 "delete must be rejected, got: {delete:?}"
934 );
935 }
936
937 #[tokio::test]
940 async fn push_ops_allowed_when_card_has_capability() {
941 use a2a_protocol_types::agent_card::AgentCapabilities;
942
943 let handler = RequestHandlerBuilder::new(DummyExecutor)
944 .with_push_sender(NoopSender)
945 .with_agent_card(card_with(
946 AgentCapabilities::none().with_push_notifications(true),
947 ))
948 .build()
949 .unwrap();
950 save_task(&handler, "t1").await;
951
952 handler
953 .on_set_push_config(make_push_config("t1"), None)
954 .await
955 .expect("set should succeed when push capability is advertised");
956 let configs = handler
957 .on_list_push_configs("t1", None, None)
958 .await
959 .expect("list should succeed");
960 assert_eq!(configs.len(), 1, "the created config should be listed");
961 }
962
963 #[tokio::test]
966 async fn get_push_config_not_found_returns_task_not_found() {
967 use a2a_protocol_types::params::GetPushConfigParams;
970
971 let handler = make_handler();
972 let params = GetPushConfigParams {
973 tenant: None,
974 task_id: "no-task".to_owned(),
975 id: "no-id".to_owned(),
976 };
977 let result = handler.on_get_push_config(params, None).await;
978 assert!(
979 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
980 "expected TaskNotFound for missing config, got: {result:?}"
981 );
982 }
983
984 #[tokio::test]
987 async fn list_push_configs_empty_returns_empty_vec() {
988 let handler = make_handler();
989 let result = handler
990 .on_list_push_configs("no-task", None, None)
991 .await
992 .expect("list should succeed on empty store");
993 assert!(
994 result.is_empty(),
995 "listing configs for an unknown task should return an empty vec"
996 );
997 }
998
999 #[tokio::test]
1002 async fn delete_push_config_nonexistent_returns_ok() {
1003 use a2a_protocol_types::params::DeletePushConfigParams;
1004
1005 let handler = make_handler();
1006 let params = DeletePushConfigParams {
1007 tenant: None,
1008 task_id: "no-task".to_owned(),
1009 id: "no-id".to_owned(),
1010 };
1011 let result = handler.on_delete_push_config(params, None).await;
1014 assert!(
1015 result.is_ok(),
1016 "deleting a non-existent push config should return Ok, got: {result:?}"
1017 );
1018 }
1019
1020 #[tokio::test]
1023 async fn list_push_configs_error_path_records_metrics() {
1024 use crate::call_context::CallContext;
1027 use crate::interceptor::ServerInterceptor;
1028 use std::future::Future;
1029 use std::pin::Pin;
1030
1031 struct FailInterceptor;
1032 impl ServerInterceptor for FailInterceptor {
1033 fn before<'a>(
1034 &'a self,
1035 _ctx: &'a CallContext,
1036 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1037 {
1038 Box::pin(async {
1039 Err(a2a_protocol_types::error::A2aError::internal(
1040 "forced failure",
1041 ))
1042 })
1043 }
1044 fn after<'a>(
1045 &'a self,
1046 _ctx: &'a CallContext,
1047 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1048 {
1049 Box::pin(async { Ok(()) })
1050 }
1051 }
1052
1053 let handler = RequestHandlerBuilder::new(DummyExecutor)
1054 .with_interceptor(FailInterceptor)
1055 .build()
1056 .unwrap();
1057
1058 let result = handler.on_list_push_configs("task-1", None, None).await;
1059 assert!(
1060 result.is_err(),
1061 "list_push_configs should fail when interceptor rejects"
1062 );
1063 }
1064
1065 #[tokio::test]
1066 async fn delete_push_config_error_path_records_metrics() {
1067 use crate::call_context::CallContext;
1070 use crate::interceptor::ServerInterceptor;
1071 use a2a_protocol_types::params::DeletePushConfigParams;
1072 use std::future::Future;
1073 use std::pin::Pin;
1074
1075 struct FailInterceptor;
1076 impl ServerInterceptor for FailInterceptor {
1077 fn before<'a>(
1078 &'a self,
1079 _ctx: &'a CallContext,
1080 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1081 {
1082 Box::pin(async {
1083 Err(a2a_protocol_types::error::A2aError::internal(
1084 "forced failure",
1085 ))
1086 })
1087 }
1088 fn after<'a>(
1089 &'a self,
1090 _ctx: &'a CallContext,
1091 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1092 {
1093 Box::pin(async { Ok(()) })
1094 }
1095 }
1096
1097 let handler = RequestHandlerBuilder::new(DummyExecutor)
1098 .with_interceptor(FailInterceptor)
1099 .build()
1100 .unwrap();
1101
1102 let params = DeletePushConfigParams {
1103 tenant: None,
1104 task_id: "task-1".to_owned(),
1105 id: "cfg-1".to_owned(),
1106 };
1107 let result = handler.on_delete_push_config(params, None).await;
1108 assert!(
1109 result.is_err(),
1110 "delete_push_config should fail when interceptor rejects"
1111 );
1112 }
1113
1114 #[tokio::test]
1115 async fn set_push_config_error_path_records_metrics() {
1116 let handler = make_handler();
1119 let config = make_push_config("task-err");
1120 let result = handler.on_set_push_config(config, None).await;
1121 assert!(
1122 result.is_err(),
1123 "set_push_config without push sender should hit error metrics path"
1124 );
1125 }
1126
1127 #[tokio::test]
1128 async fn get_push_config_error_path_records_metrics() {
1129 use a2a_protocol_types::params::GetPushConfigParams;
1132
1133 let handler = make_handler();
1134 let params = GetPushConfigParams {
1135 tenant: None,
1136 task_id: "missing-task".to_owned(),
1137 id: "missing-id".to_owned(),
1138 };
1139 let result = handler.on_get_push_config(params, None).await;
1140 assert!(
1141 result.is_err(),
1142 "get_push_config for missing config should hit error metrics path"
1143 );
1144 }
1145}