1use std::collections::{BTreeMap, BTreeSet};
11use std::fmt::Write;
12
13use anyhow::Result;
14use serde_json::{Map, Number, Value};
15
16use crate::{
17 Api, GeneratedFile, MockResponse, MockScenario, Operation, OperationMediaType,
18 OperationResponse, SchemaKind, SchemaValue, extract_mock_scenarios,
19};
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct HttpmockFixtureConfig {
24 pub output_dir: String,
26 pub include_readme: bool,
28}
29
30impl Default for HttpmockFixtureConfig {
31 fn default() -> Self {
32 Self {
33 output_dir: "mocks/httpmock".into(),
34 include_readme: true,
35 }
36 }
37}
38
39pub fn generate_httpmock_fixtures(
51 api: &Api,
52 config: &HttpmockFixtureConfig,
53) -> Result<Vec<GeneratedFile>> {
54 let output_dir = config.output_dir.trim_matches('/');
55 let scenarios = extract_mock_scenarios(api)?;
56 let mut files = Vec::with_capacity(api.operations.len() + usize::from(config.include_readme));
57 for operation in &api.operations {
58 let name = fixture_name(&operation.id);
59 let operation_scenarios = scenarios
60 .iter()
61 .filter(|scenario| scenario.operation_id == operation.id)
62 .collect::<Vec<_>>();
63 files.push(GeneratedFile::new(
64 output_path(output_dir, &format!("{name}.yaml")),
65 render_operation_fixture(api, operation, &operation_scenarios),
66 )?);
67 }
68 if config.include_readme {
69 files.push(GeneratedFile::new(
70 output_path(output_dir, "README.md"),
71 render_readme(api),
72 )?);
73 }
74 Ok(files)
75}
76
77fn output_path(output_dir: &str, file: &str) -> String {
78 if output_dir.is_empty() {
79 file.into()
80 } else {
81 format!("{output_dir}/{file}")
82 }
83}
84
85fn render_operation_fixture(
86 api: &Api,
87 operation: &Operation,
88 scenarios: &[&MockScenario],
89) -> String {
90 let mut out = format!(
91 "# Generated by Poolster. Source operation: {}\n",
92 operation.id
93 );
94 for (index, scenario) in scenarios.iter().enumerate() {
98 if index > 0 {
99 out.push_str("---\n");
100 }
101 let _ = writeln!(out, "# Scenario: {}", scenario.name);
102 render_when(&mut out, operation, Some(scenario));
103 out.push_str("then:\n");
104 render_scenario_response(&mut out, &scenario.response);
105 }
106 if !scenarios.is_empty() {
107 out.push_str("---\n");
108 }
109 out.push_str("# Default happy-path response\n");
110 render_when(&mut out, operation, None);
111 out.push_str("then:\n");
112 let response = preferred_response(operation);
113 let status = response.map_or(200, |response| status_code(&response.status));
114 let _ = writeln!(out, " status: {status}");
115 if let Some(response) = response {
116 if let Some(media_type) = response.media_types.first() {
117 render_response_body(api, media_type, &mut out);
118 }
119 }
120 out
121}
122
123fn render_when(out: &mut String, operation: &Operation, scenario: Option<&MockScenario>) {
124 let _ = writeln!(out, "when:");
125 let _ = writeln!(out, " method: {}", operation.method.as_str());
126 let values = scenario.map(|scenario| &scenario.when.path);
127 if has_unbound_path_parameter(&operation.path, values) {
128 let _ = writeln!(
129 out,
130 " path_matches: {}",
131 yaml_scalar(&path_pattern(&operation.path, values)),
132 );
133 } else {
134 let _ = writeln!(
135 out,
136 " path: {}",
137 yaml_scalar(&path_matcher(&operation.path, values)),
138 );
139 }
140 let Some(scenario) = scenario else {
141 return;
142 };
143 render_match_pairs(out, "header", &scenario.when.headers);
144 render_match_pairs(out, "query_param", &scenario.when.query);
145 if let Some(body) = &scenario.when.body {
146 let json = serde_json::to_string(body).expect("scenario request JSON must serialize");
147 let _ = writeln!(out, " json_body: {}", yaml_scalar(&json));
148 }
149}
150
151fn render_match_pairs(out: &mut String, key: &str, pairs: &BTreeMap<String, String>) {
152 if pairs.is_empty() {
153 return;
154 }
155 let _ = writeln!(out, " {key}:");
156 for (name, value) in pairs {
157 let _ = writeln!(out, " - name: {}", yaml_scalar(name));
158 let _ = writeln!(out, " value: {}", yaml_scalar(value));
159 }
160}
161
162fn render_scenario_response(out: &mut String, response: &MockResponse) {
163 let _ = writeln!(out, " status: {}", response.status);
164 render_response_headers(out, &response.headers);
165 if let Some(delay_ms) = response.delay_ms {
166 let _ = writeln!(out, " delay: {delay_ms}ms");
167 }
168 let Some(body) = &response.body else {
169 return;
170 };
171 let json_content = response
172 .headers
173 .iter()
174 .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
175 .is_some_and(|(_, value)| {
176 let value = value.to_ascii_lowercase();
177 value.contains("json") || value.ends_with("+json")
178 });
179 if json_content {
180 let json = serde_json::to_string(body).expect("scenario response JSON must serialize");
181 let _ = writeln!(out, " json_body: {}", yaml_scalar(&json));
182 } else if let Some(text) = body.as_str() {
183 let _ = writeln!(out, " body: {}", yaml_scalar(text));
184 } else {
185 let json = serde_json::to_string(body).expect("scenario response JSON must serialize");
186 let _ = writeln!(out, " body: {}", yaml_scalar(&json));
187 }
188}
189
190fn render_response_headers(out: &mut String, headers: &BTreeMap<String, String>) {
191 if headers.is_empty() {
192 return;
193 }
194 let _ = writeln!(out, " header:");
195 for (name, value) in headers {
196 let _ = writeln!(out, " - name: {}", yaml_scalar(name));
197 let _ = writeln!(out, " value: {}", yaml_scalar(value));
198 }
199}
200
201fn preferred_response(operation: &Operation) -> Option<&OperationResponse> {
202 operation
203 .responses
204 .iter()
205 .filter_map(|response| {
206 response
207 .status
208 .parse::<u16>()
209 .ok()
210 .map(|status| (status, response))
211 })
212 .filter(|(status, _)| (200..300).contains(status))
213 .min_by_key(|(status, _)| *status)
214 .map(|(_, response)| response)
215 .or_else(|| {
216 operation
217 .responses
218 .iter()
219 .find(|response| response.status == "default")
220 })
221 .or_else(|| operation.responses.first())
222}
223
224pub fn mock_happy_response(api: &Api, operation: &Operation) -> (u16, Option<String>, Value) {
228 let Some(response) = preferred_response(operation) else {
229 return (200, Some("application/json".into()), Value::Null);
230 };
231 let status = status_code(&response.status);
232 let Some(media_type) = response.media_types.first() else {
233 return (status, None, Value::Null);
234 };
235 let body = media_type
236 .schema
237 .as_ref()
238 .map(|schema| sample_schema(api, schema, &mut BTreeSet::new()))
239 .unwrap_or(Value::Null);
240 (status, Some(media_type.content_type.clone()), body)
241}
242
243pub fn mock_dynamic_response(
247 api: &Api,
248 operation: &Operation,
249 seed: u64,
250) -> (u16, Option<String>, Value) {
251 let Some(response) = preferred_response(operation) else {
252 return (200, Some("application/json".into()), Value::Null);
253 };
254 let status = status_code(&response.status);
255 let Some(media_type) = response.media_types.first() else {
256 return (status, None, Value::Null);
257 };
258 let body = media_type
259 .schema
260 .as_ref()
261 .map(|schema| dynamic_schema(api, schema, seed, &mut BTreeSet::new()))
262 .unwrap_or(Value::Null);
263 (status, Some(media_type.content_type.clone()), body)
264}
265
266fn status_code(status: &str) -> u16 {
267 status
268 .parse()
269 .ok()
270 .filter(|status| (100..=599).contains(status))
271 .unwrap_or(200)
272}
273
274fn render_response_body(api: &Api, media_type: &OperationMediaType, out: &mut String) {
275 let content_type = media_type.content_type.to_ascii_lowercase();
276 if !content_type.is_empty() {
277 render_response_headers(
278 out,
279 &BTreeMap::from([("content-type".into(), media_type.content_type.clone())]),
280 );
281 }
282 let Some(schema) = &media_type.schema else {
283 return;
284 };
285 let body = sample_schema(api, schema, &mut BTreeSet::new());
286 if content_type.contains("json") || content_type.ends_with("+json") {
287 let json = serde_json::to_string(&body).expect("sample JSON must serialize");
288 let _ = writeln!(out, " json_body: {}", yaml_scalar(&json));
289 } else if let Some(text) = body.as_str() {
290 let _ = writeln!(out, " body: {}", yaml_scalar(text));
291 } else {
292 let json = serde_json::to_string(&body).expect("sample JSON must serialize");
293 let _ = writeln!(out, " body: {}", yaml_scalar(&json));
294 }
295}
296
297fn path_matcher(path: &str, values: Option<&BTreeMap<String, String>>) -> String {
298 path.split('/')
303 .map(|segment| {
304 if segment.starts_with('{') && segment.ends_with('}') {
305 let name = &segment[1..segment.len() - 1];
306 values
307 .and_then(|values| values.get(name))
308 .cloned()
309 .unwrap_or_else(|| "poolster".into())
310 } else {
311 segment.to_owned()
312 }
313 })
314 .collect::<Vec<_>>()
315 .join("/")
316}
317
318fn has_unbound_path_parameter(path: &str, values: Option<&BTreeMap<String, String>>) -> bool {
319 path.split('/').any(|segment| {
320 segment.starts_with('{')
321 && segment.ends_with('}')
322 && values.is_none_or(|values| !values.contains_key(&segment[1..segment.len() - 1]))
323 })
324}
325
326fn path_pattern(path: &str, values: Option<&BTreeMap<String, String>>) -> String {
327 let mut pattern = String::from("^");
328 for segment in path.split_inclusive('/') {
329 let bare = segment.strip_suffix('/').unwrap_or(segment);
332 if bare.starts_with('{') && bare.ends_with('}') {
333 let name = &bare[1..bare.len() - 1];
334 if let Some(value) = values.and_then(|values| values.get(name)) {
335 push_regex_escaped(&mut pattern, value);
336 } else {
337 pattern.push_str("[^/]+");
338 }
339 } else {
340 push_regex_escaped(&mut pattern, bare);
341 }
342 if segment.ends_with('/') {
343 pattern.push('/');
344 }
345 }
346 pattern.push('$');
347 pattern
348}
349
350fn push_regex_escaped(output: &mut String, value: &str) {
351 for character in value.chars() {
352 if matches!(
353 character,
354 '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '^' | '$' | '{' | '}'
355 ) {
356 output.push('\\');
357 }
358 output.push(character);
359 }
360}
361
362fn sample_schema(api: &Api, schema: &SchemaValue, visiting: &mut BTreeSet<String>) -> Value {
363 if let Some(value) = schema
364 .const_value
365 .clone()
366 .or_else(|| schema.default.clone())
367 {
368 return value;
369 }
370 if let Some(value) = schema.enum_values.first() {
371 return value.clone();
372 }
373 if let Some(example) = schema.constraints.get("example") {
374 return example.clone();
375 }
376 if let Some(Value::Array(examples)) = schema.constraints.get("examples") {
377 if let Some(example) = examples.first() {
378 return example.clone();
379 }
380 }
381 match &schema.kind {
382 SchemaKind::Any => Value::Null,
383 SchemaKind::Null => Value::Null,
384 SchemaKind::Boolean => Value::Bool(false),
385 SchemaKind::Integer => Value::Number(Number::from(0)),
386 SchemaKind::Number => Number::from_f64(0.0).map_or(Value::Null, Value::Number),
387 SchemaKind::String => Value::String(sample_string(schema.format.as_deref())),
388 SchemaKind::Array { items } => Value::Array(vec![sample_schema(api, items, visiting)]),
389 SchemaKind::Object { fields, .. } => Value::Object(
390 fields
391 .iter()
392 .map(|field| {
393 (
394 field.name.clone(),
395 sample_schema(api, &field.value, visiting),
396 )
397 })
398 .collect::<Map<_, _>>(),
399 ),
400 SchemaKind::Reference { reference } => {
401 let name = reference.rsplit('/').next().unwrap_or(reference);
402 if !visiting.insert(name.into()) {
403 return Value::Null;
404 }
405 let sampled = api
406 .schemas
407 .iter()
408 .find(|schema| schema.name == name)
409 .map(|schema| sample_schema(api, &schema.value, visiting))
410 .unwrap_or(Value::Null);
411 visiting.remove(name);
412 sampled
413 }
414 SchemaKind::OneOf { variants } | SchemaKind::AnyOf { variants } => variants
415 .first()
416 .map(|variant| sample_schema(api, variant, visiting))
417 .unwrap_or(Value::Null),
418 SchemaKind::AllOf { variants } => {
419 let mut result = Map::new();
420 for variant in variants {
421 if let Value::Object(object) = sample_schema(api, variant, visiting) {
422 result.extend(object);
423 }
424 }
425 Value::Object(result)
426 }
427 SchemaKind::Not { .. } => Value::Null,
428 }
429}
430
431fn dynamic_schema(
432 api: &Api,
433 schema: &SchemaValue,
434 seed: u64,
435 visiting: &mut BTreeSet<String>,
436) -> Value {
437 if let Some(value) = schema
438 .const_value
439 .clone()
440 .or_else(|| schema.default.clone())
441 {
442 return value;
443 }
444 if let Some(value) = schema.enum_values.first() {
445 return value.clone();
446 }
447 if let Some(example) = schema.constraints.get("example") {
448 return example.clone();
449 }
450 if let Some(Value::Array(examples)) = schema.constraints.get("examples") {
451 if let Some(example) = examples.first() {
452 return example.clone();
453 }
454 }
455 match &schema.kind {
456 SchemaKind::Any | SchemaKind::Null | SchemaKind::Not { .. } => Value::Null,
457 SchemaKind::Boolean => Value::Bool(seed % 2 == 0),
458 SchemaKind::Integer => Value::Number(Number::from(seed)),
459 SchemaKind::Number => {
460 Number::from_f64(seed as f64 + 0.5).map_or(Value::Null, Value::Number)
461 }
462 SchemaKind::String => Value::String(dynamic_string(schema.format.as_deref(), seed)),
463 SchemaKind::Array { items } => Value::Array(vec![dynamic_schema(
464 api,
465 items,
466 seed.saturating_add(1),
467 visiting,
468 )]),
469 SchemaKind::Object { fields, .. } => Value::Object(
470 fields
471 .iter()
472 .enumerate()
473 .map(|(index, field)| {
474 (
475 field.name.clone(),
476 dynamic_schema(
477 api,
478 &field.value,
479 seed.saturating_add(index as u64),
480 visiting,
481 ),
482 )
483 })
484 .collect::<Map<_, _>>(),
485 ),
486 SchemaKind::Reference { reference } => {
487 let name = reference.rsplit('/').next().unwrap_or(reference);
488 if !visiting.insert(name.into()) {
489 return Value::Null;
490 }
491 let sampled = api
492 .schemas
493 .iter()
494 .find(|schema| schema.name == name)
495 .map(|schema| dynamic_schema(api, &schema.value, seed, visiting))
496 .unwrap_or(Value::Null);
497 visiting.remove(name);
498 sampled
499 }
500 SchemaKind::OneOf { variants } | SchemaKind::AnyOf { variants } => variants
501 .first()
502 .map(|variant| dynamic_schema(api, variant, seed, visiting))
503 .unwrap_or(Value::Null),
504 SchemaKind::AllOf { variants } => {
505 let mut result = Map::new();
506 for variant in variants {
507 if let Value::Object(object) = dynamic_schema(api, variant, seed, visiting) {
508 result.extend(object);
509 }
510 }
511 Value::Object(result)
512 }
513 }
514}
515
516fn dynamic_string(format: Option<&str>, seed: u64) -> String {
517 match format.unwrap_or_default() {
518 "email" => format!("mock-{seed}@example.test"),
519 "uuid" => format!("00000000-0000-4000-8000-{seed:012x}"),
520 "date" => format!("2026-01-{:02}", seed % 28 + 1),
521 "date-time" | "datetime" => format!("2026-01-{:02}T12:00:00Z", seed % 28 + 1),
522 "uri" | "url" => format!("https://mock.example.test/resources/{seed}"),
523 _ => format!("mock-{seed}"),
524 }
525}
526
527fn sample_string(format: Option<&str>) -> String {
528 match format.unwrap_or_default() {
529 "email" => "mock@example.com".into(),
530 "uuid" => "00000000-0000-4000-8000-000000000000".into(),
531 "date" => "2020-01-01".into(),
532 "date-time" | "datetime" => "2020-01-01T00:00:00Z".into(),
533 "uri" | "url" => "https://example.com/mock".into(),
534 "binary" | "byte" => "mock".into(),
535 _ => "mock".into(),
536 }
537}
538
539fn fixture_name(operation_id: &str) -> String {
540 let mut name = String::with_capacity(operation_id.len());
541 let mut previous_dash = false;
542 for character in operation_id.chars() {
543 if character.is_ascii_alphanumeric() {
544 name.push(character.to_ascii_lowercase());
545 previous_dash = false;
546 } else if !previous_dash {
547 name.push('-');
548 previous_dash = true;
549 }
550 }
551 let name = name.trim_matches('-');
552 if name.is_empty() {
553 "operation".into()
554 } else {
555 name.into()
556 }
557}
558
559fn yaml_scalar(value: &str) -> String {
560 format!("'{}'", value.replace('\'', "''"))
564}
565
566fn render_readme(api: &Api) -> String {
567 format!(
568 "# {} httpmock fixtures\n\nGenerated by Poolster from {} operation(s). Run a standalone httpmock server with:\n\n```sh\ndocker run --rm -p 5000:5000 -e HTTPMOCK_MOCK_FILES_DIR=/mocks -v \"$PWD:/mocks:ro\" httpmock/httpmock\n```\n\nThen configure any generated SDK with `baseUrl: \"http://localhost:5000\"`. Each YAML document is an independent static mock. Its happy-path response is derived from the declared OpenAPI response schema.\n\nTo add named errors or other cases, use `x-poolster-mock` on the operation; Poolster emits them as additional YAML documents in the operation fixture. A scenario can match exact headers, query values, path parameters, and a JSON body.\n",
569 api.name,
570 api.operations.len()
571 )
572}
573
574#[cfg(test)]
575mod tests {
576 use std::collections::BTreeMap;
577
578 use serde_json::json;
579
580 use super::*;
581 use crate::{Field, HttpMethod, OperationResponse, Schema};
582
583 #[test]
584 fn emits_static_happy_path_fixtures_with_schema_defaults() {
585 let api = Api {
586 name: "Poolster Email".into(),
587 version: "1".into(),
588 schemas: vec![Schema::new(
589 "Contact",
590 SchemaValue::new(SchemaKind::Object {
591 fields: vec![
592 Field {
593 name: "id".into(),
594 value: SchemaValue {
595 default: Some(json!("contact_123")),
596 ..SchemaValue::new(SchemaKind::String)
597 },
598 required: true,
599 annotations: BTreeMap::new(),
600 },
601 Field {
602 name: "email".into(),
603 value: SchemaValue {
604 format: Some("email".into()),
605 ..SchemaValue::new(SchemaKind::String)
606 },
607 required: true,
608 annotations: BTreeMap::new(),
609 },
610 ],
611 additional_properties: Default::default(),
612 }),
613 )],
614 operations: vec![Operation {
615 id: "getContact".into(),
616 method: HttpMethod::Get,
617 path: "/v1/contacts/{contact_id}".into(),
618 parameters: vec![],
619 request_body: None,
620 responses: vec![OperationResponse {
621 status: "200".into(),
622 description: None,
623 media_types: vec![OperationMediaType {
624 content_type: "application/json".into(),
625 schema: Some(SchemaValue::reference("#/components/schemas/Contact")),
626 }],
627 }],
628 security: vec![],
629 annotations: BTreeMap::from([(
630 "x-poolster-mock".into(),
631 json!({
632 "scenarios": [{
633 "name": "rate-limited",
634 "when": {
635 "path": { "contact_id": "contact_429" },
636 "headers": { "x-test-scenario": "rate-limited" },
637 "query": { "expand": "history" },
638 "body": { "ignored": false }
639 },
640 "response": {
641 "status": 429,
642 "headers": { "content-type": "application/json", "retry-after": "1" },
643 "body": { "message": "Too many requests" },
644 "delay_ms": 10
645 }
646 }]
647 }),
648 )]),
649 }],
650 annotations: BTreeMap::new(),
651 };
652
653 let files = generate_httpmock_fixtures(&api, &HttpmockFixtureConfig::default()).unwrap();
654 let fixture = files
655 .iter()
656 .find(|file| file.path == std::path::Path::new("mocks/httpmock/getcontact.yaml"))
657 .unwrap();
658 assert!(fixture.contents.contains("method: GET"));
659 assert!(
660 fixture
661 .contents
662 .contains("path_matches: '^/v1/contacts/[^/]+$'")
663 );
664 assert!(fixture.contents.contains("status: 200"));
665 assert!(
666 fixture
667 .contents
668 .contains("json_body: '{\"email\":\"mock@example.com\",\"id\":\"contact_123\"}'")
669 );
670 assert!(fixture.contents.contains("# Scenario: rate-limited"));
671 assert!(
672 fixture.contents.find("# Scenario: rate-limited").unwrap()
673 < fixture
674 .contents
675 .find("# Default happy-path response")
676 .unwrap()
677 );
678 assert!(
679 fixture
680 .contents
681 .contains("path: '/v1/contacts/contact_429'")
682 );
683 assert!(fixture.contents.contains("query_param:"));
684 assert!(fixture.contents.contains("status: 429"));
685 assert!(fixture.contents.contains("delay: 10ms"));
686 assert!(
687 fixture
688 .contents
689 .contains("json_body: '{\"message\":\"Too many requests\"}'")
690 );
691 }
692
693 #[test]
694 fn selects_lowest_successful_response_and_sanitizes_fixture_names() {
695 let operation = Operation {
696 id: "Create contact!".into(),
697 method: HttpMethod::Post,
698 path: "/contacts".into(),
699 parameters: vec![],
700 request_body: None,
701 responses: vec![
702 OperationResponse {
703 status: "404".into(),
704 description: None,
705 media_types: vec![],
706 },
707 OperationResponse {
708 status: "201".into(),
709 description: None,
710 media_types: vec![],
711 },
712 OperationResponse {
713 status: "200".into(),
714 description: None,
715 media_types: vec![],
716 },
717 ],
718 security: vec![],
719 annotations: BTreeMap::new(),
720 };
721 let files = generate_httpmock_fixtures(
722 &Api {
723 name: "Test".into(),
724 version: "1".into(),
725 schemas: vec![],
726 operations: vec![operation],
727 annotations: BTreeMap::new(),
728 },
729 &HttpmockFixtureConfig {
730 output_dir: "".into(),
731 include_readme: false,
732 },
733 )
734 .unwrap();
735 assert_eq!(files[0].path, std::path::Path::new("create-contact.yaml"));
736 assert!(files[0].contents.contains("status: 200"));
737 }
738
739 #[test]
740 fn dynamic_responses_vary_unconstrained_schema_values() {
741 let api = Api {
742 name: "Test".into(),
743 version: "1".into(),
744 schemas: vec![],
745 operations: vec![],
746 annotations: BTreeMap::new(),
747 };
748 let operation = Operation {
749 id: "getWidget".into(),
750 method: HttpMethod::Get,
751 path: "/widgets/{id}".into(),
752 parameters: vec![],
753 request_body: None,
754 responses: vec![OperationResponse {
755 status: "200".into(),
756 description: None,
757 media_types: vec![OperationMediaType {
758 content_type: "application/json".into(),
759 schema: Some(SchemaValue::new(SchemaKind::Object {
760 fields: vec![Field {
761 name: "email".into(),
762 value: SchemaValue {
763 format: Some("email".into()),
764 ..SchemaValue::new(SchemaKind::String)
765 },
766 required: true,
767 annotations: BTreeMap::new(),
768 }],
769 additional_properties: Default::default(),
770 })),
771 }],
772 }],
773 security: vec![],
774 annotations: BTreeMap::new(),
775 };
776 let (_, _, first) = mock_dynamic_response(&api, &operation, 1);
777 let (_, _, second) = mock_dynamic_response(&api, &operation, 2);
778 assert_eq!(first["email"], "mock-1@example.test");
779 assert_eq!(second["email"], "mock-2@example.test");
780 }
781}