Skip to main content

cloud_sdk_testkit/
mock.rs

1//! Deterministic no-allocation mock transport.
2
3use core::fmt;
4use core::sync::atomic::{AtomicUsize, Ordering};
5
6use cloud_sdk::Method;
7use cloud_sdk::transport::{
8    AsyncTransport, BlockingTransport, RequestTarget, TransportRequest, TransportResponse,
9};
10
11use crate::{FixtureBodyError, ResponseFixture};
12
13/// Expected request fields for one mock exchange.
14#[derive(Clone, Copy, Eq, PartialEq)]
15pub struct ExpectedRequest<'a> {
16    method: Method,
17    target: RequestTarget<'a>,
18    body: &'a [u8],
19}
20
21impl<'a> ExpectedRequest<'a> {
22    /// Creates a bodyless expected request.
23    #[must_use]
24    pub const fn new(method: Method, target: RequestTarget<'a>) -> Self {
25        Self {
26            method,
27            target,
28            body: &[],
29        }
30    }
31
32    /// Adds the exact expected request body.
33    #[must_use]
34    pub const fn with_body(mut self, body: &'a [u8]) -> Self {
35        self.body = body;
36        self
37    }
38
39    const fn method(self) -> Method {
40        self.method
41    }
42
43    const fn target(self) -> RequestTarget<'a> {
44        self.target
45    }
46
47    const fn body(self) -> &'a [u8] {
48        self.body
49    }
50}
51
52impl fmt::Debug for ExpectedRequest<'_> {
53    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54        formatter
55            .debug_struct("ExpectedRequest")
56            .field("method", &self.method)
57            .field("target", &"[redacted]")
58            .field("body", &"[redacted]")
59            .finish()
60    }
61}
62
63/// One expected request and deterministic response.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub struct MockExchange<'a> {
66    request: ExpectedRequest<'a>,
67    response: ResponseFixture<'a>,
68}
69
70impl<'a> MockExchange<'a> {
71    /// Creates one mock exchange.
72    #[must_use]
73    pub const fn new(request: ExpectedRequest<'a>, response: ResponseFixture<'a>) -> Self {
74        Self { request, response }
75    }
76}
77
78/// Deterministic mock transport failure.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum MockError {
81    /// No expected exchange remains.
82    Exhausted,
83    /// HTTP method differs from the next expectation.
84    MethodMismatch,
85    /// Request target differs from the next expectation.
86    TargetMismatch,
87    /// Request body differs from the next expectation.
88    BodyMismatch,
89    /// Caller response buffer cannot hold the complete fixture body.
90    ResponseBufferTooSmall,
91    /// Internal cursor arithmetic failed closed.
92    CursorOverflow,
93    /// Another request changed the ordered cursor during this exchange.
94    ConcurrentRequest,
95    /// Fixture metadata could not be represented by the core transport.
96    InvalidFixtureMetadata,
97}
98
99impl_static_error!(MockError,
100    Self::Exhausted => "mock transport has no expected exchange remaining",
101    Self::MethodMismatch => "mock request method differs from expectation",
102    Self::TargetMismatch => "mock request target differs from expectation",
103    Self::BodyMismatch => "mock request body differs from expectation",
104    Self::ResponseBufferTooSmall => "mock response buffer is too small",
105    Self::CursorOverflow => "mock transport cursor overflowed",
106    Self::ConcurrentRequest => "mock transport cursor changed concurrently",
107    Self::InvalidFixtureMetadata => "mock fixture metadata is invalid",
108);
109
110/// Ordered no-allocation mock implementation of [`BlockingTransport`].
111pub struct MockTransport<'a> {
112    exchanges: &'a [MockExchange<'a>],
113    cursor: AtomicUsize,
114}
115
116impl<'a> MockTransport<'a> {
117    /// Creates a mock over an ordered exchange slice.
118    #[must_use]
119    pub const fn new(exchanges: &'a [MockExchange<'a>]) -> Self {
120        Self {
121            exchanges,
122            cursor: AtomicUsize::new(0),
123        }
124    }
125
126    /// Returns the number of exchanges not yet consumed.
127    #[must_use]
128    pub fn remaining(&self) -> usize {
129        self.exchanges
130            .len()
131            .saturating_sub(self.cursor.load(Ordering::Acquire))
132    }
133
134    /// Reports whether every expected exchange was consumed.
135    #[must_use]
136    pub fn is_complete(&self) -> bool {
137        self.remaining() == 0
138    }
139
140    fn send_inner<'buffer>(
141        &self,
142        request: TransportRequest<'_>,
143        response_body: &'buffer mut [u8],
144    ) -> Result<TransportResponse<'buffer>, MockError> {
145        let cursor = self.cursor.load(Ordering::Acquire);
146        let exchange = self.exchanges.get(cursor).ok_or(MockError::Exhausted)?;
147        if request.method() != exchange.request.method() {
148            return Err(MockError::MethodMismatch);
149        }
150        if request.target() != exchange.request.target() {
151            return Err(MockError::TargetMismatch);
152        }
153        if request.body() != exchange.request.body() {
154            return Err(MockError::BodyMismatch);
155        }
156        let next_cursor = cursor.checked_add(1).ok_or(MockError::CursorOverflow)?;
157        let body_len =
158            exchange
159                .response
160                .body()
161                .write_to(response_body)
162                .map_err(|error| match error {
163                    FixtureBodyError::OutputTooSmall | FixtureBodyError::TooLarge => {
164                        MockError::ResponseBufferTooSmall
165                    }
166                })?;
167        let initialized = response_body
168            .get(..body_len)
169            .ok_or(MockError::ResponseBufferTooSmall)?;
170        let response = TransportResponse::new(exchange.response.status(), initialized);
171        let rate_limit = exchange
172            .response
173            .rate_limit()
174            .map(|value| value.into_rate_limit())
175            .transpose()
176            .map_err(|_| MockError::InvalidFixtureMetadata)?;
177        let response = rate_limit.map_or(response, |value| response.with_rate_limit(value));
178        self.cursor
179            .compare_exchange(cursor, next_cursor, Ordering::AcqRel, Ordering::Acquire)
180            .map_err(|_| MockError::ConcurrentRequest)?;
181        Ok(response)
182    }
183}
184
185impl BlockingTransport for MockTransport<'_> {
186    type Error = MockError;
187
188    fn send<'buffer>(
189        &self,
190        request: TransportRequest<'_>,
191        response_body: &'buffer mut [u8],
192    ) -> Result<TransportResponse<'buffer>, Self::Error> {
193        self.send_inner(request, response_body)
194    }
195}
196
197impl AsyncTransport for MockTransport<'_> {
198    type Error = MockError;
199
200    async fn send<'transport, 'request, 'buffer>(
201        &'transport self,
202        request: TransportRequest<'request>,
203        response_body: &'buffer mut [u8],
204    ) -> Result<TransportResponse<'buffer>, Self::Error>
205    where
206        'request: 'transport,
207        'buffer: 'transport,
208    {
209        self.send_inner(request, response_body)
210    }
211}
212
213impl fmt::Debug for MockTransport<'_> {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        formatter
216            .debug_struct("MockTransport")
217            .field("remaining", &self.remaining())
218            .finish_non_exhaustive()
219    }
220}