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