1use std::collections::BTreeMap;
15use std::time::Duration;
16
17use camel_api::Value;
18use noyalib::compat::serde_yaml;
19use serde::Deserialize;
20
21use crate::document::DocError;
22
23#[derive(Debug, Clone)]
32pub struct PartnerScript {
33 pub method: Option<String>,
35 pub path: Option<String>,
37 pub times: Option<u32>,
40 pub delay: Option<Duration>,
42 pub response: Option<PartnerScriptResponse>,
44 pub fault: Option<PartnerFault>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum PartnerFault {
52 Close,
54}
55
56#[derive(Debug, Clone)]
58pub struct PartnerScriptResponse {
59 pub status: Option<u16>,
61 pub headers: Option<BTreeMap<String, String>>,
63 pub body: Option<Value>,
69}
70
71#[derive(Deserialize)]
76#[serde(deny_unknown_fields, rename_all = "camelCase")]
77struct RawPartnerScript {
78 method: Option<String>,
79 path: Option<String>,
80 times: Option<u64>,
83 delay: Option<String>,
86 fault: Option<String>,
89 response: Option<RawPartnerScriptResponse>,
90}
91
92#[derive(Deserialize)]
93#[serde(deny_unknown_fields, rename_all = "camelCase")]
94struct RawPartnerScriptResponse {
95 status: Option<u16>,
96 headers: Option<BTreeMap<String, String>>,
97 body: Option<Value>,
98}
99
100pub(crate) fn partners_from_raw(
109 raw: Option<BTreeMap<String, serde_yaml::Value>>,
110) -> Result<Option<BTreeMap<String, Vec<PartnerScript>>>, DocError> {
111 let Some(raw_partners) = raw else {
112 return Ok(None);
113 };
114 let mut partners = BTreeMap::new();
115 for (endpoint, raw_scripts) in raw_partners {
116 let entry_error = |message: String| DocError::Partners {
117 endpoint: endpoint.clone(),
118 message,
119 };
120 let scripts = serde_yaml::from_value::<Vec<RawPartnerScript>>(raw_scripts)
121 .map_err(|e| entry_error(e.to_string()))?;
122 let mut converted = Vec::with_capacity(scripts.len());
123 for script in scripts {
124 let times = match script.times {
125 None => None,
126 Some(times) => match u32::try_from(times) {
127 Ok(times) if times >= 1 => Some(times),
128 _ => {
129 return Err(entry_error(format!(
130 "`times` {times} is out of range; expected 1-4294967295"
131 )));
132 }
133 },
134 };
135 let delay = match script.delay.as_deref() {
136 None => None,
137 Some(raw) => match humantime::parse_duration(raw) {
138 Ok(delay) => Some(delay),
139 Err(e) => {
140 return Err(entry_error(format!("invalid `delay` `{raw}`: {e}")));
141 }
142 },
143 };
144 let fault = match script.fault.as_deref() {
145 None => None,
146 Some("close") => Some(PartnerFault::Close),
147 Some(value) => {
148 return Err(entry_error(format!(
149 "unknown `fault` `{value}`; expected `close`"
150 )));
151 }
152 };
153 match (&script.response, &fault) {
154 (Some(_), Some(_)) => {
155 return Err(entry_error(
156 "`response` and `fault` are mutually exclusive; declare exactly one"
157 .to_string(),
158 ));
159 }
160 (None, None) => {
161 return Err(entry_error(
162 "a script requires `response` or `fault`; declare exactly one".to_string(),
163 ));
164 }
165 _ => {}
166 }
167 if let Some(response) = &script.response
168 && let Some(status) = response.status
169 && !(100..=599).contains(&status)
170 {
171 return Err(entry_error(format!(
172 "response `status` {status} is out of range; expected 100-599"
173 )));
174 }
175 converted.push(PartnerScript {
176 method: script.method,
177 path: script.path,
178 times,
179 delay,
180 response: script.response.map(|response| PartnerScriptResponse {
181 status: response.status,
182 headers: response.headers,
183 body: response.body,
184 }),
185 fault,
186 });
187 }
188 partners.insert(endpoint, converted);
189 }
190 Ok(Some(partners))
191}
192
193#[cfg(test)]
197mod tests {
198 use std::time::Duration;
199
200 use super::PartnerFault;
201 use crate::document::{DocError, ScenarioDocument, parse_scenario_document};
202
203 fn parse_case(text: &str) -> Result<ScenarioDocument, DocError> {
207 let dir = tempfile::tempdir().expect("temp dir");
208 let path = dir.path().join("case.test.yaml");
209 std::fs::write(&path, text).expect("write case file");
210 parse_scenario_document(&path)
211 }
212
213 #[test]
214 fn partners_section_parses() {
215 let doc = parse_case(
216 r#"
217routeFiles: [routes.yaml]
218scenario:
219- send:
220 to:
221 endpoint: http://127.0.0.1:0/orders
222 provisioning: harness
223partners:
224 http://127.0.0.1:0/orders:
225 - method: POST
226 path: /orders
227 response:
228 status: 201
229 body:
230 id: ord-7
231"#,
232 )
233 .expect("parse must succeed");
234 let partners = doc.partners.expect("partners map must be present");
235 let scripts = partners
236 .get("http://127.0.0.1:0/orders")
237 .expect("the endpoint key must survive as the entry key");
238 assert_eq!(scripts.len(), 1, "the entry must carry one script");
239 let script = &scripts[0];
240 assert_eq!(script.method.as_deref(), Some("POST"));
241 assert_eq!(script.path.as_deref(), Some("/orders"));
242 let response = script
243 .response
244 .as_ref()
245 .expect("the script must carry a response");
246 assert_eq!(response.status, Some(201));
247 let body = response
248 .body
249 .as_ref()
250 .expect("the script must carry a body");
251 assert_eq!(
252 body.get("id"),
253 Some(&camel_api::Value::String("ord-7".to_string())),
254 "the body must keep the id"
255 );
256 }
257
258 #[test]
259 fn partners_unknown_key_is_doc_error() {
260 let err = parse_case(
261 r#"
262routeFiles: [routes.yaml]
263scenario:
264- send:
265 to:
266 endpoint: http://127.0.0.1:0/orders
267 provisioning: harness
268partners:
269 http://127.0.0.1:0/orders:
270 - method: POST
271 responsez:
272 status: 201
273"#,
274 )
275 .expect_err("parse must fail");
276 assert!(
277 err.to_string().contains("responsez"),
278 "error must name the offending key: {err}"
279 );
280 }
281
282 #[test]
283 fn partners_absent_keeps_none() {
284 let doc = parse_case(
285 r#"
286routeFiles: [routes.yaml]
287scenario:
288- send:
289 to: direct:start
290"#,
291 )
292 .expect("parse must succeed");
293 assert!(doc.partners.is_none(), "absent partners must stay None");
294 }
295
296 #[test]
297 fn partners_status_out_of_range_rejected() {
298 let err = parse_case(
299 r#"
300routeFiles: [routes.yaml]
301scenario:
302- send:
303 to:
304 endpoint: http://127.0.0.1:0/orders
305 provisioning: harness
306partners:
307 http://127.0.0.1:0/orders:
308 - response:
309 status: 999
310"#,
311 )
312 .expect_err("parse must fail");
313 let rendered = err.to_string();
314 assert!(
315 rendered.contains("http://127.0.0.1:0/orders"),
316 "error must name the entry key: {rendered}"
317 );
318 assert!(
319 rendered.contains("999"),
320 "error must name the offending status: {rendered}"
321 );
322 }
323
324 #[test]
325 fn times_zero_is_load_error() {
326 let err = parse_case(
327 r#"
328routeFiles: [routes.yaml]
329scenario:
330- send:
331 to:
332 endpoint: http://127.0.0.1:0/orders
333 provisioning: harness
334partners:
335 http://127.0.0.1:0/orders:
336 - times: 0
337 response:
338 status: 201
339"#,
340 )
341 .expect_err("parse must fail");
342 let rendered = err.to_string();
343 assert!(
344 rendered.contains("http://127.0.0.1:0/orders"),
345 "error must name the entry key: {rendered}"
346 );
347 assert!(
348 rendered.contains("times"),
349 "error must name the `times` field: {rendered}"
350 );
351 }
352
353 #[test]
354 fn times_over_u32_max_is_load_error() {
355 let err = parse_case(
356 r#"
357routeFiles: [routes.yaml]
358scenario:
359- send:
360 to:
361 endpoint: http://127.0.0.1:0/orders
362 provisioning: harness
363partners:
364 http://127.0.0.1:0/orders:
365 - times: 4294967296
366 response:
367 status: 201
368"#,
369 )
370 .expect_err("parse must fail");
371 let rendered = err.to_string();
372 assert!(
373 rendered.contains("http://127.0.0.1:0/orders"),
374 "error must name the entry key: {rendered}"
375 );
376 assert!(
377 rendered.contains("times"),
378 "error must name the `times` field: {rendered}"
379 );
380 assert!(
381 rendered.contains("1-4294967295"),
382 "error must name the valid range: {rendered}"
383 );
384 }
385
386 #[test]
387 fn both_response_and_fault_is_load_error() {
388 let err = parse_case(
389 r#"
390routeFiles: [routes.yaml]
391scenario:
392- send:
393 to:
394 endpoint: http://127.0.0.1:0/orders
395 provisioning: harness
396partners:
397 http://127.0.0.1:0/orders:
398 - response:
399 status: 201
400 fault: close
401"#,
402 )
403 .expect_err("parse must fail");
404 let rendered = err.to_string();
405 assert!(
406 rendered.contains("http://127.0.0.1:0/orders"),
407 "error must name the entry key: {rendered}"
408 );
409 assert!(
410 rendered.contains("response") && rendered.contains("fault"),
411 "error must name both fields: {rendered}"
412 );
413 }
414
415 #[test]
416 fn neither_response_nor_fault_is_load_error() {
417 let err = parse_case(
418 r#"
419routeFiles: [routes.yaml]
420scenario:
421- send:
422 to:
423 endpoint: http://127.0.0.1:0/orders
424 provisioning: harness
425partners:
426 http://127.0.0.1:0/orders:
427 - method: POST
428 path: /orders
429"#,
430 )
431 .expect_err("parse must fail");
432 let rendered = err.to_string();
433 assert!(
434 rendered.contains("http://127.0.0.1:0/orders"),
435 "error must name the entry key: {rendered}"
436 );
437 assert!(
438 rendered.contains("response") && rendered.contains("fault"),
439 "error must name both missing fields: {rendered}"
440 );
441 }
442
443 #[test]
444 fn unknown_fault_name_is_load_error() {
445 let err = parse_case(
446 r#"
447routeFiles: [routes.yaml]
448scenario:
449- send:
450 to:
451 endpoint: http://127.0.0.1:0/orders
452 provisioning: harness
453partners:
454 http://127.0.0.1:0/orders:
455 - fault: reset
456"#,
457 )
458 .expect_err("parse must fail");
459 let rendered = err.to_string();
460 assert!(
461 rendered.contains("http://127.0.0.1:0/orders"),
462 "error must name the entry key: {rendered}"
463 );
464 assert!(
465 rendered.contains("fault") && rendered.contains("reset"),
466 "error must name the `fault` field and the value: {rendered}"
467 );
468 }
469
470 #[test]
471 fn bad_delay_is_load_error() {
472 let humantime_error = humantime::parse_duration("500xyz")
473 .expect_err("500xyz must not parse as a duration")
474 .to_string();
475 let err = parse_case(
476 r#"
477routeFiles: [routes.yaml]
478scenario:
479- send:
480 to:
481 endpoint: http://127.0.0.1:0/orders
482 provisioning: harness
483partners:
484 http://127.0.0.1:0/orders:
485 - delay: 500xyz
486 response:
487 status: 201
488"#,
489 )
490 .expect_err("parse must fail");
491 let rendered = err.to_string();
492 assert!(
493 rendered.contains("http://127.0.0.1:0/orders"),
494 "error must name the entry key: {rendered}"
495 );
496 assert!(
497 rendered.contains("delay"),
498 "error must name the `delay` field: {rendered}"
499 );
500 assert!(
501 rendered.contains(&humantime_error),
502 "error must carry the humantime error text: {rendered}"
503 );
504 }
505
506 #[test]
507 #[cfg(feature = "http")]
508 fn partner_client_body_parity() {
509 use crate::adapters::http::value_to_wire;
510
511 let cases: Vec<(camel_api::Value, &[u8])> = vec![
515 (serde_json::json!("a\"b"), b"a\"b"),
518 (serde_json::Value::Null, b""),
520 (serde_json::json!({"k": 1}), b"{\"k\":1}"),
522 (serde_json::json!([1, 2]), b"[1,2]"),
523 ];
524 for (value, wire) in &cases {
525 assert_eq!(
526 &value_to_wire(value),
527 wire,
528 "body {value} must serve as the exact client-path bytes"
529 );
530 }
531 }
532
533 #[test]
534 fn times_delay_fault_parse() {
535 let doc = parse_case(
536 r#"
537routeFiles: [routes.yaml]
538scenario:
539- send:
540 to:
541 endpoint: http://127.0.0.1:0/orders
542 provisioning: harness
543partners:
544 http://127.0.0.1:0/orders:
545 - times: 2
546 delay: 300ms
547 fault: close
548 - method: GET
549 response:
550 status: 204
551"#,
552 )
553 .expect("parse must succeed");
554 let partners = doc.partners.expect("partners map must be present");
555 let scripts = partners
556 .get("http://127.0.0.1:0/orders")
557 .expect("the endpoint key must survive as the entry key");
558 assert_eq!(scripts.len(), 2, "both entries must survive");
559 let fault_script = &scripts[0];
560 assert_eq!(fault_script.times, Some(2), "times must parse as u32");
561 assert_eq!(
562 fault_script.delay,
563 Some(Duration::from_millis(300)),
564 "delay must parse as a humantime duration"
565 );
566 assert_eq!(fault_script.fault, Some(PartnerFault::Close));
567 assert!(
568 fault_script.response.is_none(),
569 "a fault entry carries no response"
570 );
571 let response_script = &scripts[1];
572 assert_eq!(response_script.times, None);
573 assert_eq!(response_script.delay, None);
574 assert_eq!(response_script.fault, None);
575 assert_eq!(
576 response_script
577 .response
578 .as_ref()
579 .expect("a plain entry carries a response")
580 .status,
581 Some(204)
582 );
583 }
584}