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