1use serde_json::{json, Value};
51
52use crate::error::Result;
53use crate::mcp::{escape_json, escape_velocity};
54use crate::MockServerClient;
55
56fn ev_ej(value: &str) -> String {
65 escape_velocity(&escape_json(value))
66}
67
68fn velocity_json_rpc_response(result_json: &str) -> String {
72 format!(
73 "{{\"statusCode\": 200, \
74\"headers\": [{{\"name\": \"Content-Type\", \"values\": [\"application/json\"]}}], \
75\"body\": {{\"jsonrpc\": \"2.0\", \"result\": {result_json}, \"id\": $!{{request.jsonRpcRawId}}}}}}"
76 )
77}
78
79fn task_result_json(escaped_text: &str, is_error: bool) -> String {
82 let state = if is_error { "failed" } else { "completed" };
83 format!(
84 "{{\"id\": \"mock-task-id\", \
85\"status\": {{\"state\": \"{state}\"}}, \
86\"artifacts\": [{{\"parts\": [{{\"type\": \"text\", \"text\": \"{escaped_text}\"}}]}}]}}"
87 )
88}
89
90fn build_task_result_json(response_text: &str, is_error: bool) -> String {
94 task_result_json(&ev_ej(response_text), is_error)
95}
96
97fn build_task_result_json_raw(response_text: &str, is_error: bool) -> String {
102 task_result_json(&escape_json(response_text), is_error)
103}
104
105fn json_rpc_request(path: &str, method: &str) -> Value {
110 json!({
111 "method": "POST",
112 "path": path,
113 "body": { "type": "JSON_RPC", "method": method }
114 })
115}
116
117fn json_path_request(path: &str, json_path: &str) -> Value {
118 json!({
119 "method": "POST",
120 "path": path,
121 "body": { "type": "JSON_PATH", "jsonPath": json_path }
122 })
123}
124
125fn velocity_template_expectation(http_request: Value, result_json: &str) -> Value {
126 json!({
127 "httpRequest": http_request,
128 "httpResponseTemplate": {
129 "template": velocity_json_rpc_response(result_json),
130 "templateType": "VELOCITY"
131 }
132 })
133}
134
135#[derive(Debug, Clone)]
140struct SkillDef {
141 id: String,
142 name: Option<String>,
143 description: Option<String>,
144 tags: Vec<String>,
145 examples: Vec<String>,
146}
147
148#[derive(Debug, Clone)]
149struct TaskHandler {
150 message_pattern: String,
151 response_text: String,
152 is_error: bool,
153}
154
155#[derive(Debug, Clone)]
158struct WebhookTarget {
159 host: String,
160 port: u16,
161 secure: bool,
162 path: String,
163}
164
165impl WebhookTarget {
166 fn host_header(&self) -> String {
167 format!("{}:{}", self.host, self.port)
168 }
169
170 fn parse(url: &str) -> Result<Self> {
176 let (scheme, rest) = url
180 .split_once("://")
181 .ok_or_else(|| crate::Error::InvalidRequest(format!(
182 "Invalid push-notification webhook URL (no scheme): {url}"
183 )))?;
184 let secure = scheme.eq_ignore_ascii_case("https");
185
186 let authority_end = rest
188 .find(['/', '?', '#'])
189 .unwrap_or(rest.len());
190 let authority = &rest[..authority_end];
191 let path_and_rest = &rest[authority_end..];
192
193 let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
195
196 let (host, port) = match host_port.rsplit_once(':') {
197 Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => {
201 let port = p.parse::<u16>().map_err(|_| {
202 crate::Error::InvalidRequest(format!(
203 "Invalid push-notification webhook URL (bad port): {url}"
204 ))
205 })?;
206 (h.to_string(), port)
207 }
208 _ => (
209 host_port.to_string(),
210 if secure { 443 } else { 80 },
211 ),
212 };
213
214 if host.is_empty() {
215 return Err(crate::Error::InvalidRequest(format!(
216 "Invalid push-notification webhook URL (no host): {url}"
217 )));
218 }
219
220 let path = path_and_rest
222 .split(['?', '#'])
223 .next()
224 .unwrap_or("");
225 let path = if path.is_empty() { "/".to_string() } else { path.to_string() };
226
227 Ok(Self { host, port, secure, path })
228 }
229}
230
231#[derive(Debug, Clone)]
237pub struct A2aMockBuilder {
238 path: String,
239 agent_card_path: String,
240 agent_name: String,
241 agent_description: String,
242 agent_version: String,
243 agent_url: Option<String>,
244 skills: Vec<SkillDef>,
245 task_handlers: Vec<TaskHandler>,
246 default_task_response: String,
247 streaming: bool,
248 streaming_method: String,
249 push_notification_url: Option<String>,
250}
251
252impl A2aMockBuilder {
253 fn new(path: impl Into<String>) -> Self {
254 Self {
255 path: path.into(),
256 agent_card_path: "/.well-known/agent.json".to_string(),
257 agent_name: "MockAgent".to_string(),
258 agent_description: "A mock A2A agent".to_string(),
259 agent_version: "1.0.0".to_string(),
260 agent_url: None,
261 skills: Vec::new(),
262 task_handlers: Vec::new(),
263 default_task_response: "Task completed successfully".to_string(),
264 streaming: false,
265 streaming_method: "message/stream".to_string(),
266 push_notification_url: None,
267 }
268 }
269
270 pub fn with_agent_name(mut self, name: impl Into<String>) -> Self {
272 self.agent_name = name.into();
273 self
274 }
275
276 pub fn with_agent_description(mut self, description: impl Into<String>) -> Self {
278 self.agent_description = description.into();
279 self
280 }
281
282 pub fn with_agent_version(mut self, version: impl Into<String>) -> Self {
284 self.agent_version = version.into();
285 self
286 }
287
288 pub fn with_agent_url(mut self, url: impl Into<String>) -> Self {
290 self.agent_url = Some(url.into());
291 self
292 }
293
294 pub fn with_agent_card_path(mut self, path: impl Into<String>) -> Self {
296 self.agent_card_path = path.into();
297 self
298 }
299
300 pub fn with_default_task_response(mut self, response: impl Into<String>) -> Self {
302 self.default_task_response = response.into();
303 self
304 }
305
306 pub fn with_streaming(mut self) -> Self {
310 self.streaming = true;
311 self
312 }
313
314 pub fn with_streaming_method(mut self, method: impl Into<String>) -> Self {
318 self.streaming_method = method.into();
319 self.streaming = true;
320 self
321 }
322
323 pub fn with_push_notifications(mut self, webhook_url: impl Into<String>) -> Self {
329 self.push_notification_url = Some(webhook_url.into());
330 self
331 }
332
333 pub fn with_skill(self, id: impl Into<String>) -> A2aSkillBuilder {
335 A2aSkillBuilder {
336 parent: self,
337 skill: SkillDef {
338 id: id.into(),
339 name: None,
340 description: None,
341 tags: Vec::new(),
342 examples: Vec::new(),
343 },
344 }
345 }
346
347 pub fn on_task_send(self) -> A2aTaskHandlerBuilder {
350 A2aTaskHandlerBuilder {
351 parent: self,
352 message_pattern: ".*".to_string(),
353 response_text: "Task completed".to_string(),
354 is_error: false,
355 }
356 }
357
358 pub fn build(&self) -> Vec<Value> {
365 self.try_build().expect("invalid A2A mock configuration")
366 }
367
368 pub fn try_build(&self) -> Result<Vec<Value>> {
371 let mut expectations = vec![self.build_agent_card()];
372
373 for handler in &self.task_handlers {
374 expectations.push(self.build_custom_task_handler(handler));
375 }
376
377 if self.streaming {
378 expectations.push(self.build_streaming());
379 }
380
381 if let Some(url) = &self.push_notification_url {
382 expectations.push(self.build_push_notification_config(url));
383 expectations.push(self.build_push_notification_delivery(url)?);
384 } else {
385 expectations.push(self.build_tasks_send());
386 }
387 expectations.push(self.build_tasks_get());
388 expectations.push(self.build_tasks_cancel());
389
390 Ok(expectations)
391 }
392
393 pub fn apply_to(&self, client: &MockServerClient) -> Result<Value> {
395 client.upsert_raw(Value::Array(self.try_build()?))
396 }
397
398 fn build_agent_card(&self) -> Value {
401 let mut skills_json = String::from("[");
402 for (i, skill) in self.skills.iter().enumerate() {
403 if i > 0 {
404 skills_json.push_str(", ");
405 }
406 skills_json.push('{');
407 skills_json.push_str(&format!("\"id\": \"{}\"", escape_json(&skill.id)));
408 let name = skill.name.as_deref().unwrap_or(&skill.id);
409 skills_json.push_str(&format!(", \"name\": \"{}\"", escape_json(name)));
410 if let Some(description) = &skill.description {
411 skills_json.push_str(&format!(", \"description\": \"{}\"", escape_json(description)));
412 }
413 if !skill.tags.is_empty() {
414 skills_json.push_str(", \"tags\": [");
415 for (j, tag) in skill.tags.iter().enumerate() {
416 if j > 0 {
417 skills_json.push_str(", ");
418 }
419 skills_json.push_str(&format!("\"{}\"", escape_json(tag)));
420 }
421 skills_json.push(']');
422 }
423 if !skill.examples.is_empty() {
424 skills_json.push_str(", \"examples\": [");
425 for (j, example) in skill.examples.iter().enumerate() {
426 if j > 0 {
427 skills_json.push_str(", ");
428 }
429 skills_json.push_str(&format!("\"{}\"", escape_json(example)));
430 }
431 skills_json.push(']');
432 }
433 skills_json.push('}');
434 }
435 skills_json.push(']');
436
437 let default_url = format!("http://localhost{}", self.path);
438 let url = self.agent_url.as_deref().unwrap_or(&default_url);
439
440 let agent_card_json = format!(
441 "{{\"name\": \"{}\", \"description\": \"{}\", \"version\": \"{}\", \"url\": \"{}\", \
442\"capabilities\": {{\"streaming\": {}, \"pushNotifications\": {}, \"stateTransitionHistory\": false}}, \
443\"skills\": {}}}",
444 escape_json(&self.agent_name),
445 escape_json(&self.agent_description),
446 escape_json(&self.agent_version),
447 escape_json(url),
448 self.streaming,
449 self.push_notification_url.is_some(),
450 skills_json,
451 );
452
453 json!({
454 "httpRequest": { "method": "GET", "path": self.agent_card_path },
455 "httpResponse": {
456 "statusCode": 200,
457 "headers": [{ "name": "Content-Type", "values": ["application/json"] }],
458 "body": agent_card_json
459 }
460 })
461 }
462
463 fn build_tasks_send(&self) -> Value {
464 let result_json = build_task_result_json(&self.default_task_response, false);
465 velocity_template_expectation(json_rpc_request(&self.path, "tasks/send"), &result_json)
466 }
467
468 fn build_tasks_get(&self) -> Value {
469 let result_json = build_task_result_json(&self.default_task_response, false);
470 velocity_template_expectation(json_rpc_request(&self.path, "tasks/get"), &result_json)
471 }
472
473 fn build_tasks_cancel(&self) -> Value {
474 let result_json = "{\"id\": \"mock-task-id\", \"status\": {\"state\": \"canceled\"}}";
475 velocity_template_expectation(json_rpc_request(&self.path, "tasks/cancel"), result_json)
476 }
477
478 fn build_streaming(&self) -> Value {
479 let text = escape_json(&self.default_task_response);
480 let task_id = "mock-task-id";
481
482 let status_working = format!(
487 "{{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": \
488{{\"taskId\": \"{task_id}\", \"kind\": \"status-update\", \
489\"status\": {{\"state\": \"working\"}}, \"final\": false}}}}"
490 );
491 let artifact_update = format!(
492 "{{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": \
493{{\"taskId\": \"{task_id}\", \"kind\": \"artifact-update\", \
494\"artifact\": {{\"parts\": [{{\"type\": \"text\", \"text\": \"{text}\"}}]}}}}}}"
495 );
496 let status_completed = format!(
497 "{{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": \
498{{\"taskId\": \"{task_id}\", \"kind\": \"status-update\", \
499\"status\": {{\"state\": \"completed\"}}, \"final\": true}}}}"
500 );
501
502 json!({
503 "httpRequest": json_rpc_request(&self.path, &self.streaming_method),
504 "httpSseResponse": {
505 "statusCode": 200,
506 "events": [
507 { "event": "message", "data": status_working },
508 { "event": "message", "data": artifact_update },
509 { "event": "message", "data": status_completed }
510 ],
511 "closeConnection": true
512 }
513 })
514 }
515
516 fn build_push_notification_config(&self, url: &str) -> Value {
517 let result_json = format!("{{\"url\": \"{}\"}}", ev_ej(url));
520 velocity_template_expectation(
521 json_rpc_request(&self.path, "tasks/pushNotificationConfig/set"),
522 &result_json,
523 )
524 }
525
526 fn build_push_notification_delivery(&self, url: &str) -> Result<Value> {
527 let target = WebhookTarget::parse(url)?;
534
535 let push_body = format!(
539 "{{\"jsonrpc\": \"2.0\", \"result\": {}}}",
540 build_task_result_json_raw(&self.default_task_response, false)
541 );
542
543 let webhook_request = json!({
544 "method": "POST",
545 "path": target.path,
546 "secure": target.secure,
547 "socketAddress": {
548 "host": target.host,
549 "port": target.port,
550 "scheme": if target.secure { "HTTPS" } else { "HTTP" }
551 },
552 "headers": {
553 "Host": [target.host_header()],
554 "Content-Type": ["application/json"]
555 },
556 "body": push_body
557 });
558
559 let client_response_template = velocity_json_rpc_response(&build_task_result_json(
562 &self.default_task_response,
563 false,
564 ));
565
566 Ok(json!({
567 "httpRequest": json_rpc_request(&self.path, "tasks/send"),
568 "httpOverrideForwardedRequest": {
569 "requestOverride": webhook_request,
570 "responseTemplate": {
571 "template": client_response_template,
572 "templateType": "VELOCITY"
573 }
574 }
575 }))
576 }
577
578 fn build_custom_task_handler(&self, handler: &TaskHandler) -> Value {
579 let escaped_pattern = escape_message_pattern(&handler.message_pattern);
580 let json_path = format!(
581 "$[?(@.method == 'tasks/send' && @.params.message.parts[0].text =~ /{escaped_pattern}/)]"
582 );
583 let result_json = build_task_result_json(&handler.response_text, handler.is_error);
584 velocity_template_expectation(json_path_request(&self.path, &json_path), &result_json)
585 }
586}
587
588fn escape_message_pattern(pattern: &str) -> String {
603 let chars: Vec<char> = pattern.chars().collect();
604 let mut out = String::with_capacity(pattern.len());
605 let mut i = 0;
606 while i < chars.len() {
607 match chars[i] {
608 '\\' => {
609 if i + 1 < chars.len() {
610 out.push('\\');
611 out.push(chars[i + 1]);
612 i += 1;
613 } else {
614 out.push_str("\\\\");
615 }
616 }
617 '/' => out.push_str("\\/"),
618 '\n' => out.push_str("\\n"),
619 '\r' => out.push_str("\\r"),
620 '\0' => {} c => out.push(c),
622 }
623 i += 1;
624 }
625 out
626}
627
628pub struct A2aSkillBuilder {
635 parent: A2aMockBuilder,
636 skill: SkillDef,
637}
638
639impl A2aSkillBuilder {
640 pub fn with_name(mut self, name: impl Into<String>) -> Self {
642 self.skill.name = Some(name.into());
643 self
644 }
645
646 pub fn with_description(mut self, description: impl Into<String>) -> Self {
648 self.skill.description = Some(description.into());
649 self
650 }
651
652 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
654 self.skill.tags.push(tag.into());
655 self
656 }
657
658 pub fn with_example(mut self, example: impl Into<String>) -> Self {
660 self.skill.examples.push(example.into());
661 self
662 }
663
664 pub fn and(mut self) -> A2aMockBuilder {
666 self.parent.skills.push(self.skill);
667 self.parent
668 }
669}
670
671pub struct A2aTaskHandlerBuilder {
674 parent: A2aMockBuilder,
675 message_pattern: String,
676 response_text: String,
677 is_error: bool,
678}
679
680impl A2aTaskHandlerBuilder {
681 pub fn matching_message(mut self, pattern: impl Into<String>) -> Self {
683 self.message_pattern = pattern.into();
684 self
685 }
686
687 pub fn responding_with(mut self, text: impl Into<String>, is_error: bool) -> Self {
689 self.response_text = text.into();
690 self.is_error = is_error;
691 self
692 }
693
694 pub fn and(mut self) -> A2aMockBuilder {
696 self.parent.task_handlers.push(TaskHandler {
697 message_pattern: self.message_pattern,
698 response_text: self.response_text,
699 is_error: self.is_error,
700 });
701 self.parent
702 }
703}
704
705pub fn a2a_mock(path: impl Into<String>) -> A2aMockBuilder {
708 A2aMockBuilder::new(path)
709}
710
711pub fn a2a_mock_default() -> A2aMockBuilder {
713 A2aMockBuilder::new("/a2a")
714}
715
716#[cfg(test)]
721mod tests {
722 use super::*;
723
724 fn agent_card_body(expectations: &[Value]) -> String {
725 expectations[0]["httpResponse"]["body"]
726 .as_str()
727 .expect("agent card body string")
728 .to_string()
729 }
730
731 #[test]
732 fn minimal_mock_has_four_expectations() {
733 let expectations = a2a_mock_default().build();
735 assert_eq!(expectations.len(), 4);
736 }
737
738 #[test]
739 fn custom_path_is_used_on_task_endpoint() {
740 let expectations = a2a_mock("/custom/a2a").build();
741 assert_eq!(expectations.len(), 4);
742 assert_eq!(expectations[1]["httpRequest"]["path"], "/custom/a2a");
744 assert_eq!(
745 expectations[1]["httpRequest"]["body"]["method"],
746 "tasks/send"
747 );
748 }
749
750 #[test]
751 fn agent_card_is_static_response() {
752 let expectations = a2a_mock_default()
753 .with_agent_name("TestAgent")
754 .with_agent_version("2.0.0")
755 .build();
756 let card = &expectations[0];
757 assert_eq!(card["httpRequest"]["method"], "GET");
758 assert_eq!(card["httpRequest"]["path"], "/.well-known/agent.json");
759 assert_eq!(card["httpResponse"]["statusCode"], 200);
760 let body = agent_card_body(&expectations);
761 assert!(body.contains("TestAgent"), "{body}");
762 assert!(body.contains("2.0.0"), "{body}");
763 }
764
765 #[test]
766 fn custom_agent_card_path() {
767 let expectations = a2a_mock_default().with_agent_card_path("/agent-card").build();
768 assert_eq!(expectations[0]["httpRequest"]["path"], "/agent-card");
769 }
770
771 #[test]
772 fn skills_appear_in_agent_card() {
773 let expectations = a2a_mock_default()
774 .with_skill("skill1")
775 .with_name("Skill One")
776 .with_description("First skill")
777 .with_tag("test")
778 .with_example("Do it")
779 .and()
780 .build();
781 assert_eq!(expectations.len(), 4);
783 let body = agent_card_body(&expectations);
784 assert!(body.contains("skill1"), "{body}");
785 assert!(body.contains("Skill One"), "{body}");
786 assert!(body.contains("First skill"), "{body}");
787 assert!(body.contains("\"tags\": [\"test\"]"), "{body}");
788 assert!(body.contains("\"examples\": [\"Do it\"]"), "{body}");
789 }
790
791 #[test]
792 fn skill_name_defaults_to_id() {
793 let expectations = a2a_mock_default().with_skill("only_id").and().build();
794 let body = agent_card_body(&expectations);
795 assert!(body.contains("\"id\": \"only_id\", \"name\": \"only_id\""), "{body}");
796 }
797
798 #[test]
799 fn task_handlers_add_two_expectations_each_before_defaults() {
800 let expectations = a2a_mock_default()
801 .on_task_send()
802 .matching_message("translate.*")
803 .responding_with("Translation: Hola", false)
804 .and()
805 .on_task_send()
806 .matching_message("summarize.*")
807 .responding_with("Summary: Brief text", false)
808 .and()
809 .build();
810 assert_eq!(expectations.len(), 6);
812 assert!(expectations[1]["httpRequest"]["body"]["jsonPath"]
814 .as_str()
815 .unwrap()
816 .contains("translate.*"));
817 assert!(expectations[1]["httpResponseTemplate"]["template"]
818 .as_str()
819 .unwrap()
820 .contains("Translation: Hola"));
821 }
822
823 #[test]
824 fn task_responses_use_velocity_templates() {
825 let expectations = a2a_mock_default().build();
826 let tasks_send = &expectations[1];
827 assert_eq!(
828 tasks_send["httpResponseTemplate"]["templateType"],
829 "VELOCITY"
830 );
831 assert!(tasks_send["httpResponseTemplate"]["template"]
832 .as_str()
833 .unwrap()
834 .contains("$!{request.jsonRpcRawId}"));
835 }
836
837 #[test]
838 fn error_task_handler_uses_failed_state() {
839 let expectations = a2a_mock_default()
840 .on_task_send()
841 .matching_message("bad_request.*")
842 .responding_with("Error occurred", true)
843 .and()
844 .build();
845 let template = expectations[1]["httpResponseTemplate"]["template"]
846 .as_str()
847 .unwrap();
848 assert!(template.contains("failed"), "{template}");
849 }
850
851 #[test]
852 fn default_task_response_appears_in_template() {
853 let expectations = a2a_mock_default()
854 .with_default_task_response("Custom default response")
855 .build();
856 let template = expectations[1]["httpResponseTemplate"]["template"]
857 .as_str()
858 .unwrap();
859 assert!(template.contains("Custom default response"), "{template}");
860 }
861
862 #[test]
863 fn velocity_metacharacters_escaped_in_default_response() {
864 let expectations = a2a_mock_default()
865 .with_default_task_response("$100 off #sale")
866 .build();
867 let template = expectations[1]["httpResponseTemplate"]["template"]
868 .as_str()
869 .unwrap();
870 assert!(template.contains("${esc.d}100 off ${esc.h}sale"), "{template}");
871 }
872
873 #[test]
874 fn velocity_metacharacters_escaped_in_custom_handler() {
875 let expectations = a2a_mock_default()
876 .on_task_send()
877 .matching_message("test.*")
878 .responding_with("Price is $50 #discount", false)
879 .and()
880 .build();
881 let template = expectations[1]["httpResponseTemplate"]["template"]
882 .as_str()
883 .unwrap();
884 assert!(template.contains("${esc.d}50 ${esc.h}discount"), "{template}");
885 }
886
887 #[test]
888 fn slash_escaped_in_handler_message_pattern() {
889 let expectations = a2a_mock_default()
890 .on_task_send()
891 .matching_message("path/to/resource")
892 .responding_with("found", false)
893 .and()
894 .build();
895 let json_path = expectations[1]["httpRequest"]["body"]["jsonPath"]
896 .as_str()
897 .unwrap();
898 assert!(json_path.contains("path\\/to\\/resource"), "{json_path}");
899 }
900
901 #[test]
902 fn backslash_newline_and_cr_escaped_in_pattern() {
903 let expectations = a2a_mock_default()
904 .on_task_send()
905 .matching_message("line1\nline2\\d+")
906 .responding_with("found", false)
907 .and()
908 .build();
909 let json_path = expectations[1]["httpRequest"]["body"]["jsonPath"]
910 .as_str()
911 .unwrap();
912 assert!(json_path.contains("line1\\nline2\\d+"), "{json_path}");
913 assert!(!json_path.contains('\n'), "must not contain literal newline");
914 }
915
916 #[test]
917 fn null_byte_stripped_in_pattern() {
918 let expectations = a2a_mock_default()
919 .on_task_send()
920 .matching_message("before\0after")
921 .responding_with("found", false)
922 .and()
923 .build();
924 let json_path = expectations[1]["httpRequest"]["body"]["jsonPath"]
925 .as_str()
926 .unwrap();
927 assert!(json_path.contains("beforeafter"), "{json_path}");
928 assert!(!json_path.contains('\0'));
929 }
930
931 fn handler_json_path(message_pattern: &str) -> String {
932 let expectations = a2a_mock_default()
933 .on_task_send()
934 .matching_message(message_pattern)
935 .responding_with("found", false)
936 .and()
937 .build();
938 expectations[1]["httpRequest"]["body"]["jsonPath"]
939 .as_str()
940 .unwrap()
941 .to_string()
942 }
943
944 #[test]
945 fn regex_escape_sequence_is_preserved_not_doubled() {
946 assert_eq!(escape_message_pattern(r"\d+"), r"\d+");
949 let json_path = handler_json_path(r"\d+");
950 assert!(json_path.contains(r"\d+"), "{json_path}");
951 assert!(!json_path.contains(r"\\d+"), "{json_path}");
952 }
953
954 #[test]
955 fn already_escaped_slash_is_not_double_escaped() {
956 assert_eq!(escape_message_pattern(r"a\/b"), r"a\/b");
958 let json_path = handler_json_path(r"a\/b");
959 assert!(json_path.contains(r"a\/b"), "{json_path}");
960 assert!(!json_path.contains(r"a\\/b"), "{json_path}");
961 }
962
963 #[test]
964 fn trailing_backslash_cannot_break_out_of_regex_delimiter() {
965 assert_eq!(escape_message_pattern(r"abc\"), r"abc\\");
969 let json_path = handler_json_path(r"abc\");
970 assert!(json_path.contains(r"abc\\/)]"), "{json_path}");
971 assert!(json_path.ends_with(r"abc\\/)]"), "{json_path}");
974 }
975
976 #[test]
977 fn normal_slash_pattern_still_escaped_as_before() {
978 assert_eq!(
980 escape_message_pattern("path/to/resource"),
981 "path\\/to\\/resource"
982 );
983 let json_path = handler_json_path("path/to/resource");
984 assert!(json_path.contains("path\\/to\\/resource"), "{json_path}");
985 }
986
987 #[test]
988 fn streaming_advertised_and_generates_sse() {
989 let expectations = a2a_mock_default()
990 .with_streaming()
991 .with_default_task_response("streamed result")
992 .build();
993 assert_eq!(expectations.len(), 5);
995
996 let card = agent_card_body(&expectations);
997 assert!(card.contains("\"streaming\": true"), "{card}");
998 assert!(card.contains("\"pushNotifications\": false"), "{card}");
999
1000 let streaming = expectations
1001 .iter()
1002 .find(|e| e.get("httpSseResponse").is_some())
1003 .expect("expected an SSE expectation");
1004 let events = streaming["httpSseResponse"]["events"].as_array().unwrap();
1005 assert_eq!(events.len(), 3);
1006 assert_eq!(streaming["httpSseResponse"]["closeConnection"], true);
1007
1008 let all_data: String = events
1009 .iter()
1010 .map(|e| e["data"].as_str().unwrap())
1011 .collect();
1012 assert!(all_data.contains("status-update"));
1013 assert!(all_data.contains("artifact-update"));
1014 assert!(all_data.contains("\"state\": \"working\""));
1015 assert!(all_data.contains("\"state\": \"completed\""));
1016 assert!(all_data.contains("\"final\": true"));
1017 assert!(all_data.contains("streamed result"));
1018
1019 assert_eq!(
1021 streaming["httpRequest"]["body"]["method"],
1022 "message/stream"
1023 );
1024 }
1025
1026 #[test]
1027 fn custom_streaming_method() {
1028 let expectations = a2a_mock_default()
1029 .with_streaming_method("tasks/sendSubscribe")
1030 .build();
1031 let streaming = expectations
1032 .iter()
1033 .find(|e| e.get("httpSseResponse").is_some())
1034 .expect("expected an SSE expectation");
1035 assert_eq!(
1036 streaming["httpRequest"]["body"]["method"],
1037 "tasks/sendSubscribe"
1038 );
1039 }
1040
1041 #[test]
1042 fn push_notifications_generate_config_and_delivery() {
1043 let expectations = a2a_mock_default()
1044 .with_push_notifications("http://localhost:1234/callback")
1045 .build();
1046 assert_eq!(expectations.len(), 5);
1048
1049 let card = agent_card_body(&expectations);
1050 assert!(card.contains("\"pushNotifications\": true"), "{card}");
1051
1052 let mut has_config_echo = false;
1053 let mut has_forward_delivery = false;
1054 for expectation in &expectations {
1055 let method = expectation["httpRequest"]["body"]["method"]
1056 .as_str()
1057 .unwrap_or("");
1058 if method == "tasks/pushNotificationConfig/set" {
1059 has_config_echo = true;
1060 }
1061 if method == "tasks/send" && expectation.get("httpOverrideForwardedRequest").is_some() {
1062 has_forward_delivery = true;
1063 let webhook = &expectation["httpOverrideForwardedRequest"]["requestOverride"];
1064 assert_eq!(webhook["method"], "POST");
1065 assert_eq!(webhook["path"], "/callback");
1066 assert_eq!(webhook["socketAddress"]["host"], "localhost");
1067 assert_eq!(webhook["socketAddress"]["port"], 1234);
1068 assert_eq!(webhook["socketAddress"]["scheme"], "HTTP");
1069 assert_eq!(webhook["secure"], false);
1070
1071 let response_template =
1072 &expectation["httpOverrideForwardedRequest"]["responseTemplate"];
1073 assert_eq!(response_template["templateType"], "VELOCITY");
1074 assert!(response_template["template"]
1075 .as_str()
1076 .unwrap()
1077 .contains("$!{request.jsonRpcRawId}"));
1078 }
1079 }
1080 assert!(has_config_echo, "expected push-notification config echo");
1081 assert!(has_forward_delivery, "expected push-notification delivery");
1082 }
1083
1084 #[test]
1085 fn literal_webhook_push_body_not_velocity_escaped() {
1086 let expectations = a2a_mock_default()
1087 .with_push_notifications("http://localhost:1234/callback")
1088 .with_default_task_response("$100 off #sale")
1089 .build();
1090 let webhook = expectations
1091 .iter()
1092 .find_map(|e| e.get("httpOverrideForwardedRequest"))
1093 .map(|o| &o["requestOverride"])
1094 .expect("expected a webhook request override");
1095 let push_body = webhook["body"].as_str().unwrap();
1096 assert!(push_body.contains("$100 off #sale"), "{push_body}");
1097 assert!(!push_body.contains("esc.d"), "{push_body}");
1098 assert!(!push_body.contains("esc.h"), "{push_body}");
1099 }
1100
1101 #[test]
1102 fn https_webhook_default_port() {
1103 let expectations = a2a_mock_default()
1104 .with_push_notifications("https://example.com/a2a/push")
1105 .build();
1106 let webhook = expectations
1107 .iter()
1108 .find_map(|e| e.get("httpOverrideForwardedRequest"))
1109 .map(|o| &o["requestOverride"])
1110 .expect("expected a webhook request override");
1111 assert_eq!(webhook["socketAddress"]["host"], "example.com");
1112 assert_eq!(webhook["socketAddress"]["port"], 443);
1113 assert_eq!(webhook["socketAddress"]["scheme"], "HTTPS");
1114 assert_eq!(webhook["secure"], true);
1115 assert_eq!(webhook["path"], "/a2a/push");
1116 }
1117
1118 #[test]
1119 fn full_mock_layout() {
1120 let expectations = a2a_mock("/agent")
1121 .with_agent_name("FullAgent")
1122 .with_agent_description("A complete mock agent")
1123 .with_agent_version("3.0.0")
1124 .with_agent_url("http://localhost:8080/agent")
1125 .with_skill("translate")
1126 .with_name("Translation")
1127 .with_description("Translates text")
1128 .with_tag("i18n")
1129 .with_example("Translate hello to French")
1130 .and()
1131 .with_default_task_response("Default done")
1132 .on_task_send()
1133 .matching_message("translate.*")
1134 .responding_with("Bonjour", false)
1135 .and()
1136 .build();
1137 assert_eq!(expectations.len(), 5);
1139 let card = agent_card_body(&expectations);
1140 assert!(card.contains("FullAgent"));
1141 assert!(card.contains("http://localhost:8080/agent"));
1142 assert!(card.contains("translate"));
1143 }
1144
1145 #[test]
1146 fn agent_card_body_is_valid_json() {
1147 let expectations = a2a_mock_default()
1148 .with_agent_name("Quote\"Agent")
1149 .with_skill("s1")
1150 .with_name("Name/with\\chars")
1151 .and()
1152 .build();
1153 let body = agent_card_body(&expectations);
1154 let parsed: Value = serde_json::from_str(&body).expect("agent card must be valid JSON");
1155 assert_eq!(parsed["name"], "Quote\"Agent");
1156 assert_eq!(parsed["capabilities"]["stateTransitionHistory"], false);
1157 }
1158}