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:
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        // The partner body vocabulary mirrors the client send path;
512        // every expectation is a literal byte constant, never derived
513        // from the function under test.
514        let cases: Vec<(camel_api::Value, &[u8])> = vec![
515            // A string serves as exact raw bytes: the inner quote is
516            // not escaped, no quotes surround the body.
517            (serde_json::json!("a\"b"), b"a\"b"),
518            // Null serves empty.
519            (serde_json::Value::Null, b""),
520            // Structured values serve as compact JSON.
521            (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}