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, encoded onto the wire with the client send
64    /// path's `value_to_wire` semantics: a string serves as its exact
65    /// bytes (no surrounding quotes, no escaping), null serves empty,
66    /// any other value serves as compact JSON, and an absent body
67    /// serves empty.
68    pub body: Option<Value>,
69}
70
71// ---------------------------------------------------------------------------
72// Raw serde stage
73// ---------------------------------------------------------------------------
74
75#[derive(Deserialize)]
76#[serde(deny_unknown_fields, rename_all = "camelCase")]
77struct RawPartnerScript {
78    method: Option<String>,
79    path: Option<String>,
80    /// Raw repeat count; validation checks the range so the error can
81    /// name the entry key.
82    times: Option<u64>,
83    /// Raw humantime string; parsed during validation so the error
84    /// can name the entry key.
85    delay: Option<String>,
86    /// Raw fault name; validated during conversion so the error can
87    /// name the entry key.
88    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
100// ---------------------------------------------------------------------------
101// Conversion
102// ---------------------------------------------------------------------------
103
104/// Converts the raw `partners` map into the public script model.
105/// Entries convert from the raw sequence with the entry key named on
106/// every failure; an empty sequence is a valid, inert entry. `None`
107/// stays `None`: the document declares no `partners:` section.
108pub(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/// Partner-script grammar parse tests, path-based like the document
194/// parser suite: each test writes a temporary `.test.yaml` document
195/// and parses it through [`crate::document::parse_scenario_document`].
196#[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    /// Writes `text` to a fresh temporary `case.test.yaml` and parses
204    /// it. Mirrors the helper of `doc_parse_test` so each parse-test
205    /// module stays self-contained.
206    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        // The partner body vocabulary mirrors the client send path;
494        // every expectation is a literal byte constant, never derived
495        // from the function under test.
496        let cases: Vec<(camel_api::Value, &[u8])> = vec![
497            // A string serves as exact raw bytes: the inner quote is
498            // not escaped, no quotes surround the body.
499            (serde_json::json!("a\"b"), b"a\"b"),
500            // Null serves empty.
501            (serde_json::Value::Null, b""),
502            // Structured values serve as compact JSON.
503            (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}