camel-integration-test 0.46.0

Scenario document model, parser, and integration-tier test harness for rust-camel
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! Outbound HTTP bridge end-to-end (ADR-0069 sections 4, 5, 7).
//!
//! Real boot, real partner, real wire: the test boots the full
//! composition root through [`boot_scenario`] (sealed config load,
//! `camel_bundles::boot`, layered-env route interpolation, context
//! start), stimulates the booted route at `direct:start` through the
//! context's producer path, and validates the request that reaches
//! the harness-owned partner on the wire — the normative proof.
//!
//! The method-field tests drive the partner's client role instead:
//! the scenario `send` performs a real HTTP request to the partner's
//! bound address, and `receive` consumes the parked response — the
//! scripted matcher is the oracle for the wire method.
#![cfg(feature = "http")]

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use camel_api::{CamelError, Value};
use camel_bundles::BootHandle;
use camel_core::CamelContext;

use camel_integration_test::adapters::DirectStimulus;
use camel_integration_test::env_layers::ambient_std;
use camel_integration_test::{
    DocumentOutcome, EndpointRef, Expectation, HttpPartner, LayeredEnv, PartnerAdapter,
    PartnerRouter, Provisioning, RouteSource, ScenarioAction, ScenarioDocument, ScenarioFailure,
    ScenarioTarget, ScenarioVars, ScenarioVerdict, ScriptedResponse, ValidateExpectation,
    boot_scenario, parse_scenario_document, run_scenario_document,
};

/// The doc endpoint URI the fixture declares for the partner. The `:0`
/// port is the router key (dispatch by endpoint equality); the arrival
/// lane keys by request path, so the `:0` form addresses the partner's
/// listener — only the client-role send needs the bound address.
const PARTNER_ENDPOINT: &str = "http://127.0.0.1:0/orders";

/// The fixture root: Camel.toml, routes/, and the scenario document.
fn fixture_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/outbound")
}

/// A scripted partner response matching one request method on the
/// bridge path. The matcher (method + path) is the oracle: a request
/// that does not match is served the unmatched-500 with an empty body,
/// so the parked-response body validation below is the proof.
fn scripted_response(method: &str, body: &[u8]) -> ScriptedResponse {
    ScriptedResponse {
        method: Some(method.to_string()),
        path: Some("/orders".to_string()),
        status: 200,
        headers: BTreeMap::new(),
        body: body.to_vec(),
        ..Default::default()
    }
}

/// The layered environment for one document: document `env` first,
/// the harness-provisioned bindings (the partner's bound address)
/// winning over everything, passthrough keys reading the ambient
/// process environment.
fn layered_env(
    doc: &ScenarioDocument,
    harness_provisioned: BTreeMap<String, String>,
) -> LayeredEnv {
    LayeredEnv::new(
        doc.env.clone().unwrap_or_default(),
        harness_provisioned,
        doc.env_passthrough.clone().unwrap_or_default(),
        ambient_std(),
    )
}

/// A test-only context `Lifecycle` whose `stop()` always fails: a
/// failing teardown dependency for the shutdown-fault injection.
struct FailingTeardown;

#[async_trait]
impl camel_api::lifecycle::Lifecycle for FailingTeardown {
    fn name(&self) -> &str {
        "test-failing-teardown"
    }

    async fn start(&mut self) -> Result<(), CamelError> {
        Ok(())
    }

    async fn stop(&mut self) -> Result<(), CamelError> {
        Err(CamelError::Config(
            "test-only failing teardown dependency".to_string(),
        ))
    }
}

/// Everything one booted outbound scenario needs: the parsed document,
/// the router (the partner for the receive endpoint, a
/// [`DirectStimulus`] for the route stimulus), the booted context
/// behind a shared lock, and the teardown handle.
struct BootedFixture {
    doc: ScenarioDocument,
    router: PartnerRouter,
    ctx: Arc<tokio::sync::Mutex<CamelContext>>,
    boot: BootHandle,
    /// The inbound listener's bound address as the boot provisioned it
    /// (rc-5yon); always `None` here — these fixtures declare no
    /// `inbound:` section.
    inbound_bound: Option<std::net::SocketAddr>,
}

/// Boots the given scenario document with the given partner registered
/// under `partner_key`: inject `PARTNER=http://127.0.0.1:<bound>` into
/// the harness-provisioned tier, boot through [`boot_scenario`], and
/// wire the router (the `direct:start` stimulus plus the partner).
async fn boot_with(
    doc: ScenarioDocument,
    partner: HttpPartner,
    partner_key: String,
) -> BootedFixture {
    let harness_provisioned = BTreeMap::from([(
        "PARTNER".to_string(),
        format!("http://{}", partner.bound_addr()),
    )]);
    let env = layered_env(&doc, harness_provisioned);
    let run = boot_scenario(&doc, &fixture_root(), &env)
        .await
        .expect("the full boot must succeed");
    let ctx = Arc::new(tokio::sync::Mutex::new(run.ctx));

    let mut adapters: BTreeMap<String, Box<dyn PartnerAdapter>> = BTreeMap::new();
    adapters.insert(
        "direct:start".to_string(),
        Box::new(DirectStimulus::new(Arc::clone(&ctx))),
    );
    adapters.insert(partner_key, Box::new(partner));
    BootedFixture {
        doc,
        router: PartnerRouter::new(adapters),
        ctx,
        boot: run.boot,
        inbound_bound: run.inbound_bound,
    }
}

/// Boots a method-under-test scenario: bind the partner with the
/// scripted response for `method`, build the document against the
/// partner's bound address (the client-role send connects to it), and
/// boot.
async fn boot_method_fixture(method: &str, expected_body: &str) -> BootedFixture {
    let partner = HttpPartner::start(vec![scripted_response(method, expected_body.as_bytes())])
        .await
        .expect("partner must bind 127.0.0.1:0");
    let bound_endpoint = format!("http://{}/orders", partner.bound_addr());
    let doc = method_scenario_document(method, expected_body, &bound_endpoint);
    boot_with(doc, partner, bound_endpoint).await
}

/// Boots the fixture scenario with a freshly bound partner: parse the
/// document, bind the partner on `127.0.0.1:0`, inject
/// `PARTNER=http://127.0.0.1:<bound>` into the harness-provisioned
/// tier, boot through [`boot_scenario`], and wire the router.
async fn boot_fixture() -> BootedFixture {
    let doc = parse_scenario_document(&fixture_root().join("bridge.test.yaml"))
        .expect("fixture document must parse");
    let partner = HttpPartner::start(vec![scripted_response("POST", b"accepted")])
        .await
        .expect("partner must bind 127.0.0.1:0");
    boot_with(doc, partner, PARTNER_ENDPOINT.to_string()).await
}

/// A scenario document over the bridge fixture: send to the partner
/// endpoint with an explicit `method` and no body, receive the parked
/// response, and validate the scripted payload the partner served for
/// that method. The explicit method is the field under test; the
/// parked-response body is the oracle. `partner_endpoint` is the
/// partner's bound address — the client-role send connects to it.
fn method_scenario_document(
    method: &str,
    expected_body: &str,
    partner_endpoint: &str,
) -> ScenarioDocument {
    let partner = EndpointRef {
        endpoint: partner_endpoint.to_string(),
        provisioning: Some(Provisioning::Harness),
        bind_var: Some("PARTNER".to_string()),
    };
    let scenario = vec![
        ScenarioAction::Send {
            to: partner.clone(),
            body: None,
            headers: None,
            method: method.to_string(),
            expect_reply: None,
        },
        ScenarioAction::Receive {
            from: partner.clone(),
            deadline: Duration::from_secs(2),
            extract: None,
        },
        ScenarioAction::Validate {
            target: ScenarioTarget::LastReceived(partner),
            expectation: ValidateExpectation::Message(Expectation::Equals(Value::String(
                expected_body.to_string(),
            ))),
            deadline: None,
            elapsed_at_least: None,
        },
    ];
    ScenarioDocument {
        source_path: fixture_root().join("bridge.test.yaml"),
        route_source: RouteSource::RouteFiles(vec![PathBuf::from("routes/bridge.yaml")]),
        scenario,
        partners: None,
        env: None,
        env_passthrough: None,
        profile: Some("default".to_string()),
        send_deadline: None,
        inbound: None,
        logs: None,
    }
}

/// The positive path: the booted route bridges the stimulus to the
/// partner, and the wire arrival validates — method, path, headers,
/// body.
#[tokio::test]
async fn outbound_bridge_validates_wire() {
    let fixture = boot_fixture().await;
    let mut vars = ScenarioVars::new();
    let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
    assert_eq!(
        outcome.verdict,
        Some(ScenarioVerdict::Pass),
        "every action must pass: {outcome:?}"
    );

    // Teardown: the normal variant asserts clean completion.
    let mut ctx = fixture.ctx.lock().await;
    fixture
        .boot
        .shutdown(&mut ctx)
        .await
        .expect("clean shutdown must complete");
}

/// The explicit `method: PUT` field reaches the partner end to end:
/// the partner's scripted matcher demands PUT, so the parked response
/// body `put-ok` is served only when the wire request really was PUT.
/// Under the legacy `body?POST:GET` rule the send would be GET, the
/// matcher would miss, and the partner would serve the unmatched-500
/// with an empty body — the body validation would fail.
#[tokio::test]
async fn explicit_put_reaches_partner() {
    let fixture = boot_method_fixture("PUT", "put-ok").await;
    let mut vars = ScenarioVars::new();
    let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
    assert_eq!(
        outcome.verdict,
        Some(ScenarioVerdict::Pass),
        "the PUT send must reach the partner and validate: {outcome:?}"
    );

    let mut ctx = fixture.ctx.lock().await;
    fixture
        .boot
        .shutdown(&mut ctx)
        .await
        .expect("clean shutdown must complete");
}

/// The explicit `method: POST` field reaches the partner end to end:
/// the partner's scripted matcher demands POST, so the parked response
/// body `post-ok` is served only when the wire request really was POST.
/// Under the legacy `body?POST:GET` rule a bodyless send would be GET,
/// the matcher would miss, and the partner would serve the unmatched-500
/// with an empty body — the body validation would fail.
#[tokio::test]
async fn bodyless_post_reaches_partner() {
    let fixture = boot_method_fixture("POST", "post-ok").await;
    let mut vars = ScenarioVars::new();
    let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
    assert_eq!(
        outcome.verdict,
        Some(ScenarioVerdict::Pass),
        "the bodyless POST send must reach the partner and validate: {outcome:?}"
    );

    let mut ctx = fixture.ctx.lock().await;
    fixture
        .boot
        .shutdown(&mut ctx)
        .await
        .expect("clean shutdown must complete");
}

/// The regression shape of rc-eoft: one corrupted header value fails
/// the verdict with a `ValidationMismatch` naming the header.
#[tokio::test]
async fn outbound_bridge_header_corruption_fails() {
    let fixture = boot_fixture().await;
    // Corrupt one header expectation: the route stamps `priority`, the
    // scenario demands `express`.
    let corrupted = ScenarioDocument {
        source_path: fixture.doc.source_path.clone(),
        route_source: fixture.doc.route_source,
        scenario: fixture
            .doc
            .scenario
            .iter()
            .map(|action| {
                if let ScenarioAction::Validate { target, .. } = action
                    && matches!(target, ScenarioTarget::Variable(name) if name == "orderType")
                {
                    ScenarioAction::Validate {
                        target: target.clone(),
                        expectation: ValidateExpectation::Message(Expectation::Equals(
                            Value::String("express".to_string()),
                        )),
                        deadline: None,
                        elapsed_at_least: None,
                    }
                } else {
                    action.clone()
                }
            })
            .collect(),
        env: fixture.doc.env.clone(),
        env_passthrough: fixture.doc.env_passthrough.clone(),
        profile: fixture.doc.profile.clone(),
        partners: None,
        send_deadline: fixture.doc.send_deadline,
        inbound: fixture.doc.inbound,
        logs: fixture.doc.logs,
    };
    let mut vars = ScenarioVars::new();
    let outcome = run_scenario_document(&corrupted, &fixture.router, &mut vars, None).await;
    assert_eq!(outcome.verdict, None, "the corrupted header must fail");
    let mismatch = outcome
        .per_action
        .last()
        .and_then(|result| result.as_ref().err())
        .expect("the failing action must carry a failure");
    assert!(
        matches!(mismatch, ScenarioFailure::ValidationMismatch { .. }),
        "expected ValidationMismatch, got {mismatch:?}"
    );
    assert!(
        mismatch.to_string().contains("orderType"),
        "the mismatch must name the header's variable: {mismatch}"
    );

    let mut ctx = fixture.ctx.lock().await;
    fixture
        .boot
        .shutdown(&mut ctx)
        .await
        .expect("shutdown after a verdict failure must still complete");
}

/// Shutdown fault injection, deterministic: a test-only context
/// `Lifecycle` whose `stop()` fails. A passing verdict stays recorded
/// while the shutdown failure reports in the post-verdict slot — exit
/// path 2 at the CLI mapping, never a masked verdict.
#[tokio::test]
async fn shutdown_failure_does_not_mask_verdict() {
    let fixture = boot_fixture().await;
    // Fault injection AFTER the boot, BEFORE the run: the failing
    // teardown dependency sits in the context's lifecycle drain.
    fixture.ctx.lock().await.add_lifecycle(FailingTeardown);

    let mut vars = ScenarioVars::new();
    let mut outcome: DocumentOutcome =
        run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
    // The boot-owning flow forwards the provisioned inbound address to
    // the outcome slot (rc-5yon); `None` for these fixtures.
    outcome.inbound_bound = fixture.inbound_bound;
    assert_eq!(outcome.verdict, Some(ScenarioVerdict::Pass));

    // The shutdown-failure slot is the boot-owning caller's to fill
    // (the CLI after `handle.shutdown`, Task 3.5). This test fills it
    // exactly as that mapping will.
    let mut ctx = fixture.ctx.lock().await;
    let shutdown = fixture.boot.shutdown(&mut ctx).await;
    outcome.final_failure = shutdown.err().map(|e| ScenarioFailure::ShutdownFailure {
        message: e.to_string(),
    });

    assert_eq!(
        outcome.verdict,
        Some(ScenarioVerdict::Pass),
        "the shutdown failure must not mask the recorded verdict"
    );
    let final_failure = outcome
        .final_failure
        .as_ref()
        .expect("the shutdown failure must be reported deterministically");
    assert!(
        matches!(final_failure, ScenarioFailure::ShutdownFailure { .. }),
        "expected ShutdownFailure, got {final_failure:?}"
    );
    assert!(
        final_failure
            .to_string()
            .contains("test-only failing teardown"),
        "the failure must name the teardown dependency: {final_failure}"
    );
}

/// A receive deadline is honored end-to-end: without the route
/// stimulus (the scenario never sends), the receive reports a
/// verdict-class timeout bounded by the declared deadline.
#[tokio::test]
async fn outbound_receive_deadline_is_real() {
    let mut fixture = boot_fixture().await;
    // Drop the stimulus: only the receive and validate actions run.
    fixture.doc.scenario.retain(|action| {
        matches!(
            action,
            ScenarioAction::Receive { .. } | ScenarioAction::Validate { .. }
        )
    });
    let mut vars = ScenarioVars::new();
    let started = std::time::Instant::now();
    let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
    assert_eq!(outcome.verdict, None);
    let failure = outcome
        .per_action
        .first()
        .and_then(|result| result.as_ref().err())
        .expect("the receive must fail");
    assert!(
        matches!(failure, ScenarioFailure::ReceiveTimeout { .. }),
        "expected ReceiveTimeout, got {failure:?}"
    );
    assert!(
        started.elapsed() < Duration::from_secs(5),
        "the 2s deadline must bound the wait, not hang"
    );

    let mut ctx = fixture.ctx.lock().await;
    fixture
        .boot
        .shutdown(&mut ctx)
        .await
        .expect("shutdown must complete after a timeout");
}