Skip to main content

max / synckit

10.7 KB · 316 lines History Blame Raw
1 //! The mock harness: a wiremock server, the clients built against it, and the
2 //! request inspection every module was hand-rolling.
3 //!
4 //! What this replaces is a five-line incantation. Mounting one route used to be
5 //!
6 //! ```ignore
7 //! Mock::given(method("POST"))
8 //! .and(path("/api/v1/sync/push"))
9 //! .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
10 //! .mount(&server)
11 //! .await;
12 //! ```
13 //!
14 //! and is now `kit.post("/api/v1/sync/push").json(json!({"cursor": 1})).await`.
15 //! The point is not the line count: four of those five lines are identical in
16 //! every one of the suite's mounts, so the one line that differs (the response)
17 //! was the hardest thing on screen to find.
18 //!
19 //! [`MockKit`] mounts routes and hands out clients; [`Route`] is the per-mount
20 //! builder. Response fixtures stay in [`common`](crate::common) — this module
21 //! knows how to serve a body, never what a body says.
22
23 use wiremock::matchers::{method, path};
24 use wiremock::{Match, MockBuilder, MockServer, Respond, ResponseTemplate};
25
26 use synckit_client::{SyncKitClient, SyncKitConfig};
27
28 use crate::common::{ensure_crypto_provider, ensure_mock_keystore, fresh_token, test_ids};
29
30 /// A wiremock server plus the wiring around it.
31 ///
32 /// Every test owns one: `MockServer::start` binds its own port, so nothing is
33 /// shared between tests and they pass in any order at any thread count.
34 pub(crate) struct MockKit {
35 server: MockServer,
36 }
37
38 impl MockKit {
39 pub(crate) async fn start() -> Self {
40 Self {
41 server: MockServer::start().await,
42 }
43 }
44
45 // ── Mounting ──
46
47 pub(crate) fn get(&self, route_path: &str) -> Route<'_> {
48 self.route("GET", route_path)
49 }
50
51 pub(crate) fn post(&self, route_path: &str) -> Route<'_> {
52 self.route("POST", route_path)
53 }
54
55 pub(crate) fn put(&self, route_path: &str) -> Route<'_> {
56 self.route("PUT", route_path)
57 }
58
59 fn route(&self, verb: &str, route_path: &str) -> Route<'_> {
60 Route::new(
61 self,
62 wiremock::Mock::given(method(verb)).and(path(route_path)),
63 )
64 }
65
66 /// A route matched by something other than an exact path: a `path_regex`
67 /// over an id-bearing URL, or a path plus a `query_param`.
68 pub(crate) fn matching(&self, verb: &str, matcher: impl Match + 'static) -> Route<'_> {
69 Route::new(self, wiremock::Mock::given(method(verb)).and(matcher))
70 }
71
72 /// Drop every mounted route and the recorded requests. Lets one test run two
73 /// phases against one server (a second device, a loop over status codes).
74 pub(crate) async fn reset(&self) {
75 self.server.reset().await;
76 }
77
78 // ── Clients ──
79
80 /// No session: the client a test uses to assert `NotAuthenticated`.
81 pub(crate) fn client(&self) -> SyncKitClient {
82 ensure_crypto_provider();
83 ensure_mock_keystore();
84 SyncKitClient::new(self.config())
85 }
86
87 /// A session restored from a token that is valid for the next hour.
88 pub(crate) fn authed(&self) -> SyncKitClient {
89 self.client_with_token(&fresh_token())
90 }
91
92 /// Authenticated and holding a master key, which is what every push, pull or
93 /// blob call needs before it will encrypt anything. The key is returned
94 /// because a test that seals its own fixture has to seal it under this one.
95 pub(crate) fn keyed(&self) -> (SyncKitClient, [u8; 32]) {
96 let client = self.authed();
97 let key = synckit_client::crypto::generate_master_key();
98 client.set_master_key_raw(key);
99 (client, key)
100 }
101
102 /// A session restored from a caller-supplied JWT. The expiry cases pass one
103 /// that is already past, or inside the pre-flight buffer.
104 pub(crate) fn client_with_token(&self, token: &str) -> SyncKitClient {
105 let client = self.client();
106 let (user_id, app_id) = test_ids();
107 client.restore_session(token, user_id, app_id);
108 client
109 }
110
111 /// Authenticated over a caller-built reqwest client. The timeout tests are
112 /// the reason: the timeout is a property of the HTTP client, so it cannot be
113 /// set after the fact.
114 pub(crate) fn authed_with_http(&self, http: reqwest::Client) -> SyncKitClient {
115 ensure_crypto_provider();
116 ensure_mock_keystore();
117 let client = SyncKitClient::with_http_client(self.config(), http);
118 let (user_id, app_id) = test_ids();
119 client.restore_session(&fresh_token(), user_id, app_id);
120 client
121 }
122
123 fn config(&self) -> SyncKitConfig {
124 SyncKitConfig {
125 server_url: self.server.uri(),
126 api_key: "test-api-key".to_string(),
127 }
128 }
129
130 // ── URLs ──
131
132 pub(crate) fn uri(&self) -> String {
133 self.server.uri()
134 }
135
136 /// An absolute URL on this server, for the presigned-URL arguments the blob
137 /// calls take: the SDK is handed a full URL rather than building one.
138 pub(crate) fn url(&self, route_path: &str) -> String {
139 format!("{}{route_path}", self.server.uri())
140 }
141
142 // ── Request inspection ──
143
144 pub(crate) async fn requests(&self) -> Vec<wiremock::Request> {
145 self.server
146 .received_requests()
147 .await
148 .expect("the mock server records requests unless explicitly disabled")
149 }
150
151 pub(crate) async fn requests_to(&self, route_path: &str) -> Vec<wiremock::Request> {
152 self.requests()
153 .await
154 .into_iter()
155 .filter(|r| r.url.path() == route_path)
156 .collect()
157 }
158
159 pub(crate) async fn hits(&self, route_path: &str) -> usize {
160 self.requests_to(route_path).await.len()
161 }
162
163 /// The JSON body of the first request to `route_path`.
164 ///
165 /// Panics when there was none: a test reading a body it never provoked is
166 /// asserting against nothing, and should fail loudly rather than compare
167 /// two `null`s.
168 pub(crate) async fn body(&self, route_path: &str) -> serde_json::Value {
169 let requests = self.requests_to(route_path).await;
170 let first = requests
171 .first()
172 .unwrap_or_else(|| panic!("no request was made to {route_path}"));
173 first
174 .body_json()
175 .unwrap_or_else(|e| panic!("{route_path} body is not JSON: {e}"))
176 }
177
178 /// The JSON bodies of every request to `route_path` with the given verb, in
179 /// order. `/keys` is read and written over the same path, so the method is
180 /// part of the question.
181 pub(crate) async fn bodies(&self, verb: &str, route_path: &str) -> Vec<serde_json::Value> {
182 self.requests()
183 .await
184 .iter()
185 .filter(|r| r.method.as_str() == verb && r.url.path() == route_path)
186 .map(|r| {
187 r.body_json()
188 .unwrap_or_else(|e| panic!("{verb} {route_path} body is not JSON: {e}"))
189 })
190 .collect()
191 }
192
193 /// The raw bytes of the first request to `route_path`, for the blob paths
194 /// where the body is ciphertext rather than JSON.
195 pub(crate) async fn raw_body(&self, route_path: &str) -> Vec<u8> {
196 let requests = self.requests_to(route_path).await;
197 requests
198 .first()
199 .unwrap_or_else(|| panic!("no request was made to {route_path}"))
200 .body
201 .clone()
202 }
203 }
204
205 /// One route, from the matcher to the response.
206 ///
207 /// Built by [`MockKit::get`] and friends, finished by a terminal method
208 /// (`json`, `text`, `empty`, `bytes`, `responder`, `reply`) that mounts it.
209 /// The status code and the call-count constraints are set in between.
210 pub(crate) struct Route<'a> {
211 kit: &'a MockKit,
212 builder: MockBuilder,
213 code: u16,
214 at_most: Option<u64>,
215 exactly: Option<u64>,
216 }
217
218 impl<'a> Route<'a> {
219 fn new(kit: &'a MockKit, builder: MockBuilder) -> Self {
220 Self {
221 kit,
222 builder,
223 code: 200,
224 at_most: None,
225 exactly: None,
226 }
227 }
228
229 /// Narrow the match further: a `query_param`, a second path condition.
230 pub(crate) fn and(mut self, matcher: impl Match + 'static) -> Self {
231 self.builder = self.builder.and(matcher);
232 self
233 }
234
235 /// The status to answer with. 200 unless set.
236 pub(crate) fn code(mut self, code: u16) -> Self {
237 self.code = code;
238 self
239 }
240
241 /// Serve this response once, then fall through to whatever was mounted
242 /// after it. The transient-failure-then-success pattern.
243 pub(crate) fn once(self) -> Self {
244 self.at_most(1)
245 }
246
247 /// Serve this response at most `n` times before falling through.
248 pub(crate) fn at_most(mut self, n: u64) -> Self {
249 self.at_most = Some(n);
250 self
251 }
252
253 /// Assert, when the server drops, that this route was called exactly `n`
254 /// times. How "not retried" is stated.
255 pub(crate) fn exactly(mut self, n: u64) -> Self {
256 self.exactly = Some(n);
257 self
258 }
259
260 // ── Terminals ──
261
262 pub(crate) async fn json(self, body: impl serde::Serialize) {
263 let code = self.code;
264 self.reply(ResponseTemplate::new(code).set_body_json(body))
265 .await;
266 }
267
268 pub(crate) async fn text(self, body: impl Into<String>) {
269 let code = self.code;
270 self.reply(ResponseTemplate::new(code).set_body_string(body))
271 .await;
272 }
273
274 pub(crate) async fn bytes(self, body: impl Into<Vec<u8>>) {
275 let code = self.code;
276 self.reply(ResponseTemplate::new(code).set_body_bytes(body))
277 .await;
278 }
279
280 /// A status and nothing else.
281 pub(crate) async fn empty(self) {
282 let code = self.code;
283 self.reply(ResponseTemplate::new(code)).await;
284 }
285
286 /// A response computed from the request, for a server whose answer depends
287 /// on what was asked (the multipart part-URL minting).
288 pub(crate) async fn responder(self, responder: impl Respond + 'static) {
289 let mock = self.builder.respond_with(responder);
290 self.kit.mount(mock, self.at_most, self.exactly).await;
291 }
292
293 /// A caller-built template, for the response knobs the terminals above do
294 /// not carry: a delay, an extra header.
295 pub(crate) async fn reply(self, template: ResponseTemplate) {
296 let mock = self.builder.respond_with(template);
297 self.kit.mount(mock, self.at_most, self.exactly).await;
298 }
299 }
300
301 impl MockKit {
302 async fn mount(&self, mock: wiremock::Mock, at_most: Option<u64>, exactly: Option<u64>) {
303 // `up_to_n_times` consumes the Mock and `expect` borrows it, so the
304 // order here is forced rather than chosen.
305 let mock = match at_most {
306 Some(n) => mock.up_to_n_times(n),
307 None => mock,
308 };
309 let mock = match exactly {
310 Some(n) => mock.expect(n),
311 None => mock,
312 };
313 mock.mount(&self.server).await;
314 }
315 }
316