//! The mock harness: a wiremock server, the clients built against it, and the //! request inspection every module was hand-rolling. //! //! What this replaces is a five-line incantation. Mounting one route used to be //! //! ```ignore //! Mock::given(method("POST")) //! .and(path("/api/v1/sync/push")) //! .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) //! .mount(&server) //! .await; //! ``` //! //! and is now `kit.post("/api/v1/sync/push").json(json!({"cursor": 1})).await`. //! The point is not the line count: four of those five lines are identical in //! every one of the suite's mounts, so the one line that differs (the response) //! was the hardest thing on screen to find. //! //! [`MockKit`] mounts routes and hands out clients; [`Route`] is the per-mount //! builder. Response fixtures stay in [`common`](crate::common) — this module //! knows how to serve a body, never what a body says. use wiremock::matchers::{method, path}; use wiremock::{Match, MockBuilder, MockServer, Respond, ResponseTemplate}; use synckit_client::{SyncKitClient, SyncKitConfig}; use crate::common::{ensure_crypto_provider, ensure_mock_keystore, fresh_token, test_ids}; /// A wiremock server plus the wiring around it. /// /// Every test owns one: `MockServer::start` binds its own port, so nothing is /// shared between tests and they pass in any order at any thread count. pub(crate) struct MockKit { server: MockServer, } impl MockKit { pub(crate) async fn start() -> Self { Self { server: MockServer::start().await, } } // ── Mounting ── pub(crate) fn get(&self, route_path: &str) -> Route<'_> { self.route("GET", route_path) } pub(crate) fn post(&self, route_path: &str) -> Route<'_> { self.route("POST", route_path) } pub(crate) fn put(&self, route_path: &str) -> Route<'_> { self.route("PUT", route_path) } fn route(&self, verb: &str, route_path: &str) -> Route<'_> { Route::new( self, wiremock::Mock::given(method(verb)).and(path(route_path)), ) } /// A route matched by something other than an exact path: a `path_regex` /// over an id-bearing URL, or a path plus a `query_param`. pub(crate) fn matching(&self, verb: &str, matcher: impl Match + 'static) -> Route<'_> { Route::new(self, wiremock::Mock::given(method(verb)).and(matcher)) } /// Drop every mounted route and the recorded requests. Lets one test run two /// phases against one server (a second device, a loop over status codes). pub(crate) async fn reset(&self) { self.server.reset().await; } // ── Clients ── /// No session: the client a test uses to assert `NotAuthenticated`. pub(crate) fn client(&self) -> SyncKitClient { ensure_crypto_provider(); ensure_mock_keystore(); SyncKitClient::new(self.config()) } /// A session restored from a token that is valid for the next hour. pub(crate) fn authed(&self) -> SyncKitClient { self.client_with_token(&fresh_token()) } /// Authenticated and holding a master key, which is what every push, pull or /// blob call needs before it will encrypt anything. The key is returned /// because a test that seals its own fixture has to seal it under this one. pub(crate) fn keyed(&self) -> (SyncKitClient, [u8; 32]) { let client = self.authed(); let key = synckit_client::crypto::generate_master_key(); client.set_master_key_raw(key); (client, key) } /// A session restored from a caller-supplied JWT. The expiry cases pass one /// that is already past, or inside the pre-flight buffer. pub(crate) fn client_with_token(&self, token: &str) -> SyncKitClient { let client = self.client(); let (user_id, app_id) = test_ids(); client.restore_session(token, user_id, app_id); client } /// Authenticated over a caller-built reqwest client. The timeout tests are /// the reason: the timeout is a property of the HTTP client, so it cannot be /// set after the fact. pub(crate) fn authed_with_http(&self, http: reqwest::Client) -> SyncKitClient { ensure_crypto_provider(); ensure_mock_keystore(); let client = SyncKitClient::with_http_client(self.config(), http); let (user_id, app_id) = test_ids(); client.restore_session(&fresh_token(), user_id, app_id); client } fn config(&self) -> SyncKitConfig { SyncKitConfig { server_url: self.server.uri(), api_key: "test-api-key".to_string(), } } // ── URLs ── pub(crate) fn uri(&self) -> String { self.server.uri() } /// An absolute URL on this server, for the presigned-URL arguments the blob /// calls take: the SDK is handed a full URL rather than building one. pub(crate) fn url(&self, route_path: &str) -> String { format!("{}{route_path}", self.server.uri()) } // ── Request inspection ── pub(crate) async fn requests(&self) -> Vec { self.server .received_requests() .await .expect("the mock server records requests unless explicitly disabled") } pub(crate) async fn requests_to(&self, route_path: &str) -> Vec { self.requests() .await .into_iter() .filter(|r| r.url.path() == route_path) .collect() } pub(crate) async fn hits(&self, route_path: &str) -> usize { self.requests_to(route_path).await.len() } /// The JSON body of the first request to `route_path`. /// /// Panics when there was none: a test reading a body it never provoked is /// asserting against nothing, and should fail loudly rather than compare /// two `null`s. pub(crate) async fn body(&self, route_path: &str) -> serde_json::Value { let requests = self.requests_to(route_path).await; let first = requests .first() .unwrap_or_else(|| panic!("no request was made to {route_path}")); first .body_json() .unwrap_or_else(|e| panic!("{route_path} body is not JSON: {e}")) } /// The JSON bodies of every request to `route_path` with the given verb, in /// order. `/keys` is read and written over the same path, so the method is /// part of the question. pub(crate) async fn bodies(&self, verb: &str, route_path: &str) -> Vec { self.requests() .await .iter() .filter(|r| r.method.as_str() == verb && r.url.path() == route_path) .map(|r| { r.body_json() .unwrap_or_else(|e| panic!("{verb} {route_path} body is not JSON: {e}")) }) .collect() } /// The raw bytes of the first request to `route_path`, for the blob paths /// where the body is ciphertext rather than JSON. pub(crate) async fn raw_body(&self, route_path: &str) -> Vec { let requests = self.requests_to(route_path).await; requests .first() .unwrap_or_else(|| panic!("no request was made to {route_path}")) .body .clone() } } /// One route, from the matcher to the response. /// /// Built by [`MockKit::get`] and friends, finished by a terminal method /// (`json`, `text`, `empty`, `bytes`, `responder`, `reply`) that mounts it. /// The status code and the call-count constraints are set in between. pub(crate) struct Route<'a> { kit: &'a MockKit, builder: MockBuilder, code: u16, at_most: Option, exactly: Option, } impl<'a> Route<'a> { fn new(kit: &'a MockKit, builder: MockBuilder) -> Self { Self { kit, builder, code: 200, at_most: None, exactly: None, } } /// Narrow the match further: a `query_param`, a second path condition. pub(crate) fn and(mut self, matcher: impl Match + 'static) -> Self { self.builder = self.builder.and(matcher); self } /// The status to answer with. 200 unless set. pub(crate) fn code(mut self, code: u16) -> Self { self.code = code; self } /// Serve this response once, then fall through to whatever was mounted /// after it. The transient-failure-then-success pattern. pub(crate) fn once(self) -> Self { self.at_most(1) } /// Serve this response at most `n` times before falling through. pub(crate) fn at_most(mut self, n: u64) -> Self { self.at_most = Some(n); self } /// Assert, when the server drops, that this route was called exactly `n` /// times. How "not retried" is stated. pub(crate) fn exactly(mut self, n: u64) -> Self { self.exactly = Some(n); self } // ── Terminals ── pub(crate) async fn json(self, body: impl serde::Serialize) { let code = self.code; self.reply(ResponseTemplate::new(code).set_body_json(body)) .await; } pub(crate) async fn text(self, body: impl Into) { let code = self.code; self.reply(ResponseTemplate::new(code).set_body_string(body)) .await; } pub(crate) async fn bytes(self, body: impl Into>) { let code = self.code; self.reply(ResponseTemplate::new(code).set_body_bytes(body)) .await; } /// A status and nothing else. pub(crate) async fn empty(self) { let code = self.code; self.reply(ResponseTemplate::new(code)).await; } /// A response computed from the request, for a server whose answer depends /// on what was asked (the multipart part-URL minting). pub(crate) async fn responder(self, responder: impl Respond + 'static) { let mock = self.builder.respond_with(responder); self.kit.mount(mock, self.at_most, self.exactly).await; } /// A caller-built template, for the response knobs the terminals above do /// not carry: a delay, an extra header. pub(crate) async fn reply(self, template: ResponseTemplate) { let mock = self.builder.respond_with(template); self.kit.mount(mock, self.at_most, self.exactly).await; } } impl MockKit { async fn mount(&self, mock: wiremock::Mock, at_most: Option, exactly: Option) { // `up_to_n_times` consumes the Mock and `expect` borrows it, so the // order here is forced rather than chosen. let mock = match at_most { Some(n) => mock.up_to_n_times(n), None => mock, }; let mock = match exactly { Some(n) => mock.expect(n), None => mock, }; mock.mount(&self.server).await; } }