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