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: direct:start
221partners:
222 http://127.0.0.1:0/orders:
223 - method: POST
224 path: /orders
225 response:
226 status: 201
227 body:
228 id: ord-7
229"#,
230 )
231 .expect("parse must succeed");
232 let partners = doc.partners.expect("partners map must be present");
233 let scripts = partners
234 .get("http://127.0.0.1:0/orders")
235 .expect("the endpoint key must survive as the entry key");
236 assert_eq!(scripts.len(), 1, "the entry must carry one script");
237 let script = &scripts[0];
238 assert_eq!(script.method.as_deref(), Some("POST"));
239 assert_eq!(script.path.as_deref(), Some("/orders"));
240 let response = script
241 .response
242 .as_ref()
243 .expect("the script must carry a response");
244 assert_eq!(response.status, Some(201));
245 let body = response
246 .body
247 .as_ref()
248 .expect("the script must carry a body");
249 assert_eq!(
250 body.get("id"),
251 Some(&camel_api::Value::String("ord-7".to_string())),
252 "the body must keep the id"
253 );
254 }
255
256 #[test]
257 fn partners_unknown_key_is_doc_error() {
258 let err = parse_case(
259 r#"
260routeFiles: [routes.yaml]
261scenario:
262- send:
263 to: direct:start
264partners:
265 http://127.0.0.1:0/orders:
266 - method: POST
267 responsez:
268 status: 201
269"#,
270 )
271 .expect_err("parse must fail");
272 assert!(
273 err.to_string().contains("responsez"),
274 "error must name the offending key: {err}"
275 );
276 }
277
278 #[test]
279 fn partners_absent_keeps_none() {
280 let doc = parse_case(
281 r#"
282routeFiles: [routes.yaml]
283scenario:
284- send:
285 to: direct:start
286"#,
287 )
288 .expect("parse must succeed");
289 assert!(doc.partners.is_none(), "absent partners must stay None");
290 }
291
292 #[test]
293 fn partners_status_out_of_range_rejected() {
294 let err = parse_case(
295 r#"
296routeFiles: [routes.yaml]
297scenario:
298- send:
299 to: direct:start
300partners:
301 http://127.0.0.1:0/orders:
302 - response:
303 status: 999
304"#,
305 )
306 .expect_err("parse must fail");
307 let rendered = err.to_string();
308 assert!(
309 rendered.contains("http://127.0.0.1:0/orders"),
310 "error must name the entry key: {rendered}"
311 );
312 assert!(
313 rendered.contains("999"),
314 "error must name the offending status: {rendered}"
315 );
316 }
317
318 #[test]
319 fn times_zero_is_load_error() {
320 let err = parse_case(
321 r#"
322routeFiles: [routes.yaml]
323scenario:
324- send:
325 to: direct:start
326partners:
327 http://127.0.0.1:0/orders:
328 - times: 0
329 response:
330 status: 201
331"#,
332 )
333 .expect_err("parse must fail");
334 let rendered = err.to_string();
335 assert!(
336 rendered.contains("http://127.0.0.1:0/orders"),
337 "error must name the entry key: {rendered}"
338 );
339 assert!(
340 rendered.contains("times"),
341 "error must name the `times` field: {rendered}"
342 );
343 }
344
345 #[test]
346 fn times_over_u32_max_is_load_error() {
347 let err = parse_case(
348 r#"
349routeFiles: [routes.yaml]
350scenario:
351- send:
352 to: direct:start
353partners:
354 http://127.0.0.1:0/orders:
355 - times: 4294967296
356 response:
357 status: 201
358"#,
359 )
360 .expect_err("parse must fail");
361 let rendered = err.to_string();
362 assert!(
363 rendered.contains("http://127.0.0.1:0/orders"),
364 "error must name the entry key: {rendered}"
365 );
366 assert!(
367 rendered.contains("times"),
368 "error must name the `times` field: {rendered}"
369 );
370 assert!(
371 rendered.contains("1-4294967295"),
372 "error must name the valid range: {rendered}"
373 );
374 }
375
376 #[test]
377 fn both_response_and_fault_is_load_error() {
378 let err = parse_case(
379 r#"
380routeFiles: [routes.yaml]
381scenario:
382- send:
383 to: direct:start
384partners:
385 http://127.0.0.1:0/orders:
386 - response:
387 status: 201
388 fault: close
389"#,
390 )
391 .expect_err("parse must fail");
392 let rendered = err.to_string();
393 assert!(
394 rendered.contains("http://127.0.0.1:0/orders"),
395 "error must name the entry key: {rendered}"
396 );
397 assert!(
398 rendered.contains("response") && rendered.contains("fault"),
399 "error must name both fields: {rendered}"
400 );
401 }
402
403 #[test]
404 fn neither_response_nor_fault_is_load_error() {
405 let err = parse_case(
406 r#"
407routeFiles: [routes.yaml]
408scenario:
409- send:
410 to: direct:start
411partners:
412 http://127.0.0.1:0/orders:
413 - method: POST
414 path: /orders
415"#,
416 )
417 .expect_err("parse must fail");
418 let rendered = err.to_string();
419 assert!(
420 rendered.contains("http://127.0.0.1:0/orders"),
421 "error must name the entry key: {rendered}"
422 );
423 assert!(
424 rendered.contains("response") && rendered.contains("fault"),
425 "error must name both missing fields: {rendered}"
426 );
427 }
428
429 #[test]
430 fn unknown_fault_name_is_load_error() {
431 let err = parse_case(
432 r#"
433routeFiles: [routes.yaml]
434scenario:
435- send:
436 to: direct:start
437partners:
438 http://127.0.0.1:0/orders:
439 - fault: reset
440"#,
441 )
442 .expect_err("parse must fail");
443 let rendered = err.to_string();
444 assert!(
445 rendered.contains("http://127.0.0.1:0/orders"),
446 "error must name the entry key: {rendered}"
447 );
448 assert!(
449 rendered.contains("fault") && rendered.contains("reset"),
450 "error must name the `fault` field and the value: {rendered}"
451 );
452 }
453
454 #[test]
455 fn bad_delay_is_load_error() {
456 let humantime_error = humantime::parse_duration("500xyz")
457 .expect_err("500xyz must not parse as a duration")
458 .to_string();
459 let err = parse_case(
460 r#"
461routeFiles: [routes.yaml]
462scenario:
463- send:
464 to: direct:start
465partners:
466 http://127.0.0.1:0/orders:
467 - delay: 500xyz
468 response:
469 status: 201
470"#,
471 )
472 .expect_err("parse must fail");
473 let rendered = err.to_string();
474 assert!(
475 rendered.contains("http://127.0.0.1:0/orders"),
476 "error must name the entry key: {rendered}"
477 );
478 assert!(
479 rendered.contains("delay"),
480 "error must name the `delay` field: {rendered}"
481 );
482 assert!(
483 rendered.contains(&humantime_error),
484 "error must carry the humantime error text: {rendered}"
485 );
486 }
487
488 #[test]
489 #[cfg(feature = "http")]
490 fn partner_client_body_parity() {
491 use crate::adapters::http::value_to_wire;
492
493 let cases: Vec<(camel_api::Value, &[u8])> = vec![
497 (serde_json::json!("a\"b"), b"a\"b"),
500 (serde_json::Value::Null, b""),
502 (serde_json::json!({"k": 1}), b"{\"k\":1}"),
504 (serde_json::json!([1, 2]), b"[1,2]"),
505 ];
506 for (value, wire) in &cases {
507 assert_eq!(
508 &value_to_wire(value),
509 wire,
510 "body {value} must serve as the exact client-path bytes"
511 );
512 }
513 }
514
515 #[test]
516 fn times_delay_fault_parse() {
517 let doc = parse_case(
518 r#"
519routeFiles: [routes.yaml]
520scenario:
521- send:
522 to: direct:start
523partners:
524 http://127.0.0.1:0/orders:
525 - times: 2
526 delay: 300ms
527 fault: close
528 - method: GET
529 response:
530 status: 204
531"#,
532 )
533 .expect("parse must succeed");
534 let partners = doc.partners.expect("partners map must be present");
535 let scripts = partners
536 .get("http://127.0.0.1:0/orders")
537 .expect("the endpoint key must survive as the entry key");
538 assert_eq!(scripts.len(), 2, "both entries must survive");
539 let fault_script = &scripts[0];
540 assert_eq!(fault_script.times, Some(2), "times must parse as u32");
541 assert_eq!(
542 fault_script.delay,
543 Some(Duration::from_millis(300)),
544 "delay must parse as a humantime duration"
545 );
546 assert_eq!(fault_script.fault, Some(PartnerFault::Close));
547 assert!(
548 fault_script.response.is_none(),
549 "a fault entry carries no response"
550 );
551 let response_script = &scripts[1];
552 assert_eq!(response_script.times, None);
553 assert_eq!(response_script.delay, None);
554 assert_eq!(response_script.fault, None);
555 assert_eq!(
556 response_script
557 .response
558 .as_ref()
559 .expect("a plain entry carries a response")
560 .status,
561 Some(204)
562 );
563 }
564}