Skip to main content

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}