Skip to main content

camel_processor/
recipient_list.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::task::{Context, Poll};
4
5use tokio::task::JoinSet;
6use tower::Service;
7use tower::ServiceExt;
8
9use camel_api::endpoint_pipeline::{CAMEL_SLIP_ENDPOINT, EndpointPipelineConfig};
10use camel_api::recipient_list::RecipientListConfig;
11use camel_api::{Body, CamelError, Exchange, Value};
12
13use crate::endpoint_pipeline::EndpointPipelineService;
14
15#[derive(Clone)]
16pub struct RecipientListService {
17    config: RecipientListConfig,
18    pipeline: EndpointPipelineService,
19}
20
21impl RecipientListService {
22    pub fn new(
23        config: RecipientListConfig,
24        endpoint_resolver: camel_api::EndpointResolver,
25    ) -> Result<Self, CamelError> {
26        config.validate()?;
27        let pipeline_config = EndpointPipelineConfig {
28            cache_size: EndpointPipelineConfig::from_signed(1000),
29            ignore_invalid_endpoints: false,
30        };
31        Ok(Self {
32            config,
33            pipeline: EndpointPipelineService::new(endpoint_resolver, pipeline_config),
34        })
35    }
36}
37
38impl Service<Exchange> for RecipientListService {
39    type Response = Exchange;
40    type Error = CamelError;
41    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
42
43    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
44        Poll::Ready(Ok(()))
45    }
46
47    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
48        let config = self.config.clone();
49        let pipeline = self.pipeline.clone();
50
51        Box::pin(async move {
52            let uris_raw = (config.expression)(&exchange);
53            if uris_raw.is_empty() {
54                return Ok(exchange);
55            }
56
57            // H13 Batch 1: cap the resolved-URI list BEFORE any endpoint
58            // resolution. A malicious expression yielding millions of URIs
59            // would otherwise allocate a Vec of millions of &str references
60            // and resolve each one (multicast) or call each one (sequential).
61            // The default cap is 1_000 (camel-api::recipient_list).
62            let cap = config.max_recipients;
63            let uris: Vec<&str> = uris_raw
64                .split(&config.delimiter)
65                .map(|s| s.trim())
66                .filter(|s| !s.is_empty())
67                .take(cap)
68                .collect();
69            if uris.is_empty() {
70                return Ok(exchange);
71            }
72
73            if config.parallel {
74                let original_for_aggregate = exchange.clone();
75                let mut endpoints_to_call = Vec::with_capacity(uris.len());
76                for uri in &uris {
77                    if let Some(endpoint) = pipeline.resolve(uri)? {
78                        endpoints_to_call.push((uri.to_string(), endpoint));
79                    }
80                }
81
82                let mut results: Vec<Exchange> = Vec::with_capacity(endpoints_to_call.len());
83                let mut join_set = JoinSet::new();
84                let mut iter = endpoints_to_call.into_iter();
85                let raw_limit = config.parallel_limit.unwrap_or(results.capacity());
86                let limit = raw_limit.max(1).min(results.capacity().max(1));
87                let mut last_parallel_error: Option<CamelError> = None;
88
89                for _ in 0..limit {
90                    if let Some((uri, mut endpoint)) = iter.next() {
91                        let mut cloned = original_for_aggregate.clone();
92                        cloned.set_property(CAMEL_SLIP_ENDPOINT, Value::String(uri));
93                        join_set.spawn(async move { endpoint.ready().await?.call(cloned).await });
94                    }
95                }
96
97                while let Some(result) = join_set.join_next().await {
98                    match result {
99                        Ok(Ok(ex)) => results.push(ex),
100                        Ok(Err(e)) if config.stop_on_exception => {
101                            join_set.abort_all();
102                            return Err(e);
103                        }
104                        Ok(Err(e)) => {
105                            // stop_on_exception=false: track the representative
106                            // error — the last failing task to complete via
107                            // join_next order (ADR-0058). Pending tasks continue.
108                            last_parallel_error = Some(e);
109                        }
110                        Err(join_err) if join_err.is_panic() => {
111                            // A recipient task panicked. ADR-0058: a panic is
112                            // zero-success attempted work and MUST NOT launder to
113                            // Ok(original); convert to a representative error so
114                            // the zero-success guard fires. (Cancellation is
115                            // handled separately below — it is often self-induced
116                            // by stop_on_exception's abort_all.)
117                            last_parallel_error = Some(CamelError::ProcessorError(format!(
118                                "recipient task panicked: {join_err}"
119                            )));
120                        }
121                        Err(_) => {} // Cancellation (JoinSet abort); ignore.
122                    }
123
124                    if let Some((uri, mut endpoint)) = iter.next() {
125                        let mut cloned = original_for_aggregate.clone();
126                        cloned.set_property(CAMEL_SLIP_ENDPOINT, Value::String(uri));
127                        join_set.spawn(async move { endpoint.ready().await?.call(cloned).await });
128                    }
129                }
130
131                // ADR-0058: zero-success operational failure. At least one
132                // recipient was called and zero returned Ok — report the
133                // representative error instead of laundering to Ok(original).
134                let zero_success_error = if results.is_empty() {
135                    last_parallel_error
136                } else {
137                    None
138                };
139                if let Some(err) = zero_success_error {
140                    return Err(err);
141                }
142
143                exchange = aggregate_results(config.strategy, original_for_aggregate, results);
144            } else {
145                let mut results: Vec<Exchange> = Vec::new();
146                let mut last_error: Option<CamelError> = None;
147                let original_for_aggregate = exchange.clone();
148                for uri in &uris {
149                    let endpoint = match pipeline.resolve(uri)? {
150                        Some(e) => e,
151                        None => continue,
152                    };
153                    exchange.set_property(CAMEL_SLIP_ENDPOINT, Value::String(uri.to_string()));
154                    let mut endpoint = endpoint;
155                    let result = endpoint.ready().await?.call(exchange.clone()).await;
156                    match result {
157                        Ok(ex) => {
158                            results.push(ex.clone());
159                            exchange = ex;
160                        }
161                        Err(e) if config.stop_on_exception => return Err(e),
162                        Err(e) => {
163                            // stop_on_exception=false: track the iteration-last
164                            // error (ADR-0058) and continue to remaining recipients.
165                            last_error = Some(e);
166                            continue;
167                        }
168                    }
169                }
170                // ADR-0058: zero-success operational failure. At least one
171                // recipient was called and zero returned Ok — report the
172                // iteration-last error instead of laundering to Ok(original),
173                // which would poison an outer cache write-back with the inbound body.
174                let zero_success_error = if results.is_empty() { last_error } else { None };
175                if let Some(err) = zero_success_error {
176                    return Err(err);
177                }
178                exchange = aggregate_results(config.strategy, original_for_aggregate, results);
179            }
180
181            Ok(exchange)
182        })
183    }
184}
185
186fn aggregate_results(
187    strategy: camel_api::MulticastStrategy,
188    original: Exchange,
189    results: Vec<Exchange>,
190) -> Exchange {
191    match strategy {
192        camel_api::MulticastStrategy::LastWins => results.into_iter().last().unwrap_or(original),
193        camel_api::MulticastStrategy::CollectAll => {
194            let bodies: Vec<Value> = results
195                .iter()
196                .map(|ex| match &ex.input.body {
197                    Body::Text(s) => Value::String(s.clone()),
198                    Body::Json(v) => v.clone(),
199                    Body::Xml(s) => Value::String(s.clone()),
200                    Body::Bytes(b) => Value::String(String::from_utf8_lossy(b).into_owned()),
201                    Body::Stream(s) => serde_json::json!({
202                        "_stream": {
203                            "origin": s.metadata.origin,
204                            "placeholder": true,
205                            "hint": "Materialize exchange body with .into_bytes() before recipient-list aggregation"
206                        }
207                    }),
208                    // Empty and future variants contribute no extractable value.
209                    _ => Value::Null,
210                })
211                .collect();
212            let mut result = results.into_iter().last().unwrap_or(original);
213            result.input.body = camel_api::Body::from(Value::Array(bodies));
214            result
215        }
216        camel_api::MulticastStrategy::Custom(fn_) => {
217            results.into_iter().fold(original, |acc, ex| fn_(acc, ex))
218        }
219        // Original and any future variant return the original exchange.
220        _ => original,
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use camel_api::MulticastStrategy;
228    use camel_api::{BoxProcessor, BoxProcessorExt, CamelError, Message};
229    use std::collections::HashMap;
230    use std::sync::Arc;
231    use std::sync::atomic::{AtomicUsize, Ordering};
232    use std::time::{Duration, Instant};
233    use tokio::sync::Mutex;
234    use tokio::time::sleep;
235
236    fn mock_resolver() -> camel_api::EndpointResolver {
237        Arc::new(|uri: &str| {
238            if uri.starts_with("mock:") {
239                Some(BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) })))
240            } else {
241                None
242            }
243        })
244    }
245
246    #[tokio::test]
247    async fn recipient_list_single_destination() {
248        let call_count = Arc::new(AtomicUsize::new(0));
249        let count_clone = call_count.clone();
250
251        let resolver = Arc::new(move |uri: &str| {
252            if uri == "mock:a" {
253                let count = count_clone.clone();
254                Some(BoxProcessor::from_fn(move |ex| {
255                    count.fetch_add(1, Ordering::SeqCst);
256                    Box::pin(async move { Ok(ex) })
257                }))
258            } else {
259                None
260            }
261        });
262
263        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| "mock:a".to_string()));
264
265        let mut svc = RecipientListService::new(config, resolver).unwrap();
266        let ex = Exchange::new(Message::new("test"));
267        let result = svc.ready().await.unwrap().call(ex).await;
268
269        assert!(result.is_ok());
270        assert_eq!(call_count.load(Ordering::SeqCst), 1);
271    }
272
273    #[tokio::test]
274    async fn recipient_list_multiple_destinations() {
275        let call_count = Arc::new(AtomicUsize::new(0));
276        let count_clone = call_count.clone();
277
278        let resolver = Arc::new(move |uri: &str| {
279            if uri.starts_with("mock:") {
280                let count = count_clone.clone();
281                Some(BoxProcessor::from_fn(move |ex| {
282                    count.fetch_add(1, Ordering::SeqCst);
283                    Box::pin(async move { Ok(ex) })
284                }))
285            } else {
286                None
287            }
288        });
289
290        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
291            "mock:a,mock:b,mock:c".to_string()
292        }));
293
294        let mut svc = RecipientListService::new(config, resolver).unwrap();
295        let ex = Exchange::new(Message::new("test"));
296        let result = svc.ready().await.unwrap().call(ex).await;
297
298        assert!(result.is_ok());
299        assert_eq!(call_count.load(Ordering::SeqCst), 3);
300    }
301
302    #[tokio::test]
303    async fn recipient_list_empty_expression() {
304        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| String::new()));
305
306        let mut svc = RecipientListService::new(config, mock_resolver()).unwrap();
307        let ex = Exchange::new(Message::new("test"));
308        let result = svc.ready().await.unwrap().call(ex).await;
309
310        assert!(result.is_ok());
311    }
312
313    #[tokio::test]
314    async fn recipient_list_invalid_endpoint_error() {
315        let config =
316            RecipientListConfig::new(Arc::new(|_ex: &Exchange| "invalid:endpoint".to_string()));
317
318        let mut svc = RecipientListService::new(config, mock_resolver()).unwrap();
319        let ex = Exchange::new(Message::new("test"));
320        let result = svc.ready().await.unwrap().call(ex).await;
321
322        assert!(result.is_err());
323        assert!(result.unwrap_err().to_string().contains("Invalid endpoint"));
324    }
325
326    #[tokio::test]
327    async fn recipient_list_custom_delimiter() {
328        use std::sync::Mutex;
329
330        let order: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
331
332        let resolver = {
333            let order = order.clone();
334            Arc::new(move |uri: &str| {
335                let order = order.clone();
336                let uri = uri.to_string();
337                Some(BoxProcessor::from_fn(move |ex| {
338                    order.lock().unwrap().push(uri.clone());
339                    Box::pin(async move { Ok(ex) })
340                }))
341            })
342        };
343
344        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
345            "mock:x|mock:y|mock:z".to_string()
346        }))
347        .delimiter("|");
348
349        let mut svc = RecipientListService::new(config, resolver).unwrap();
350        let ex = Exchange::new(Message::new("test"));
351        svc.ready().await.unwrap().call(ex).await.unwrap();
352
353        let order = order.lock().unwrap();
354        assert_eq!(*order, vec!["mock:x", "mock:y", "mock:z"]);
355    }
356
357    #[tokio::test]
358    async fn recipient_list_expression_evaluated_once() {
359        let expr_count = Arc::new(AtomicUsize::new(0));
360        let expr_count_clone = expr_count.clone();
361
362        let config = RecipientListConfig::new(Arc::new(move |_ex: &Exchange| {
363            expr_count_clone.fetch_add(1, Ordering::SeqCst);
364            "mock:a,mock:b".to_string()
365        }));
366
367        let mut svc = RecipientListService::new(config, mock_resolver()).unwrap();
368        let ex = Exchange::new(Message::new("test"));
369        svc.ready().await.unwrap().call(ex).await.unwrap();
370
371        assert_eq!(
372            expr_count.load(Ordering::SeqCst),
373            1,
374            "Expression must be evaluated exactly once"
375        );
376    }
377
378    #[tokio::test]
379    async fn recipient_list_ignores_empty_uri_tokens() {
380        let call_count = Arc::new(AtomicUsize::new(0));
381        let call_count_clone = call_count.clone();
382
383        let resolver = Arc::new(move |uri: &str| {
384            if uri.starts_with("mock:") {
385                let count = call_count_clone.clone();
386                Some(BoxProcessor::from_fn(move |ex| {
387                    count.fetch_add(1, Ordering::SeqCst);
388                    Box::pin(async move { Ok(ex) })
389                }))
390            } else {
391                None
392            }
393        });
394
395        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
396            " ,mock:a, ,mock:b,, ".to_string()
397        }));
398
399        let mut svc = RecipientListService::new(config, resolver).unwrap();
400        let ex = Exchange::new(Message::new("test"));
401        let result = svc.ready().await.unwrap().call(ex).await;
402        assert!(result.is_ok());
403        assert_eq!(call_count.load(Ordering::SeqCst), 2);
404    }
405
406    #[tokio::test]
407    async fn recipient_list_mutation_between_steps() {
408        let resolver = Arc::new(|uri: &str| {
409            if uri == "mock:mutate" {
410                Some(BoxProcessor::from_fn(|mut ex| {
411                    ex.input.body = camel_api::Body::Text("mutated".to_string());
412                    Box::pin(async move { Ok(ex) })
413                }))
414            } else if uri == "mock:verify" {
415                Some(BoxProcessor::from_fn(|ex| {
416                    let body = ex.input.body.as_text().unwrap_or("").to_string();
417                    assert_eq!(body, "mutated");
418                    Box::pin(async move { Ok(ex) })
419                }))
420            } else {
421                None
422            }
423        });
424
425        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
426            "mock:mutate,mock:verify".to_string()
427        }));
428
429        let mut svc = RecipientListService::new(config, resolver).unwrap();
430        let ex = Exchange::new(Message::new("original"));
431        let result = svc.ready().await.unwrap().call(ex).await;
432
433        assert!(result.is_ok());
434    }
435
436    #[tokio::test]
437    async fn recipient_list_parallel_executes_concurrently() {
438        let records: Arc<Mutex<Vec<(String, Instant, Instant)>>> = Arc::new(Mutex::new(Vec::new()));
439
440        let resolver = {
441            let records = records.clone();
442            Arc::new(move |uri: &str| {
443                if uri.starts_with("mock:") {
444                    let records = records.clone();
445                    let uri = uri.to_string();
446                    Some(BoxProcessor::from_fn(move |ex| {
447                        let records = records.clone();
448                        let uri = uri.clone();
449                        Box::pin(async move {
450                            let start = Instant::now();
451                            sleep(Duration::from_millis(100)).await;
452                            let end = Instant::now();
453                            records.lock().await.push((uri, start, end));
454                            Ok(ex)
455                        })
456                    }))
457                } else {
458                    None
459                }
460            })
461        };
462
463        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
464            "mock:a,mock:b,mock:c".to_string()
465        }))
466        .parallel(true);
467
468        let mut svc = RecipientListService::new(config, resolver).unwrap();
469        let ex = Exchange::new(Message::new("test"));
470        svc.ready().await.unwrap().call(ex).await.unwrap();
471
472        let records = records.lock().await;
473        assert_eq!(records.len(), 3);
474
475        let mut overlap_found = false;
476        for i in 0..records.len() {
477            for j in (i + 1)..records.len() {
478                let (_, a_start, a_end) = records[i];
479                let (_, b_start, b_end) = records[j];
480                if a_start < b_end && b_start < a_end {
481                    overlap_found = true;
482                    break;
483                }
484            }
485            if overlap_found {
486                break;
487            }
488        }
489
490        assert!(overlap_found);
491    }
492
493    #[tokio::test]
494    async fn recipient_list_parallel_stop_on_exception_returns_error() {
495        let resolver = Arc::new(|uri: &str| {
496            if uri == "mock:err" {
497                Some(BoxProcessor::from_fn(|_ex| {
498                    Box::pin(async { Err(CamelError::ProcessorError("boom".to_string())) })
499                }))
500            } else if uri.starts_with("mock:") {
501                Some(BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) })))
502            } else {
503                None
504            }
505        });
506
507        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
508            "mock:a,mock:err,mock:c".to_string()
509        }))
510        .parallel(true)
511        .stop_on_exception(true);
512
513        let mut svc = RecipientListService::new(config, resolver).unwrap();
514        let ex = Exchange::new(Message::new("test"));
515        let result = svc.ready().await.unwrap().call(ex).await;
516        assert!(matches!(result, Err(CamelError::ProcessorError(msg)) if msg == "boom"));
517    }
518
519    #[tokio::test]
520    async fn recipient_list_parallel_limit_respects_limit() {
521        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
522            "mock:a,mock:b,mock:c,mock:d".to_string()
523        }))
524        .parallel(true)
525        .parallel_limit(2);
526
527        let resolver = Arc::new(|uri: &str| {
528            if uri.starts_with("mock:") {
529                Some(BoxProcessor::from_fn(|ex| {
530                    Box::pin(async move {
531                        sleep(Duration::from_millis(100)).await;
532                        Ok(ex)
533                    })
534                }))
535            } else {
536                None
537            }
538        });
539
540        let mut svc = RecipientListService::new(config, resolver).unwrap();
541        let ex = Exchange::new(Message::new("test"));
542        let start = Instant::now();
543        svc.ready().await.unwrap().call(ex).await.unwrap();
544        let elapsed = start.elapsed();
545
546        assert!(elapsed >= Duration::from_millis(180));
547        assert!(elapsed < Duration::from_millis(350));
548    }
549
550    #[tokio::test]
551    async fn recipient_list_collect_all_strategy() {
552        let resolver = Arc::new(|uri: &str| {
553            if uri == "mock:a" {
554                Some(BoxProcessor::from_fn(|mut ex| {
555                    ex.input.body = Body::Text("a".to_string());
556                    Box::pin(async move { Ok(ex) })
557                }))
558            } else if uri == "mock:b" {
559                Some(BoxProcessor::from_fn(|mut ex| {
560                    ex.input.body = Body::Text("b".to_string());
561                    Box::pin(async move { Ok(ex) })
562                }))
563            } else if uri == "mock:c" {
564                Some(BoxProcessor::from_fn(|mut ex| {
565                    ex.input.body = Body::Text("c".to_string());
566                    Box::pin(async move { Ok(ex) })
567                }))
568            } else {
569                None
570            }
571        });
572
573        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
574            "mock:a,mock:b,mock:c".to_string()
575        }))
576        .strategy(MulticastStrategy::CollectAll);
577
578        let mut svc = RecipientListService::new(config, resolver).unwrap();
579        let ex = Exchange::new(Message::new("seed"));
580        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
581
582        assert_eq!(
583            result.input.body,
584            Body::from(Value::Array(vec![
585                Value::String("a".to_string()),
586                Value::String("b".to_string()),
587                Value::String("c".to_string()),
588            ]))
589        );
590    }
591
592    #[tokio::test]
593    async fn recipient_list_original_strategy() {
594        let resolver = Arc::new(|uri: &str| {
595            if uri.starts_with("mock:") {
596                let label = uri.to_string();
597                Some(BoxProcessor::from_fn(move |mut ex| {
598                    let label = label.clone();
599                    ex.input.body = Body::Text(format!("mutated-{label}"));
600                    Box::pin(async move { Ok(ex) })
601                }))
602            } else {
603                None
604            }
605        });
606
607        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
608            "mock:a,mock:b,mock:c".to_string()
609        }))
610        .strategy(MulticastStrategy::Original);
611
612        let mut svc = RecipientListService::new(config, resolver).unwrap();
613        let ex = Exchange::new(Message::new("original"));
614        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
615
616        assert_eq!(result.input.body.as_text(), Some("original"));
617    }
618
619    // ── H13 Batch 1: cap resolved-URI count ──────────────────────────
620
621    /// H13: an expression yielding millions of URIs is truncated to
622    /// `max_recipients` before endpoint resolution. The test uses a
623    /// cap of 4 to keep the test fast; the principle (cap the list) is
624    /// what Batch 1 enforces. The default cap is 1_000 in camel-api.
625    #[tokio::test]
626    async fn test_huge_recipient_list_is_capped() {
627        let call_count = Arc::new(AtomicUsize::new(0));
628        let count_clone = call_count.clone();
629
630        let resolver = Arc::new(move |uri: &str| {
631            if uri.starts_with("mock:") {
632                let count = count_clone.clone();
633                Some(BoxProcessor::from_fn(move |ex| {
634                    count.fetch_add(1, Ordering::SeqCst);
635                    Box::pin(async move { Ok(ex) })
636                }))
637            } else {
638                None
639            }
640        });
641
642        // Build the untrusted payload: 1_000_000 URIs as one string.
643        let mut many = String::with_capacity(8 * 1_000_000);
644        for i in 0..1_000_000 {
645            if i > 0 {
646                many.push(',');
647            }
648            many.push_str(&format!("mock:k{i}"));
649        }
650
651        // Disposition-5 pattern: the untrusted data flows FROM the exchange
652        // (a header on the inbound message), NOT from a captured variable.
653        // The expression reads it off the passed `&Exchange` — this is what
654        // makes the cap an untrusted-data-validation control, not a local
655        // limit.
656        let config = RecipientListConfig::new(Arc::new(|ex: &Exchange| {
657            ex.input
658                .header("CamelRecipients")
659                .and_then(|v| v.as_str().map(|s| s.to_string()))
660                .unwrap_or_default()
661        }))
662        .max_recipients(4);
663
664        let mut svc = RecipientListService::new(config, resolver).unwrap();
665        let mut ex = Exchange::new(Message::new("test"));
666        ex.input.set_header("CamelRecipients", Value::String(many));
667        let result = svc.ready().await.unwrap().call(ex).await;
668        assert!(result.is_ok(), "capped execution should still succeed");
669        assert_eq!(
670            call_count.load(Ordering::SeqCst),
671            4,
672            "must resolve at most max_recipients (4) endpoints"
673        );
674    }
675
676    #[tokio::test]
677    async fn recipient_list_last_wins_strategy() {
678        let payloads: Arc<HashMap<String, String>> = Arc::new(HashMap::from([
679            ("mock:a".to_string(), "first".to_string()),
680            ("mock:b".to_string(), "second".to_string()),
681            ("mock:c".to_string(), "third".to_string()),
682        ]));
683
684        let resolver = {
685            let payloads = payloads.clone();
686            Arc::new(move |uri: &str| {
687                if let Some(payload) = payloads.get(uri) {
688                    let payload = payload.clone();
689                    Some(BoxProcessor::from_fn(move |mut ex| {
690                        let payload = payload.clone();
691                        ex.input.body = Body::Text(payload);
692                        Box::pin(async move { Ok(ex) })
693                    }))
694                } else {
695                    None
696                }
697            })
698        };
699
700        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
701            "mock:a,mock:b,mock:c".to_string()
702        }))
703        .strategy(MulticastStrategy::LastWins);
704
705        let mut svc = RecipientListService::new(config, resolver).unwrap();
706        let ex = Exchange::new(Message::new("seed"));
707        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
708
709        assert_eq!(result.input.body.as_text(), Some("third"));
710    }
711
712    // ── ADR-0058: zero-success operational failure must not launder to Ok(original) ─
713
714    fn err_resolver(uri_to_err: Vec<(&'static str, CamelError)>) -> camel_api::EndpointResolver {
715        Arc::new(move |uri: &str| {
716            for (pattern, err) in &uri_to_err {
717                if uri == *pattern {
718                    let err = err.clone();
719                    return Some(BoxProcessor::from_fn(move |_ex| {
720                        let err = err.clone();
721                        Box::pin(async move { Err(err) })
722                    }));
723                }
724            }
725            None
726        })
727    }
728
729    #[tokio::test]
730    async fn recipient_list_sequential_all_failed_returns_err() {
731        // ADR-0058: zero-success sequential. One recipient errors; zero Ok.
732        // MUST return Err, not Ok(original) (which would poison an outer cache).
733        let resolver = err_resolver(vec![(
734            "mock:a",
735            CamelError::Config(String::from("seq-all-failed")),
736        )]);
737        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| "mock:a".to_string()))
738            .strategy(MulticastStrategy::LastWins);
739
740        let mut svc = RecipientListService::new(config, resolver).unwrap();
741        let mut ex = Exchange::new(Message::new("timer:t tick #1"));
742        ex.input.body = Body::Text(String::from("timer:t tick #1"));
743        let result = svc.ready().await.unwrap().call(ex).await;
744
745        assert!(
746            result.is_err(),
747            "zero-success recipient_list must return Err, not Ok(original)"
748        );
749        assert!(
750            matches!(result, Err(CamelError::Config(m)) if m == "seq-all-failed"),
751            "returned error must carry the iteration-last error"
752        );
753    }
754
755    #[tokio::test]
756    async fn recipient_list_parallel_all_failed_returns_err() {
757        // ADR-0058: zero-success parallel. Two recipients error; zero Ok.
758        // MUST return a representative Err, not Ok(original).
759        let resolver = err_resolver(vec![
760            ("mock:a", CamelError::Config(String::from("par-err-a"))),
761            ("mock:b", CamelError::Config(String::from("par-err-b"))),
762        ]);
763        let config =
764            RecipientListConfig::new(Arc::new(|_ex: &Exchange| "mock:a,mock:b".to_string()))
765                .strategy(MulticastStrategy::LastWins)
766                .parallel(true);
767
768        let mut svc = RecipientListService::new(config, resolver).unwrap();
769        let ex = Exchange::new(Message::new("inbound"));
770        let result = svc.ready().await.unwrap().call(ex).await;
771
772        assert!(
773            result.is_err(),
774            "zero-success parallel recipient_list must return Err, not Ok(original)"
775        );
776    }
777
778    #[tokio::test]
779    async fn recipient_list_parallel_last_error_is_join_next_order() {
780        // ADR-0058 last-error determinism: the representative error is the one
781        // from the task returned by the last `JoinSet::join_next` that completed
782        // with an error. mock:a errors immediately; mock:b awaits a oneshot
783        // signal then errors. The test sends the signal after a brief yield so
784        // mock:a completes first → join_next order yields mock:b's error last.
785        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
786        let rx = Arc::new(tokio::sync::Mutex::new(Some(rx)));
787        let resolver: camel_api::EndpointResolver = Arc::new(move |uri: &str| {
788            if uri == "mock:a" {
789                Some(BoxProcessor::from_fn(|_ex| {
790                    Box::pin(async move { Err(CamelError::Config(String::from("par-err-a"))) })
791                }))
792            } else if uri == "mock:b" {
793                let rx = rx.clone();
794                Some(BoxProcessor::from_fn(move |_ex| {
795                    let rx = rx.clone();
796                    Box::pin(async move {
797                        // Wait for the test's signal before completing.
798                        let mut lock = rx.lock().await;
799                        if let Some(rx) = lock.take() {
800                            let _ = rx.await;
801                        }
802                        Err(CamelError::Config(String::from("par-err-b")))
803                    })
804                }))
805            } else {
806                None
807            }
808        });
809        let config =
810            RecipientListConfig::new(Arc::new(|_ex: &Exchange| "mock:a,mock:b".to_string()))
811                .strategy(MulticastStrategy::LastWins)
812                .parallel(true);
813
814        let mut svc = RecipientListService::new(config, resolver).unwrap();
815        let ex = Exchange::new(Message::new("inbound"));
816
817        // Drive the call concurrently; release mock:b after mock:a has had a
818        // chance to error first.
819        let join = tokio::spawn(async move { svc.ready().await.unwrap().call(ex).await });
820        // Yield the runtime so mock:a (synchronous Err) completes before mock:b.
821        for _ in 0..10 {
822            tokio::task::yield_now().await;
823        }
824        let _ = tx.send(());
825        let result = join.await.unwrap();
826
827        assert!(
828            matches!(result, Err(CamelError::Config(ref m)) if m == "par-err-b"),
829            "representative error must be the last failing task to complete (mock:b), got: {result:?}"
830        );
831    }
832
833    #[tokio::test]
834    async fn recipient_list_partial_success_aggregates_and_returns_ok() {
835        // ADR-0058: partial success (>=1 Ok) MUST aggregate over successes and
836        // return Ok. The invariant fires only on ZERO successes.
837        let call_count = Arc::new(AtomicUsize::new(0));
838        let ok_count = call_count.clone();
839        let resolver: camel_api::EndpointResolver = Arc::new(move |uri: &str| {
840            if uri == "mock:ok" {
841                let c = ok_count.clone();
842                Some(BoxProcessor::from_fn(move |mut ex| {
843                    c.fetch_add(1, Ordering::SeqCst);
844                    ex.input.body = Body::Text(String::from("ok-body"));
845                    Box::pin(async move { Ok(ex) })
846                }))
847            } else if uri == "mock:fail" {
848                Some(BoxProcessor::from_fn(|_ex| {
849                    Box::pin(async move { Err(CamelError::Config(String::from("partial-fail"))) })
850                }))
851            } else {
852                None
853            }
854        });
855        let config =
856            RecipientListConfig::new(Arc::new(|_ex: &Exchange| "mock:fail,mock:ok".to_string()))
857                .strategy(MulticastStrategy::LastWins);
858
859        let mut svc = RecipientListService::new(config, resolver).unwrap();
860        let ex = Exchange::new(Message::new("inbound"));
861        let result = svc.ready().await.unwrap().call(ex).await;
862
863        assert!(
864            result.is_ok(),
865            "partial success must return Ok, got: {result:?}"
866        );
867        assert_eq!(call_count.load(Ordering::SeqCst), 1);
868        assert_eq!(result.unwrap().input.body.as_text(), Some("ok-body"));
869    }
870
871    #[tokio::test]
872    async fn recipient_list_parallel_all_panic_returns_err() {
873        // ADR-0058 (e_gpt review gap): a parallel recipient_list where every
874        // spawned task PANICS produces only JoinError(panic) results. These
875        // MUST NOT launder to Ok(original); convert to a representative error
876        // so the zero-success guard fires. Cancels (self-induced abort) stay
877        // ignored.
878        let resolver: camel_api::EndpointResolver = Arc::new(|uri: &str| {
879            if uri.starts_with("mock:panic") {
880                Some(BoxProcessor::from_fn(|_ex| {
881                    Box::pin(async move {
882                        panic!("recipient panicked");
883                    })
884                }))
885            } else {
886                None
887            }
888        });
889        let config = RecipientListConfig::new(Arc::new(|_ex: &Exchange| {
890            "mock:panic1,mock:panic2".to_string()
891        }))
892        .strategy(MulticastStrategy::LastWins)
893        .parallel(true);
894
895        let mut svc = RecipientListService::new(config, resolver).unwrap();
896        let ex = Exchange::new(Message::new("inbound"));
897        let result = svc.ready().await.unwrap().call(ex).await;
898
899        assert!(
900            result.is_err(),
901            "all-panic parallel recipient_list must return Err, not Ok(original); got: {result:?}"
902        );
903        assert!(
904            matches!(result, Err(CamelError::ProcessorError(_))),
905            "panic must surface as a ProcessorError representative"
906        );
907    }
908}