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}
93
94/// Ordered no-allocation mock implementation of [`BlockingTransport`].
95pub struct MockTransport<'a> {
96    exchanges: &'a [MockExchange<'a>],
97    cursor: usize,
98}
99
100impl<'a> MockTransport<'a> {
101    /// Creates a mock over an ordered exchange slice.
102    #[must_use]
103    pub const fn new(exchanges: &'a [MockExchange<'a>]) -> Self {
104        Self {
105            exchanges,
106            cursor: 0,
107        }
108    }
109
110    /// Returns the number of exchanges not yet consumed.
111    #[must_use]
112    pub const fn remaining(&self) -> usize {
113        self.exchanges.len().saturating_sub(self.cursor)
114    }
115
116    /// Reports whether every expected exchange was consumed.
117    #[must_use]
118    pub const fn is_complete(&self) -> bool {
119        self.remaining() == 0
120    }
121
122    fn send_inner<'buffer>(
123        &mut self,
124        request: TransportRequest<'_>,
125        response_body: &'buffer mut [u8],
126    ) -> Result<TransportResponse<'buffer>, MockError> {
127        let exchange = self
128            .exchanges
129            .get(self.cursor)
130            .ok_or(MockError::Exhausted)?;
131        if request.method() != exchange.request.method() {
132            return Err(MockError::MethodMismatch);
133        }
134        if request.target() != exchange.request.target() {
135            return Err(MockError::TargetMismatch);
136        }
137        if request.body() != exchange.request.body() {
138            return Err(MockError::BodyMismatch);
139        }
140        let next_cursor = self
141            .cursor
142            .checked_add(1)
143            .ok_or(MockError::CursorOverflow)?;
144        let body_len =
145            exchange
146                .response
147                .body()
148                .write_to(response_body)
149                .map_err(|error| match error {
150                    FixtureBodyError::OutputTooSmall | FixtureBodyError::TooLarge => {
151                        MockError::ResponseBufferTooSmall
152                    }
153                })?;
154        let initialized = response_body
155            .get(..body_len)
156            .ok_or(MockError::ResponseBufferTooSmall)?;
157        self.cursor = next_cursor;
158        Ok(TransportResponse::new(
159            exchange.response.status(),
160            initialized,
161        ))
162    }
163}
164
165impl BlockingTransport for MockTransport<'_> {
166    type Error = MockError;
167
168    fn send<'buffer>(
169        &mut self,
170        request: TransportRequest<'_>,
171        response_body: &'buffer mut [u8],
172    ) -> Result<TransportResponse<'buffer>, Self::Error> {
173        self.send_inner(request, response_body)
174    }
175}
176
177impl AsyncTransport for MockTransport<'_> {
178    type Error = MockError;
179
180    async fn send<'transport, 'request, 'buffer>(
181        &'transport mut self,
182        request: TransportRequest<'request>,
183        response_body: &'buffer mut [u8],
184    ) -> Result<TransportResponse<'buffer>, Self::Error>
185    where
186        'request: 'transport,
187        'buffer: 'transport,
188    {
189        self.send_inner(request, response_body)
190    }
191}
192
193impl fmt::Debug for MockTransport<'_> {
194    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195        formatter
196            .debug_struct("MockTransport")
197            .field("remaining", &self.remaining())
198            .finish_non_exhaustive()
199    }
200}