Skip to main content

cloud_sdk_testkit/
mock.rs

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