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