steam_client/client/builder.rs
1//! Builder pattern for creating SteamClient instances.
2//!
3//! This module provides a fluent builder API for constructing `SteamClient`
4//! instances with customized dependencies. This is particularly useful for
5//! testing, where you want to inject mock implementations of external
6//! dependencies.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use std::sync::Arc;
12//!
13//! use steam_client::{
14//! HttpResponse, MockClock, MockHttpClient, MockRng, SteamClient, SteamClientBuilder,
15//! SteamOptions,
16//! };
17//!
18//! // Create a fully mocked client for testing
19//! let (client, mocks) = SteamClient::builder().with_all_mocks().build_with_mocks();
20//!
21//! // Or configure specific responses
22//! let mock_http = MockHttpClient::new();
23//! mock_http.queue_response(HttpResponse::ok(b"{}".to_vec()));
24//!
25//! let client = SteamClient::builder()
26//! .with_http_client(Arc::new(mock_http))
27//! .build();
28//! ```
29
30use std::sync::Arc;
31
32use super::steam_client::SteamClient;
33use crate::{
34 options::SteamOptions,
35 utils::{
36 clock::{Clock, MockClock, SystemClock},
37 http::{HttpClient, MockHttpClient, ReqwestHttpClient},
38 rng::{MockRng, Rng, ThreadRng},
39 },
40};
41
42/// Builder for creating `SteamClient` instances with customized dependencies.
43///
44/// Use this builder when you need to inject mock implementations for testing
45/// or customize the behavior of the Steam client.
46///
47/// # Testing Example
48///
49/// ```rust,no_run
50/// use steam_client::{SteamClient, SteamClientBuilder};
51///
52/// // Create a test client with mocked dependencies
53/// let (client, mocks) = SteamClient::builder().with_mock_http().build_with_mocks();
54///
55/// assert!(!client.is_logged_in());
56/// ```
57///
58/// # Production Example
59///
60/// ```rust
61/// use steam_client::{SteamClient, SteamOptions};
62///
63/// // For production, just use SteamClient::new()
64/// let client = SteamClient::new(SteamOptions::default());
65/// ```
66pub struct SteamClientBuilder {
67 options: Option<SteamOptions>,
68 http_client: Option<Arc<dyn HttpClient>>,
69 clock: Option<Arc<dyn Clock>>,
70 rng: Option<Arc<dyn Rng>>,
71
72 // Store mocks for test inspection (using Arc for shared access)
73 mock_http: Option<Arc<MockHttpClient>>,
74 mock_clock: Option<Arc<MockClock>>,
75 mock_rng: Option<Arc<MockRng>>,
76}
77
78impl Default for SteamClientBuilder {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl SteamClientBuilder {
85 /// Create a new builder with default settings.
86 ///
87 /// The default configuration uses:
88 /// - `SteamOptions::default()`
89 /// - `HttpCmServerProvider::new_default()`
90 /// - `ReqwestHttpClient` (lazy initialized in `build`)
91 /// - `SystemClock` (lazy initialized in `build`)
92 /// - `ThreadRng` (lazy initialized in `build`)
93 pub fn new() -> Self {
94 Self { options: None, http_client: None, clock: None, rng: None, mock_http: None, mock_clock: None, mock_rng: None }
95 }
96
97 /// Set the options for the Steam client.
98 ///
99 /// These options control high-level behavior like auto-relogin,
100 /// proxy settings, and connection protocols.
101 ///
102 /// If not called, the client will be built using
103 /// [`SteamOptions::default()`].
104 pub fn with_options(mut self, mut options: SteamOptions) -> Self {
105 // Normalize HTTP proxy URL if present
106 if let Some(proxy) = &mut options.http_proxy {
107 if !proxy.contains("://") {
108 *proxy = format!("http://{}", proxy);
109 }
110 }
111 self.options = Some(options);
112 self
113 }
114
115 // ========================================
116 // HTTP Client Configuration
117 // ========================================
118
119 /// Use a custom HTTP client.
120 ///
121 /// This is useful for providing a custom-configured `reqwest` client
122 /// or a completely different implementation of the [`HttpClient`] trait.
123 pub fn with_http_client(mut self, client: Arc<dyn HttpClient>) -> Self {
124 self.http_client = Some(client);
125 self.mock_http = None;
126 self
127 }
128
129 /// Use a mock HTTP client for testing.
130 ///
131 /// Returns a client that records requests and returns queued responses.
132 /// Use `build_with_mocks()` to get access to the mock for test assertions.
133 pub fn with_mock_http(mut self) -> Self {
134 let mock = Arc::new(MockHttpClient::new());
135 self.mock_http = Some(mock.clone());
136 self.http_client = Some(mock);
137 self
138 }
139
140 /// Use a mock HTTP client with pre-queued responses.
141 ///
142 /// This is a convenience method for short tests that only need to verify
143 /// sequence of HTTP responses.
144 pub fn with_mock_http_responses(mut self, responses: Vec<crate::utils::http::HttpResponse>) -> Self {
145 let mock = MockHttpClient::new();
146 mock.queue_responses(responses);
147 let mock = Arc::new(mock);
148 self.mock_http = Some(mock.clone());
149 self.http_client = Some(mock);
150 self
151 }
152
153 // ========================================
154 // Clock Configuration
155 // ========================================
156
157 /// Use a custom clock.
158 ///
159 /// The clock is used for timing heartbeats, measuring timeouts,
160 /// and other time-sensitive logic.
161 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
162 self.clock = Some(clock);
163 self.mock_clock = None;
164 self
165 }
166
167 /// Use a mock clock for testing.
168 ///
169 /// The mock clock starts at time zero and can be advanced manually.
170 /// Use `build_with_mocks()` to get access to the mock for time control.
171 pub fn with_mock_clock(mut self) -> Self {
172 let mock = Arc::new(MockClock::new());
173 self.mock_clock = Some(mock.clone());
174 self.clock = Some(mock);
175 self
176 }
177
178 // ========================================
179 // RNG Configuration
180 // ========================================
181
182 /// Use a custom random number generator.
183 ///
184 /// The RNG is used for generating session IDs, encryption keys,
185 /// and other randomized values.
186 pub fn with_rng(mut self, rng: Arc<dyn Rng>) -> Self {
187 self.rng = Some(rng);
188 self.mock_rng = None;
189 self
190 }
191
192 /// Use a mock RNG for testing.
193 ///
194 /// The mock RNG returns deterministic values that can be set.
195 /// Use `build_with_mocks()` to get access to the mock for value control.
196 pub fn with_mock_rng(mut self) -> Self {
197 let mock = Arc::new(MockRng::new());
198 self.mock_rng = Some(mock.clone());
199 self.rng = Some(mock);
200 self
201 }
202
203 /// Use a mock RNG with specific initial values.
204 ///
205 /// Provides deterministic values for `usize`, `i32`, and `u32` requests.
206 pub fn with_mock_rng_values(mut self, usize_val: usize, i32_val: i32, u32_val: u32) -> Self {
207 let mock = Arc::new(MockRng::with_values(usize_val, i32_val, u32_val));
208 self.mock_rng = Some(mock.clone());
209 self.rng = Some(mock);
210 self
211 }
212
213 // ========================================
214 // Convenience Methods for Testing
215 // ========================================
216
217 /// Configure all other dependencies with mock implementations.
218 ///
219 /// Equivalent to calling `with_mock_http()`,
220 /// `with_mock_clock()`, and `with_mock_rng()`.
221 pub fn with_all_mocks(self) -> Self {
222 self.with_mock_http().with_mock_clock().with_mock_rng()
223 }
224
225 // ========================================
226 // Build Methods
227 // ========================================
228
229 /// Build the SteamClient instance.
230 ///
231 /// - Clock: [`SystemClock`]
232 /// - RNG: [`ThreadRng`]
233 /// - CM Provider: [`HttpCmServerProvider`]
234 pub fn build(self) -> SteamClient {
235 let mut options = self.options.unwrap_or_default();
236
237 // Enforce web compatibility mode constraints
238 if options.web_compatibility_mode && options.protocol == crate::options::EConnectionProtocol::Tcp {
239 tracing::warn!("web_compatibility_mode is enabled so connection protocol is being forced to WebSocket");
240 options.protocol = crate::options::EConnectionProtocol::WebSocket;
241 }
242
243 let http_client = self.http_client.unwrap_or_else(|| Arc::new(ReqwestHttpClient::new()));
244 let clock = self.clock.unwrap_or_else(|| Arc::new(SystemClock));
245 let rng = self.rng.unwrap_or_else(|| Arc::new(ThreadRng));
246 SteamClient::with_all_providers(options, http_client, clock, rng)
247 }
248
249 /// Build the SteamClient and return handles to any mock dependencies.
250 ///
251 /// This is useful in tests where you need to control mock behavior
252 /// or inspect recorded interactions.
253 ///
254 /// # Example
255 ///
256 /// ```rust
257 /// use std::time::Duration;
258 ///
259 /// use steam_client::{utils::http::HttpResponse, SteamClient};
260 ///
261 /// let (client, mocks) = SteamClient::builder()
262 /// .with_mock_http()
263 /// .with_mock_clock()
264 /// .build_with_mocks();
265 ///
266 /// // Control the mock clock
267 /// if let Some(clock) = &mocks.clock {
268 /// clock.advance(Duration::from_secs(10));
269 /// }
270 ///
271 /// // Inspect HTTP requests
272 /// if let Some(http) = &mocks.http {
273 /// assert_eq!(http.request_count(), 0);
274 /// }
275 /// ```
276 pub fn build_with_mocks(self) -> (SteamClient, MockHandles) {
277 let mocks = MockHandles { http: self.mock_http.clone(), clock: self.mock_clock.clone(), rng: self.mock_rng.clone() };
278
279 (self.build(), mocks)
280 }
281}
282
283/// Handles to mock dependencies for test inspection and control.
284///
285/// Returned by [`SteamClientBuilder::build_with_mocks()`].
286///
287/// These handles allow you to:
288/// - [`MockHttpClient`]: Queue responses and inspect recorded requests.
289/// - [`MockClock`]: Advance time manually to test timeouts and heartbeats.
290/// - [`MockRng`]: Control random values for deterministic testing.
291#[derive(Clone)]
292pub struct MockHandles {
293 /// Mock HTTP client, if configured with
294 /// [`SteamClientBuilder::with_mock_http`].
295 pub http: Option<Arc<MockHttpClient>>,
296
297 /// Mock clock, if configured with [`SteamClientBuilder::with_mock_clock`].
298 pub clock: Option<Arc<MockClock>>,
299
300 /// Mock RNG, if configured with [`SteamClientBuilder::with_mock_rng`].
301 pub rng: Option<Arc<MockRng>>,
302}
303
304impl MockHandles {
305 /// Check if any mocks are configured.
306 pub fn has_any(&self) -> bool {
307 self.http.is_some() || self.clock.is_some() || self.rng.is_some()
308 }
309
310 /// Get the mock HTTP client or panic.
311 ///
312 /// # Panics
313 ///
314 /// Panics if no mock HTTP client was configured with
315 /// [`SteamClientBuilder::with_mock_http`].
316 pub fn http_or_panic(&self) -> &MockHttpClient {
317 self.http.as_ref().expect("No mock HTTP client configured")
318 }
319
320 /// Get the mock clock or panic.
321 ///
322 /// # Panics
323 ///
324 /// Panics if no mock clock was configured with
325 /// [`SteamClientBuilder::with_mock_clock`].
326 pub fn clock_or_panic(&self) -> &MockClock {
327 self.clock.as_ref().expect("No mock clock configured")
328 }
329
330 /// Get the mock RNG or panic.
331 ///
332 /// # Panics
333 ///
334 /// Panics if no mock RNG was configured with
335 /// [`SteamClientBuilder::with_mock_rng`].
336 pub fn rng_or_panic(&self) -> &MockRng {
337 self.rng.as_ref().expect("No mock RNG configured")
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use std::time::Duration;
344
345 use super::*;
346 use crate::utils::http::HttpResponse;
347
348 #[test]
349 fn test_builder_default() {
350 let client = SteamClientBuilder::new().build();
351 assert!(!client.is_logged_in());
352 }
353
354 #[test]
355 fn test_builder_with_options() {
356 let options = SteamOptions { auto_relogin: false, ..Default::default() };
357
358 let client = SteamClient::builder().with_options(options).build();
359
360 assert!(!client.options.auto_relogin);
361 }
362
363 #[test]
364 fn test_builder_with_mock_http() {
365 let (client, mocks) = SteamClient::builder().with_mock_http().build_with_mocks();
366
367 assert!(!client.is_logged_in());
368 assert!(mocks.http.is_some());
369 assert_eq!(mocks.http_or_panic().request_count(), 0);
370 }
371
372 #[test]
373 fn test_builder_with_mock_clock() {
374 let (client, mocks) = SteamClient::builder().with_mock_clock().build_with_mocks();
375
376 assert!(!client.is_logged_in());
377 assert!(mocks.clock.is_some());
378
379 let clock = mocks.clock_or_panic();
380 clock.advance(Duration::from_secs(30));
381 assert_eq!(clock.current_offset(), Duration::from_secs(30));
382 }
383
384 #[test]
385 fn test_builder_with_mock_rng() {
386 let (client, mocks) = SteamClient::builder().with_mock_rng_values(42, -1, 100).build_with_mocks();
387
388 assert!(!client.is_logged_in());
389 assert!(mocks.rng.is_some());
390
391 let rng = mocks.rng_or_panic();
392 assert_eq!(rng.current_usize(), 42);
393 assert_eq!(rng.current_i32(), -1);
394 assert_eq!(rng.current_u32(), 100);
395 }
396
397 #[test]
398 fn test_builder_with_all_mocks() {
399 let (client, mocks) = SteamClient::builder().with_all_mocks().build_with_mocks();
400
401 assert!(!client.is_logged_in());
402 assert!(mocks.has_any());
403 assert!(mocks.http.is_some());
404 assert!(mocks.clock.is_some());
405 assert!(mocks.rng.is_some());
406 }
407
408 #[test]
409 fn test_builder_with_mock_http_responses() {
410 let responses = vec![HttpResponse::ok(b"response1".to_vec()), HttpResponse::ok(b"response2".to_vec())];
411
412 let (_, mocks) = SteamClient::builder().with_mock_http_responses(responses).build_with_mocks();
413
414 assert!(mocks.http.is_some());
415 }
416
417 #[test]
418 fn test_steam_client_builder_method() {
419 // Verify the builder() method exists on SteamClient
420 let client = SteamClient::builder().build();
421 assert!(!client.is_logged_in());
422 }
423
424 #[test]
425 fn test_mock_handles_clone() {
426 let (_, mocks) = SteamClient::builder().with_mock_http().with_mock_clock().build_with_mocks();
427
428 let cloned = mocks.clone();
429
430 // Both should point to same mock
431 mocks.clock_or_panic().advance(Duration::from_secs(5));
432 assert_eq!(cloned.clock_or_panic().current_offset(), Duration::from_secs(5));
433 }
434}