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