aws_smithy_mocks/
interceptor.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::{MockResponse, Rule, RuleMode};
7use aws_smithy_http_client::test_util::infallible_client_fn;
8use aws_smithy_runtime_api::box_error::BoxError;
9use aws_smithy_runtime_api::client::http::SharedHttpClient;
10use aws_smithy_runtime_api::client::interceptors::context::{
11    BeforeSerializationInterceptorContextMut, BeforeTransmitInterceptorContextMut, Error,
12    FinalizerInterceptorContextMut, Input, Output,
13};
14use aws_smithy_runtime_api::client::interceptors::Intercept;
15use aws_smithy_runtime_api::client::orchestrator::{HttpResponse, OrchestratorError};
16use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
17use aws_smithy_types::body::SdkBody;
18use aws_smithy_types::config_bag::{ConfigBag, Storable, StoreReplace};
19use std::collections::VecDeque;
20use std::fmt;
21use std::sync::{Arc, Mutex};
22
23// Store active rule in config bag
24#[derive(Debug, Clone)]
25struct ActiveRule(Rule);
26
27impl Storable for ActiveRule {
28    type Storer = StoreReplace<ActiveRule>;
29}
30
31/// Interceptor which produces mock responses based on a list of rules
32pub struct MockResponseInterceptor {
33    rules: Arc<Mutex<VecDeque<Rule>>>,
34    rule_mode: RuleMode,
35    must_match: bool,
36    active_response: Arc<Mutex<Option<MockResponse<Output, Error>>>>,
37}
38
39impl fmt::Debug for MockResponseInterceptor {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        write!(f, "{} rules", self.rules.lock().unwrap().len())
42    }
43}
44
45impl Default for MockResponseInterceptor {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl MockResponseInterceptor {
52    /// Create a new [MockResponseInterceptor]
53    ///
54    /// This is normally created and registered on a client through the [`mock_client`](crate::mock_client) macro.
55    pub fn new() -> Self {
56        Self {
57            rules: Default::default(),
58            rule_mode: RuleMode::MatchAny,
59            must_match: true,
60            active_response: Default::default(),
61        }
62    }
63    /// Add a rule to the Interceptor
64    ///
65    /// Rules are matched in order—this rule will only apply if all previous rules do not match.
66    pub fn with_rule(self, rule: &Rule) -> Self {
67        self.rules.lock().unwrap().push_back(rule.clone());
68        self
69    }
70
71    /// Set the RuleMode to use when evaluating rules.
72    ///
73    /// See `RuleMode` enum for modes and how they are applied.
74    pub fn rule_mode(mut self, rule_mode: RuleMode) -> Self {
75        self.rule_mode = rule_mode;
76        self
77    }
78
79    /// Allow passthrough for unmatched requests.
80    ///
81    /// By default, if a request doesn't match any rule, the interceptor will panic.
82    /// This method allows unmatched requests to pass through.
83    pub fn allow_passthrough(mut self) -> Self {
84        self.must_match = false;
85        self
86    }
87}
88
89impl Intercept for MockResponseInterceptor {
90    fn name(&self) -> &'static str {
91        "MockResponseInterceptor"
92    }
93
94    fn modify_before_serialization(
95        &self,
96        context: &mut BeforeSerializationInterceptorContextMut<'_>,
97        _runtime_components: &RuntimeComponents,
98        cfg: &mut ConfigBag,
99    ) -> Result<(), BoxError> {
100        let mut rules = self.rules.lock().unwrap();
101        let input = context.inner().input().expect("input set");
102
103        // Find a matching rule and get its response
104        let mut matching_rule = None;
105        let mut matching_response = None;
106
107        match self.rule_mode {
108            RuleMode::Sequential => {
109                // Sequential mode requires rules match in-order
110                let i = 0;
111                while i < rules.len() && matching_response.is_none() {
112                    let rule = &rules[i];
113
114                    // Check if the rule is already exhausted or if it's a simple rule used once
115                    //
116                    // In `aws-smithy-mocks-experimental` all rules were infinite sequences
117                    // but were only usable once in sequential mode. We retain that here for
118                    // backwards compatibility.
119                    if rule.is_exhausted() || (rule.is_simple() && rule.num_calls() > 0) {
120                        // Rule is exhausted, remove it and try the next one
121                        rules.remove(i);
122                        continue; // Don't increment i since we removed an element
123                    }
124
125                    // Check if the rule matches
126                    if !(rule.matcher)(input) {
127                        // Rule doesn't match, this is an error in sequential mode
128                        panic!("In order matching was enforced but rule did not match {input:?}");
129                    }
130
131                    // Rule matches and is not exhausted, get the response
132                    if let Some(response) = rule.next_response(input) {
133                        matching_rule = Some(rule.clone());
134                        matching_response = Some(response);
135                    } else {
136                        // Rule is exhausted, remove it and try the next one
137                        rules.remove(i);
138                        continue; // Don't increment i since we removed an element
139                    }
140
141                    // We found a matching rule and got a response, so we're done
142                    break;
143                }
144            }
145            RuleMode::MatchAny => {
146                // Find any matching rule with a response
147                for rule in rules.iter() {
148                    // Skip exhausted rules
149                    if rule.is_exhausted() {
150                        continue;
151                    }
152
153                    if (rule.matcher)(input) {
154                        if let Some(response) = rule.next_response(input) {
155                            matching_rule = Some(rule.clone());
156                            matching_response = Some(response);
157                            break;
158                        }
159                    }
160                }
161            }
162        };
163
164        match (matching_rule, matching_response) {
165            (Some(rule), Some(response)) => {
166                // Store the rule in the config bag
167                cfg.interceptor_state().store_put(ActiveRule(rule));
168                // store the response on the interceptor (because going
169                // through interceptor context requires the type to impl Clone)
170                let mut active_resp = self.active_response.lock().unwrap();
171                let _ = (*active_resp).replace(response);
172            }
173            _ => {
174                // No matching rule or no response
175                if self.must_match {
176                    panic!(
177                        "must_match was enabled but no rules matched or all rules were exhausted for {input:?}"
178                    );
179                }
180            }
181        }
182
183        Ok(())
184    }
185
186    fn modify_before_transmit(
187        &self,
188        context: &mut BeforeTransmitInterceptorContextMut<'_>,
189        _runtime_components: &RuntimeComponents,
190        cfg: &mut ConfigBag,
191    ) -> Result<(), BoxError> {
192        let mut state = self.active_response.lock().unwrap();
193        let mut active_response = (*state).take();
194        if active_response.is_none() {
195            // in the case of retries we try to get the next response if it has been consumed
196            if let Some(active_rule) = cfg.load::<ActiveRule>() {
197                // During retries, input is not available in modify_before_transmit.
198                // For HTTP status responses that don't use the input, we can use a dummy input.
199                let dummy_input = Input::doesnt_matter();
200                let next_resp = active_rule.0.next_response(&dummy_input);
201                active_response = next_resp;
202            }
203        }
204
205        if let Some(resp) = active_response {
206            match resp {
207                // place the http response into the extensions and let the HTTP client return it
208                MockResponse::Http(http_resp) => {
209                    context
210                        .request_mut()
211                        .add_extension(MockHttpResponse(Arc::new(http_resp)));
212                }
213                _ => {
214                    // put it back for modeled output/errors
215                    let _ = (*state).replace(resp);
216                }
217            }
218        }
219
220        Ok(())
221    }
222
223    fn modify_before_attempt_completion(
224        &self,
225        context: &mut FinalizerInterceptorContextMut<'_>,
226        _runtime_components: &RuntimeComponents,
227        _cfg: &mut ConfigBag,
228    ) -> Result<(), BoxError> {
229        // Handle modeled responses
230        let mut state = self.active_response.lock().unwrap();
231        let active_response = (*state).take();
232        if let Some(resp) = active_response {
233            match resp {
234                MockResponse::Output(output) => {
235                    context.inner_mut().set_output_or_error(Ok(output));
236                }
237                MockResponse::Error(error) => {
238                    context
239                        .inner_mut()
240                        .set_output_or_error(Err(OrchestratorError::operation(error)));
241                }
242                MockResponse::Http(_) => {
243                    // HTTP responses are handled by the mock HTTP client
244                }
245            }
246        }
247
248        Ok(())
249    }
250}
251
252/// Extension for storing mock HTTP responses in request extensions
253#[derive(Clone)]
254struct MockHttpResponse(Arc<HttpResponse>);
255
256/// Create a mock HTTP client that works with the interceptor using existing utilities
257pub fn create_mock_http_client() -> SharedHttpClient {
258    infallible_client_fn(|mut req| {
259        // Try to get the mock HTTP response generator from the extensions
260        if let Some(mock_response) = req.extensions_mut().remove::<MockHttpResponse>() {
261            let http_resp =
262                Arc::try_unwrap(mock_response.0).expect("mock HTTP response has single reference");
263            return http_resp.try_into_http1x().unwrap();
264        }
265
266        // Default dummy response if no mock response is defined
267        http::Response::builder()
268            .status(418)
269            .body(SdkBody::from("Mock HTTP client dummy response"))
270            .unwrap()
271    })
272}
273
274#[cfg(test)]
275mod tests {
276    use aws_smithy_async::rt::sleep::{SharedAsyncSleep, TokioSleep};
277    use aws_smithy_runtime::client::orchestrator::operation::Operation;
278    use aws_smithy_runtime::client::retries::classifiers::HttpStatusCodeClassifier;
279    use aws_smithy_runtime_api::client::orchestrator::{
280        HttpRequest, HttpResponse, OrchestratorError,
281    };
282    use aws_smithy_runtime_api::client::result::SdkError;
283    use aws_smithy_runtime_api::http::StatusCode;
284    use aws_smithy_types::body::SdkBody;
285    use aws_smithy_types::retry::RetryConfig;
286    use aws_smithy_types::timeout::TimeoutConfig;
287
288    use crate::{create_mock_http_client, MockResponseInterceptor, RuleBuilder, RuleMode};
289    use std::time::Duration;
290
291    // Simple test input and output types
292    #[derive(Debug)]
293    struct TestInput {
294        bucket: String,
295        key: String,
296    }
297    impl TestInput {
298        fn new(bucket: &str, key: &str) -> Self {
299            Self {
300                bucket: bucket.to_string(),
301                key: key.to_string(),
302            }
303        }
304    }
305
306    #[derive(Debug, PartialEq)]
307    struct TestOutput {
308        content: String,
309    }
310
311    impl TestOutput {
312        fn new(content: &str) -> Self {
313            Self {
314                content: content.to_string(),
315            }
316        }
317    }
318
319    #[derive(Debug)]
320    struct TestError {
321        message: String,
322    }
323
324    impl TestError {
325        fn new(message: &str) -> Self {
326            Self {
327                message: message.to_string(),
328            }
329        }
330    }
331
332    impl std::fmt::Display for TestError {
333        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334            write!(f, "{}", self.message)
335        }
336    }
337
338    impl std::error::Error for TestError {}
339
340    // Helper function to create a RuleBuilder with proper type hints
341    fn create_rule_builder() -> RuleBuilder<TestInput, TestOutput, TestError> {
342        RuleBuilder::new_from_mock(
343            || TestInput {
344                bucket: "".to_string(),
345                key: "".to_string(),
346            },
347            || {
348                let fut: std::future::Ready<Result<TestOutput, SdkError<TestError, HttpResponse>>> =
349                    std::future::ready(Ok(TestOutput {
350                        content: "".to_string(),
351                    }));
352                fut
353            },
354        )
355    }
356
357    // Helper function to create an Operation with common configuration
358    fn create_test_operation(
359        interceptor: MockResponseInterceptor,
360        enable_retries: bool,
361    ) -> Operation<TestInput, TestOutput, TestError> {
362        let builder = Operation::builder()
363            .service_name("test")
364            .operation_name("test")
365            .http_client(create_mock_http_client())
366            .endpoint_url("http://localhost:1234")
367            .no_auth()
368            .sleep_impl(SharedAsyncSleep::new(TokioSleep::new()))
369            .timeout_config(TimeoutConfig::disabled())
370            .interceptor(interceptor)
371            .serializer(|input: TestInput| {
372                let mut request = HttpRequest::new(SdkBody::empty());
373                request
374                    .set_uri(format!("/{}/{}", input.bucket, input.key))
375                    .expect("valid URI");
376                Ok(request)
377            })
378            .deserializer::<TestOutput, TestError>(|response| {
379                if response.status().is_success() {
380                    let body = std::str::from_utf8(response.body().bytes().unwrap())
381                        .unwrap_or("empty body")
382                        .to_string();
383                    Ok(TestOutput { content: body })
384                } else {
385                    Err(OrchestratorError::operation(TestError {
386                        message: format!("Error: {}", response.status()),
387                    }))
388                }
389            });
390
391        if enable_retries {
392            let retry_config = RetryConfig::standard()
393                .with_max_attempts(5)
394                .with_initial_backoff(Duration::from_millis(1))
395                .with_max_backoff(Duration::from_millis(5));
396
397            builder
398                .retry_classifier(HttpStatusCodeClassifier::default())
399                .standard_retry(&retry_config)
400                .build()
401        } else {
402            builder.no_retry().build()
403        }
404    }
405
406    #[tokio::test]
407    async fn test_retry_sequence() {
408        // Create a rule with repeated error responses followed by success
409        let rule = create_rule_builder()
410            .match_requests(|input| input.bucket == "test-bucket" && input.key == "test-key")
411            .sequence()
412            .http_status(503, None)
413            .times(2)
414            .output(|| TestOutput::new("success after retries"))
415            .build();
416
417        // Create an interceptor with the rule
418        let interceptor = MockResponseInterceptor::new()
419            .rule_mode(RuleMode::Sequential)
420            .with_rule(&rule);
421
422        let operation = create_test_operation(interceptor, true);
423
424        // Make a single request - it should automatically retry through the sequence
425        let result = operation
426            .invoke(TestInput::new("test-bucket", "test-key"))
427            .await;
428
429        // Should succeed with the final output after retries
430        assert!(
431            result.is_ok(),
432            "Expected success but got error: {:?}",
433            result.err()
434        );
435        assert_eq!(
436            result.unwrap(),
437            TestOutput {
438                content: "success after retries".to_string()
439            }
440        );
441
442        // Verify the rule was used the expected number of times (all 4 responses: 2 errors + 1 success)
443        assert_eq!(rule.num_calls(), 3);
444    }
445
446    #[tokio::test]
447    async fn test_compute_output() {
448        // Create a rule that computes its responses based off of input data
449        let rule = create_rule_builder()
450            .match_requests(|input| input.bucket == "test-bucket" && input.key == "test-key")
451            .then_compute_output(|input| TestOutput {
452                content: format!("{}.{}", input.bucket, input.key),
453            });
454
455        // Create an interceptor with the rule
456        let interceptor = MockResponseInterceptor::new()
457            .rule_mode(RuleMode::Sequential)
458            .with_rule(&rule);
459
460        let operation = create_test_operation(interceptor, true);
461
462        let result = operation
463            .invoke(TestInput::new("test-bucket", "test-key"))
464            .await;
465
466        // Should succeed with the output derived from input
467        assert!(
468            result.is_ok(),
469            "Expected success but got error: {:?}",
470            result.err()
471        );
472        assert_eq!(
473            result.unwrap(),
474            TestOutput {
475                content: "test-bucket.test-key".to_string()
476            }
477        );
478
479        // Verify the rule was used once, no retries
480        assert_eq!(rule.num_calls(), 1);
481    }
482
483    #[should_panic(
484        expected = "must_match was enabled but no rules matched or all rules were exhausted for"
485    )]
486    #[tokio::test]
487    async fn test_exhausted_rules_sequential() {
488        // Create a rule with a single response
489        let rule = create_rule_builder().then_output(|| TestOutput::new("only response"));
490
491        // Create an interceptor with the rule
492        let interceptor = MockResponseInterceptor::new()
493            .rule_mode(RuleMode::Sequential)
494            .with_rule(&rule);
495
496        let operation = create_test_operation(interceptor, false);
497
498        // First call should succeed
499        let result1 = operation
500            .invoke(TestInput::new("test-bucket", "test-key"))
501            .await;
502        assert!(result1.is_ok());
503
504        // Second call should panic because the rules are exhausted
505        let _result2 = operation
506            .invoke(TestInput::new("test-bucket", "test-key"))
507            .await;
508    }
509
510    #[tokio::test]
511    async fn test_rule_mode_match_any() {
512        // Create two rules with different matchers
513        let rule1 = create_rule_builder()
514            .match_requests(|input| input.bucket == "bucket1")
515            .then_output(|| TestOutput::new("response1"));
516
517        let rule2 = create_rule_builder()
518            .match_requests(|input| input.bucket == "bucket2")
519            .then_output(|| TestOutput::new("response2"));
520
521        // Create an interceptor with both rules in MatchAny mode
522        let interceptor = MockResponseInterceptor::new()
523            .rule_mode(RuleMode::MatchAny)
524            .with_rule(&rule1)
525            .with_rule(&rule2);
526
527        let operation = create_test_operation(interceptor, false);
528
529        // Call with bucket1 should match rule1
530        let result1 = operation
531            .invoke(TestInput::new("bucket1", "test-key"))
532            .await;
533        assert!(result1.is_ok());
534        assert_eq!(result1.unwrap(), TestOutput::new("response1"));
535
536        // Call with bucket2 should match rule2
537        let result2 = operation
538            .invoke(TestInput::new("bucket2", "test-key"))
539            .await;
540        assert!(result2.is_ok());
541        assert_eq!(result2.unwrap(), TestOutput::new("response2"));
542
543        // Verify the rules were used the expected number of times
544        assert_eq!(rule1.num_calls(), 1);
545        assert_eq!(rule2.num_calls(), 1);
546
547        // Calling with bucket1 again should match rule1 a second time
548        let result1 = operation
549            .invoke(TestInput::new("bucket1", "test-key"))
550            .await;
551        assert!(result1.is_ok());
552        assert_eq!(result1.unwrap(), TestOutput::new("response1"));
553        assert_eq!(rule1.num_calls(), 2);
554    }
555
556    #[tokio::test]
557    async fn test_mixed_response_types() {
558        // Create a rule with all three types of responses
559        let rule = create_rule_builder()
560            .sequence()
561            .output(|| TestOutput::new("first output"))
562            .error(|| TestError::new("expected error"))
563            .http_response(|| {
564                HttpResponse::new(
565                    StatusCode::try_from(200).unwrap(),
566                    SdkBody::from("http response"),
567                )
568            })
569            .build();
570
571        // Create an interceptor with the rule
572        let interceptor = MockResponseInterceptor::new()
573            .rule_mode(RuleMode::Sequential)
574            .with_rule(&rule);
575
576        let operation = create_test_operation(interceptor, false);
577
578        // First call should return the modeled output
579        let result1 = operation
580            .invoke(TestInput::new("test-bucket", "test-key"))
581            .await;
582        assert!(result1.is_ok());
583        assert_eq!(result1.unwrap(), TestOutput::new("first output"));
584
585        // Second call should return the modeled error
586        let result2 = operation
587            .invoke(TestInput::new("test-bucket", "test-key"))
588            .await;
589        assert!(result2.is_err());
590        let sdk_err = result2.unwrap_err();
591        let err = sdk_err.as_service_error().expect("expected service error");
592        assert_eq!(err.to_string(), "expected error");
593
594        // Third call should return the HTTP response
595        let result3 = operation
596            .invoke(TestInput::new("test-bucket", "test-key"))
597            .await;
598        assert!(result3.is_ok());
599        assert_eq!(result3.unwrap(), TestOutput::new("http response"));
600
601        // Verify the rule was used the expected number of times
602        assert_eq!(rule.num_calls(), 3);
603    }
604    #[tokio::test]
605    async fn test_exhausted_sequence_match_any() {
606        // Create a rule with a sequence that will be exhausted
607        let rule = create_rule_builder()
608            .match_requests(|input| input.bucket == "bucket-1")
609            .sequence()
610            .output(|| TestOutput::new("response 1"))
611            .output(|| TestOutput::new("response 2"))
612            .build();
613
614        // Create another rule to use after the first one is exhausted
615        let fallback_rule =
616            create_rule_builder().then_output(|| TestOutput::new("fallback response"));
617
618        // Create an interceptor with both rules
619        let interceptor = MockResponseInterceptor::new()
620            .rule_mode(RuleMode::MatchAny)
621            .with_rule(&rule)
622            .with_rule(&fallback_rule);
623
624        let operation = create_test_operation(interceptor, false);
625
626        // First two calls should use the first rule
627        let result1 = operation
628            .invoke(TestInput::new("bucket-1", "test-key"))
629            .await;
630        assert!(result1.is_ok());
631        assert_eq!(result1.unwrap(), TestOutput::new("response 1"));
632
633        // second should use our fallback rule
634        let result2 = operation
635            .invoke(TestInput::new("other-bucket", "test-key"))
636            .await;
637        assert!(result2.is_ok());
638        assert_eq!(result2.unwrap(), TestOutput::new("fallback response"));
639
640        // Third call should use the first rule again and exhaust it
641        let result3 = operation
642            .invoke(TestInput::new("bucket-1", "test-key"))
643            .await;
644        assert!(result3.is_ok());
645        assert_eq!(result3.unwrap(), TestOutput::new("response 2"));
646
647        // first rule is exhausted so the matcher shouldn't matter and we should hit our fallback rule
648        let result4 = operation
649            .invoke(TestInput::new("bucket-1", "test-key"))
650            .await;
651        assert!(result4.is_ok());
652        assert_eq!(result4.unwrap(), TestOutput::new("fallback response"));
653
654        // Verify the rules were used the expected number of times
655        assert_eq!(rule.num_calls(), 2);
656        assert_eq!(fallback_rule.num_calls(), 2);
657    }
658
659    #[tokio::test]
660    async fn test_exhausted_sequence_sequential() {
661        // Create a rule with a sequence that will be exhausted
662        let rule = create_rule_builder()
663            .sequence()
664            .output(|| TestOutput::new("response 1"))
665            .output(|| TestOutput::new("response 2"))
666            .build();
667
668        // Create another rule to use after the first one is exhausted
669        let fallback_rule =
670            create_rule_builder().then_output(|| TestOutput::new("fallback response"));
671
672        // Create an interceptor with both rules
673        let interceptor = MockResponseInterceptor::new()
674            .rule_mode(RuleMode::Sequential)
675            .with_rule(&rule)
676            .with_rule(&fallback_rule);
677
678        let operation = create_test_operation(interceptor, false);
679
680        // First two calls should use the first rule
681        let result1 = operation
682            .invoke(TestInput::new("test-bucket", "test-key"))
683            .await;
684        assert!(result1.is_ok());
685        assert_eq!(result1.unwrap(), TestOutput::new("response 1"));
686
687        let result2 = operation
688            .invoke(TestInput::new("test-bucket", "test-key"))
689            .await;
690        assert!(result2.is_ok());
691        assert_eq!(result2.unwrap(), TestOutput::new("response 2"));
692
693        // Third call should use the fallback rule
694        let result3 = operation
695            .invoke(TestInput::new("test-bucket", "test-key"))
696            .await;
697        assert!(result3.is_ok());
698        assert_eq!(result3.unwrap(), TestOutput::new("fallback response"));
699
700        // Verify the rules were used the expected number of times
701        assert_eq!(rule.num_calls(), 2);
702        assert_eq!(fallback_rule.num_calls(), 1);
703    }
704
705    #[tokio::test]
706    async fn test_concurrent_usage() {
707        use std::sync::Arc;
708        use tokio::task;
709
710        // Create a rule with multiple responses
711        let rule = Arc::new(
712            create_rule_builder()
713                .sequence()
714                .output(|| TestOutput::new("response 1"))
715                .output(|| TestOutput::new("response 2"))
716                .output(|| TestOutput::new("response 3"))
717                .build(),
718        );
719
720        // Create an interceptor with the rule
721        let interceptor = MockResponseInterceptor::new()
722            .rule_mode(RuleMode::Sequential)
723            .with_rule(&rule);
724
725        let operation = Arc::new(create_test_operation(interceptor, false));
726
727        // Spawn multiple tasks that use the operation concurrently
728        let mut handles = vec![];
729        for i in 0..3 {
730            let op = operation.clone();
731            let handle = task::spawn(async move {
732                let result = op
733                    .invoke(TestInput::new(&format!("bucket-{i}"), "test-key"))
734                    .await;
735                result.unwrap()
736            });
737            handles.push(handle);
738        }
739
740        // Wait for all tasks to complete
741        let mut results = vec![];
742        for handle in handles {
743            results.push(handle.await.unwrap());
744        }
745
746        // Sort the results to make the test deterministic
747        results.sort_by(|a, b| a.content.cmp(&b.content));
748
749        // Verify we got all three responses
750        assert_eq!(results.len(), 3);
751        assert_eq!(results[0], TestOutput::new("response 1"));
752        assert_eq!(results[1], TestOutput::new("response 2"));
753        assert_eq!(results[2], TestOutput::new("response 3"));
754
755        // Verify the rule was used the expected number of times
756        assert_eq!(rule.num_calls(), 3);
757    }
758
759    #[tokio::test]
760    async fn test_sequential_rule_removal() {
761        // Create a rule that matches only when key != "correct-key"
762        let rule1 = create_rule_builder()
763            .match_requests(|input| input.bucket == "test-bucket" && input.key != "correct-key")
764            .then_http_response(|| {
765                HttpResponse::new(
766                    StatusCode::try_from(404).unwrap(),
767                    SdkBody::from("not found"),
768                )
769            });
770
771        // Create a rule that matches only when key == "correct-key"
772        let rule2 = create_rule_builder()
773            .match_requests(|input| input.bucket == "test-bucket" && input.key == "correct-key")
774            .then_output(|| TestOutput::new("success"));
775
776        // Create an interceptor with both rules in Sequential mode
777        let interceptor = MockResponseInterceptor::new()
778            .rule_mode(RuleMode::Sequential)
779            .with_rule(&rule1)
780            .with_rule(&rule2);
781
782        let operation = create_test_operation(interceptor, true);
783
784        // First call with key="foo" should match rule1
785        let result1 = operation.invoke(TestInput::new("test-bucket", "foo")).await;
786        assert!(result1.is_err());
787        assert_eq!(rule1.num_calls(), 1);
788
789        // Second call with key="correct-key" should match rule2
790        // But this will fail if rule1 is not removed after being used
791        let result2 = operation
792            .invoke(TestInput::new("test-bucket", "correct-key"))
793            .await;
794
795        // This should succeed, rule1 doesn't match but should have been removed
796        assert!(result2.is_ok());
797        assert_eq!(result2.unwrap(), TestOutput::new("success"));
798        assert_eq!(rule2.num_calls(), 1);
799    }
800
801    #[tokio::test]
802    async fn test_simple_rule_in_match_any_mode() {
803        let rule = create_rule_builder().then_output(|| TestOutput::new("simple response"));
804
805        let interceptor = MockResponseInterceptor::new()
806            .rule_mode(RuleMode::MatchAny)
807            .with_rule(&rule);
808
809        let operation = create_test_operation(interceptor, false);
810
811        for i in 0..5 {
812            let result = operation
813                .invoke(TestInput::new("test-bucket", "test-key"))
814                .await;
815            assert!(result.is_ok(), "Call {i} should succeed");
816            assert_eq!(result.unwrap(), TestOutput::new("simple response"));
817        }
818        assert_eq!(rule.num_calls(), 5);
819        assert!(!rule.is_exhausted());
820    }
821
822    #[tokio::test]
823    async fn test_simple_rule_in_sequential_mode() {
824        let rule1 = create_rule_builder().then_output(|| TestOutput::new("first response"));
825        let rule2 = create_rule_builder().then_output(|| TestOutput::new("second response"));
826
827        let interceptor = MockResponseInterceptor::new()
828            .rule_mode(RuleMode::Sequential)
829            .with_rule(&rule1)
830            .with_rule(&rule2);
831
832        let operation = create_test_operation(interceptor, false);
833
834        let result1 = operation
835            .invoke(TestInput::new("test-bucket", "test-key"))
836            .await;
837        assert!(result1.is_ok());
838        assert_eq!(result1.unwrap(), TestOutput::new("first response"));
839
840        // Second call should use rule2 (rule1 should be removed after one use in Sequential mode)
841        let result2 = operation
842            .invoke(TestInput::new("test-bucket", "test-key"))
843            .await;
844        assert!(result2.is_ok());
845        assert_eq!(result2.unwrap(), TestOutput::new("second response"));
846
847        assert_eq!(rule1.num_calls(), 1);
848        assert_eq!(rule2.num_calls(), 1);
849    }
850
851    #[tokio::test]
852    async fn test_repeatedly_method() {
853        let rule = create_rule_builder()
854            .sequence()
855            .output(|| TestOutput::new("first response"))
856            .output(|| TestOutput::new("repeated response"))
857            .repeatedly()
858            .build();
859
860        let interceptor = MockResponseInterceptor::new()
861            .rule_mode(RuleMode::Sequential)
862            .with_rule(&rule);
863
864        let operation = create_test_operation(interceptor, false);
865
866        let result1 = operation
867            .invoke(TestInput::new("test-bucket", "test-key"))
868            .await;
869        assert!(result1.is_ok());
870        assert_eq!(result1.unwrap(), TestOutput::new("first response"));
871
872        // all subsequent calls should return "repeated response"
873        for i in 0..10 {
874            let result = operation
875                .invoke(TestInput::new("test-bucket", "test-key"))
876                .await;
877            assert!(result.is_ok(), "Call {i} should succeed");
878            assert_eq!(result.unwrap(), TestOutput::new("repeated response"));
879        }
880        assert_eq!(rule.num_calls(), 11);
881        assert!(!rule.is_exhausted());
882    }
883
884    #[should_panic(expected = "times(n) called before adding a response to the sequence")]
885    #[test]
886    fn test_times_validation() {
887        // This should panic because times() is called before adding any responses
888        let _rule = create_rule_builder()
889            .sequence()
890            .times(3)
891            .output(|| TestOutput::new("response"))
892            .build();
893    }
894
895    #[should_panic(expected = "repeatedly() called before adding a response to the sequence")]
896    #[test]
897    fn test_repeatedly_validation() {
898        // This should panic because repeatedly() is called before adding any responses
899        let _rule = create_rule_builder().sequence().repeatedly().build();
900    }
901
902    #[test]
903    fn test_total_responses_overflow() {
904        // Create a rule with a large number of repetitions to test overflow handling
905        let rule = create_rule_builder()
906            .sequence()
907            .output(|| TestOutput::new("response"))
908            .times(usize::MAX / 2)
909            .output(|| TestOutput::new("another response"))
910            .repeatedly()
911            .build();
912        assert_eq!(rule.max_responses, usize::MAX);
913    }
914}