Skip to main content

remendo/client/
mock.rs

1//! Mock client for unit testing.
2//!
3//! Implements `SashikoApi` with canned responses that can be
4//! configured per test via `set_*()` methods.
5
6use crate::client::api::SashikoApi;
7use crate::client::error::ApiError;
8use crate::client::types::{ListParams, ReviewQuery};
9use crate::models::{
10    EmailMessage, MailingList, Paginated, PatchId, Patchset, PatchsetDetail, ServerStats,
11};
12use std::sync::Mutex;
13
14type MockResult<T> = Option<Result<T, String>>;
15
16/// A mock implementation of `SashikoApi` for testing.
17///
18/// Set canned responses via `set_*()` methods before calling
19/// the trait methods. Unset endpoints return a descriptive error.
20#[allow(clippy::struct_field_names)]
21pub struct MockClient {
22    patchsets_response: Mutex<MockResult<Paginated<Patchset>>>,
23    stats_response: Mutex<MockResult<ServerStats>>,
24    lists_response: Mutex<MockResult<Vec<MailingList>>>,
25    patch_detail_response: Mutex<MockResult<PatchsetDetail>>,
26    messages_response: Mutex<MockResult<Paginated<EmailMessage>>>,
27    message_detail_response: Mutex<MockResult<EmailMessage>>,
28}
29
30impl MockClient {
31    /// Create a new mock client with no canned responses.
32    #[must_use]
33    pub fn new() -> Self {
34        Self {
35            patchsets_response: Mutex::new(None),
36            stats_response: Mutex::new(None),
37            lists_response: Mutex::new(None),
38            patch_detail_response: Mutex::new(None),
39            messages_response: Mutex::new(None),
40            message_detail_response: Mutex::new(None),
41        }
42    }
43
44    /// Set the canned response for `patchsets()`.
45    pub fn set_patchsets(&self, result: Result<Paginated<Patchset>, String>) {
46        if let Ok(mut guard) = self.patchsets_response.lock() {
47            *guard = Some(result);
48        }
49    }
50
51    /// Set the canned response for `stats()`.
52    pub fn set_stats(&self, result: Result<ServerStats, String>) {
53        if let Ok(mut guard) = self.stats_response.lock() {
54            *guard = Some(result);
55        }
56    }
57
58    /// Set the canned response for `lists()`.
59    pub fn set_lists(&self, result: Result<Vec<MailingList>, String>) {
60        if let Ok(mut guard) = self.lists_response.lock() {
61            *guard = Some(result);
62        }
63    }
64
65    /// Set the canned response for `patch_detail()`.
66    pub fn set_patch_detail(&self, result: Result<PatchsetDetail, String>) {
67        if let Ok(mut guard) = self.patch_detail_response.lock() {
68            *guard = Some(result);
69        }
70    }
71
72    /// Set the canned response for `messages()`.
73    pub fn set_messages(&self, result: Result<Paginated<EmailMessage>, String>) {
74        if let Ok(mut guard) = self.messages_response.lock() {
75            *guard = Some(result);
76        }
77    }
78
79    /// Set the canned response for `message_detail()`.
80    pub fn set_message_detail(&self, result: Result<EmailMessage, String>) {
81        if let Ok(mut guard) = self.message_detail_response.lock() {
82            *guard = Some(result);
83        }
84    }
85
86    /// Extract a canned result, converting the String error to `ApiError`.
87    fn take_result<T: Clone>(lock: &Mutex<MockResult<T>>, endpoint: &str) -> Result<T, ApiError> {
88        let guard = lock
89            .lock()
90            .map_err(|e| ApiError::Configuration(format!("mock lock poisoned: {e}")))?;
91
92        match guard.as_ref() {
93            Some(Ok(val)) => Ok(val.clone()),
94            Some(Err(msg)) => Err(ApiError::Network {
95                source: msg.clone().into(),
96                remote: "mock".to_string(),
97            }),
98            None => Err(ApiError::Configuration(format!(
99                "mock: no canned response set for {endpoint}"
100            ))),
101        }
102    }
103}
104
105impl Default for MockClient {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111#[async_trait::async_trait]
112impl SashikoApi for MockClient {
113    async fn lists(&self) -> Result<Vec<MailingList>, ApiError> {
114        Self::take_result(&self.lists_response, "lists")
115    }
116
117    async fn patchsets(&self, _params: &ListParams) -> Result<Paginated<Patchset>, ApiError> {
118        Self::take_result(&self.patchsets_response, "patchsets")
119    }
120
121    async fn messages(&self, _params: &ListParams) -> Result<Paginated<EmailMessage>, ApiError> {
122        Self::take_result(&self.messages_response, "messages")
123    }
124
125    async fn patch_detail(&self, _id: &PatchId) -> Result<PatchsetDetail, ApiError> {
126        Self::take_result(&self.patch_detail_response, "patch_detail")
127    }
128
129    async fn patchset_summary(&self, _id: &PatchId) -> Result<PatchsetDetail, ApiError> {
130        Self::take_result(&self.patch_detail_response, "patchset_summary")
131    }
132
133    async fn message_detail(&self, _id: &PatchId) -> Result<EmailMessage, ApiError> {
134        Self::take_result(&self.message_detail_response, "message_detail")
135    }
136
137    async fn review(&self, _params: &ReviewQuery) -> Result<serde_json::Value, ApiError> {
138        Err(ApiError::Configuration(
139            "mock: review not implemented".to_string(),
140        ))
141    }
142
143    async fn review_log(&self, _params: &ReviewQuery) -> Result<serde_json::Value, ApiError> {
144        Err(ApiError::Configuration(
145            "mock: review_log not implemented".to_string(),
146        ))
147    }
148
149    async fn stats(&self) -> Result<ServerStats, ApiError> {
150        Self::take_result(&self.stats_response, "stats")
151    }
152
153    async fn stats_timeline(
154        &self,
155        _subsystem_id: Option<i64>,
156    ) -> Result<serde_json::Value, ApiError> {
157        Err(ApiError::Configuration(
158            "mock: stats_timeline not implemented".to_string(),
159        ))
160    }
161
162    async fn stats_reviews(&self) -> Result<serde_json::Value, ApiError> {
163        Err(ApiError::Configuration(
164            "mock: stats_reviews not implemented".to_string(),
165        ))
166    }
167
168    async fn stats_tools(&self) -> Result<serde_json::Value, ApiError> {
169        Err(ApiError::Configuration(
170            "mock: stats_tools not implemented".to_string(),
171        ))
172    }
173}
174
175#[cfg(test)]
176#[allow(clippy::expect_used)]
177mod tests {
178    use super::*;
179    use crate::models::Patchset;
180
181    #[tokio::test]
182    async fn mock_returns_canned_patchsets() {
183        let mock = MockClient::new();
184        let patchsets = Paginated {
185            items: vec![Patchset::fixture()],
186            total: 1,
187            page: 1,
188            per_page: 50,
189        };
190        mock.set_patchsets(Ok(patchsets));
191
192        let result = mock.patchsets(&ListParams::default()).await;
193        assert!(result.is_ok());
194        assert_eq!(result.expect("patchsets").items.len(), 1);
195    }
196
197    #[tokio::test]
198    async fn mock_returns_canned_error() {
199        let mock = MockClient::new();
200        mock.set_patchsets(Err("connection refused".to_string()));
201
202        let result = mock.patchsets(&ListParams::default()).await;
203        assert!(result.is_err());
204    }
205
206    #[tokio::test]
207    async fn mock_returns_error_when_not_set() {
208        let mock = MockClient::new();
209        let result = mock.patchsets(&ListParams::default()).await;
210        assert!(result.is_err());
211        assert!(matches!(result, Err(ApiError::Configuration(_))));
212    }
213
214    #[tokio::test]
215    async fn mock_stats() {
216        let mock = MockClient::new();
217        mock.set_stats(Ok(ServerStats {
218            status: "ok".to_string(),
219            version: "0.1.0".to_string(),
220            pending: 5,
221            reviewing: 2,
222            messages: 1000,
223            patchsets: 500,
224        }));
225
226        let stats = mock.stats().await.expect("stats");
227        assert_eq!(stats.status, "ok");
228        assert_eq!(stats.pending, 5);
229    }
230}