Skip to main content

camel_integration_test/
partner_script.rs

1//! Partner-script grammar: the endpoint-keyed scripting vocabulary of
2//! a scenario document's `partners:` map (ADR-0069 section 9).
3//!
4//! Each entry maps an endpoint address to an ordered script list; a
5//! script carries optional `method`, `path`, `times`, and `delay`
6//! selectors plus exactly one of `response` / `fault`. The raw serde
7//! stage keeps selectors as strings; conversion runs during document
8//! validation so every failure names the entry key.
9//!
10//! The public grammar types are re-exported through
11//! [`crate::document`], which owns the document model and the parse
12//! entry point; this module stays private.
13
14use 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// ---------------------------------------------------------------------------
24// Public grammar
25// ---------------------------------------------------------------------------
26
27/// One partner script of a `partners:` entry: the response a partner
28/// serves when the system under test reaches its endpoint, or the
29/// fault it applies instead. Exactly one of `response` / `fault` must
30/// be declared. Grammar only; the runner consumes the map.
31#[derive(Debug, Clone)]
32pub struct PartnerScript {
33    /// Request method the script applies to; optional.
34    pub method: Option<String>,
35    /// Request path the script applies to; optional.
36    pub path: Option<String>,
37    /// How many requests the script applies to before it is
38    /// exhausted; optional.
39    pub times: Option<u32>,
40    /// How long the partner waits before acting; optional.
41    pub delay: Option<Duration>,
42    /// The scripted response; mutually exclusive with `fault`.
43    pub response: Option<PartnerScriptResponse>,
44    /// The scripted fault; mutually exclusive with `response`.
45    pub fault: Option<PartnerFault>,
46}
47
48/// The fault a partner script applies instead of serving a response.
49#[derive(Debug, Clone, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum PartnerFault {
52    /// Close the connection without answering.
53    Close,
54}
55
56/// The response a partner script serves.
57#[derive(Debug, Clone)]
58pub struct PartnerScriptResponse {
59    /// HTTP status code, validated to the 100-599 range at load.
60    pub status: Option<u16>,
61    /// Response headers.
62    pub headers: Option<BTreeMap<String, String>>,
63    /// Response body.
64    pub body: Option<Value>,
65}
66
67// ---------------------------------------------------------------------------
68// Raw serde stage
69// ---------------------------------------------------------------------------
70
71#[derive(Deserialize)]
72#[serde(deny_unknown_fields, rename_all = "camelCase")]
73struct RawPartnerScript {
74    method: Option<String>,
75    path: Option<String>,
76    /// Raw repeat count; validation checks the range so the error can
77    /// name the entry key.
78    times: Option<u64>,
79    /// Raw humantime string; parsed during validation so the error
80    /// can name the entry key.
81    delay: Option<String>,
82    /// Raw fault name; validated during conversion so the error can
83    /// name the entry key.
84    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
96// ---------------------------------------------------------------------------
97// Conversion
98// ---------------------------------------------------------------------------
99
100/// Converts the raw `partners` map into the public script model.
101/// Entries convert from the raw sequence with the entry key named on
102/// every failure; an empty sequence is a valid, inert entry. `None`
103/// stays `None`: the document declares no `partners:` section.
104pub(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/// Partner-script grammar parse tests, path-based like the document
190/// parser suite: each test writes a temporary `.test.yaml` document
191/// and parses it through [`crate::document::parse_scenario_document`].
192#[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    /// Writes `text` to a fresh temporary `case.test.yaml` and parses
200    /// it. Mirrors the helper of `doc_parse_test` so each parse-test
201    /// module stays self-contained.
202    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}