Skip to main content

mockforge_test/
server.rs

1//! MockForge server management for tests
2
3use crate::config::{ServerConfig, ServerConfigBuilder};
4use crate::error::Result;
5use crate::health::{HealthCheck, HealthStatus};
6use crate::process::{find_available_port, ManagedProcess};
7use crate::scenario::ScenarioManager;
8use parking_lot::Mutex;
9use serde_json::Value;
10use std::path::Path;
11use std::sync::Arc;
12use tracing::{debug, error, info};
13
14/// A managed MockForge server instance for testing
15pub struct MockForgeServer {
16    process: Arc<Mutex<ManagedProcess>>,
17    health: HealthCheck,
18    scenario: ScenarioManager,
19    http_port: u16,
20    ws_port: Option<u16>,
21    grpc_port: Option<u16>,
22    admin_port: Option<u16>,
23    metrics_port: Option<u16>,
24}
25
26impl MockForgeServer {
27    /// Create a new builder for MockForgeServer
28    pub fn builder() -> MockForgeServerBuilder {
29        MockForgeServerBuilder::default()
30    }
31
32    /// Start a MockForge server with the given configuration
33    pub async fn start(config: ServerConfig) -> Result<Self> {
34        // Resolve any ports the caller asked us to auto-assign (port == 0).
35        // Without this the SDK would forward `--<service>-port 0` to the CLI,
36        // which leaves the SDK with no way to know the OS-assigned port — and
37        // any test that hardcoded a port (e.g. 9080) would silently talk to a
38        // *different* mockforge process if one happened to be running.
39        let mut resolved_config = config.clone();
40        if resolved_config.http_port == 0 {
41            resolved_config.http_port = find_available_port(30000)?;
42            info!("Auto-assigned HTTP port: {}", resolved_config.http_port);
43        }
44        if resolved_config.admin_port == Some(0) {
45            let port = find_available_port(31000)?;
46            resolved_config.admin_port = Some(port);
47            info!("Auto-assigned admin port: {}", port);
48        }
49        if resolved_config.metrics_port == Some(0) {
50            let port = find_available_port(32000)?;
51            resolved_config.metrics_port = Some(port);
52            info!("Auto-assigned metrics port: {}", port);
53        }
54        // The mockforge CLI always starts a WS server (default 3001) and gRPC
55        // server (default 50051) even when not explicitly requested. When
56        // multiple test instances spin up in parallel, all of them race for
57        // those default ports — and lose. Auto-assign whenever the caller
58        // didn't pin them, so each test gets its own private ports.
59        if resolved_config.ws_port.is_none() || resolved_config.ws_port == Some(0) {
60            let port = find_available_port(33000)?;
61            resolved_config.ws_port = Some(port);
62            info!("Auto-assigned WebSocket port: {}", port);
63        }
64        if resolved_config.grpc_port.is_none() || resolved_config.grpc_port == Some(0) {
65            let port = find_available_port(34000)?;
66            resolved_config.grpc_port = Some(port);
67            info!("Auto-assigned gRPC port: {}", port);
68        }
69
70        // Spawn the process
71        let process = ManagedProcess::spawn(&resolved_config)?;
72        let http_port = process.http_port();
73
74        info!("MockForge server started on port {}", http_port);
75
76        // Create health check client
77        let health = HealthCheck::new("localhost", http_port);
78
79        // Wait for server to become healthy
80        debug!("Waiting for server to become healthy...");
81        health
82            .wait_until_healthy(resolved_config.health_timeout, resolved_config.health_interval)
83            .await?;
84
85        info!("MockForge server is healthy and ready");
86
87        // Create scenario manager
88        let scenario = ScenarioManager::new("localhost", http_port);
89
90        Ok(Self {
91            process: Arc::new(Mutex::new(process)),
92            health,
93            scenario,
94            http_port,
95            ws_port: resolved_config.ws_port,
96            grpc_port: resolved_config.grpc_port,
97            admin_port: resolved_config.admin_port,
98            metrics_port: resolved_config.metrics_port,
99        })
100    }
101
102    /// Get the HTTP port the server is running on
103    pub fn http_port(&self) -> u16 {
104        self.http_port
105    }
106
107    /// Get the WebSocket port if configured
108    pub fn ws_port(&self) -> Option<u16> {
109        self.ws_port
110    }
111
112    /// Get the gRPC port if configured
113    pub fn grpc_port(&self) -> Option<u16> {
114        self.grpc_port
115    }
116
117    /// Get the admin UI port if admin was enabled.
118    ///
119    /// When the builder set `.admin_port(0)` this returns the
120    /// auto-assigned port; when the builder set a specific port it
121    /// returns that port; when the builder didn't enable admin at all
122    /// it returns `None`.
123    pub fn admin_port(&self) -> Option<u16> {
124        self.admin_port
125    }
126
127    /// Get the metrics endpoint port if metrics was enabled.
128    pub fn metrics_port(&self) -> Option<u16> {
129        self.metrics_port
130    }
131
132    /// Get the base URL of the server
133    pub fn base_url(&self) -> String {
134        format!("http://localhost:{}", self.http_port)
135    }
136
137    /// Get the WebSocket URL if WebSocket is enabled
138    pub fn ws_url(&self) -> Option<String> {
139        self.ws_port.map(|port| format!("ws://localhost:{}/ws", port))
140    }
141
142    /// Get the process ID
143    pub fn pid(&self) -> u32 {
144        self.process.lock().pid()
145    }
146
147    /// Check if the server is still running
148    pub fn is_running(&self) -> bool {
149        self.process.lock().is_running()
150    }
151
152    /// Perform a health check
153    pub async fn health_check(&self) -> Result<HealthStatus> {
154        self.health.check().await
155    }
156
157    /// Check if the server is ready
158    pub async fn is_ready(&self) -> bool {
159        self.health.is_ready().await
160    }
161
162    /// Switch to a different scenario/workspace
163    ///
164    /// # Arguments
165    ///
166    /// * `scenario_name` - Name of the scenario to switch to
167    ///
168    /// # Example
169    ///
170    /// ```no_run
171    /// # use mockforge_test::MockForgeServer;
172    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
173    /// let server = MockForgeServer::builder().build().await?;
174    /// server.scenario("user-auth-success").await?;
175    /// # Ok(())
176    /// # }
177    /// ```
178    pub async fn scenario(&self, scenario_name: &str) -> Result<()> {
179        self.scenario.switch_scenario(scenario_name).await
180    }
181
182    /// Load a workspace configuration from a file
183    pub async fn load_workspace<P: AsRef<Path>>(&self, workspace_file: P) -> Result<()> {
184        self.scenario.load_workspace(workspace_file).await
185    }
186
187    /// Update mock configuration for a specific endpoint
188    pub async fn update_mock(&self, endpoint: &str, config: Value) -> Result<()> {
189        self.scenario.update_mock(endpoint, config).await
190    }
191
192    /// List available fixtures
193    pub async fn list_fixtures(&self) -> Result<Vec<String>> {
194        self.scenario.list_fixtures().await
195    }
196
197    /// Get server statistics
198    pub async fn get_stats(&self) -> Result<Value> {
199        self.scenario.get_stats().await
200    }
201
202    /// Reset all mocks to their initial state
203    pub async fn reset(&self) -> Result<()> {
204        self.scenario.reset().await
205    }
206
207    /// Stop the server
208    pub fn stop(&self) -> Result<()> {
209        info!("Stopping MockForge server (port: {})", self.http_port);
210        self.process.lock().kill()
211    }
212}
213
214impl Drop for MockForgeServer {
215    fn drop(&mut self) {
216        if let Err(e) = self.stop() {
217            error!("Failed to stop MockForge server on drop: {}", e);
218        }
219    }
220}
221
222/// Builder for MockForgeServer
223pub struct MockForgeServerBuilder {
224    config_builder: ServerConfigBuilder,
225}
226
227impl Default for MockForgeServerBuilder {
228    fn default() -> Self {
229        Self {
230            config_builder: ServerConfig::builder(),
231        }
232    }
233}
234
235impl MockForgeServerBuilder {
236    /// Set HTTP port (0 for auto-assign)
237    pub fn http_port(mut self, port: u16) -> Self {
238        self.config_builder = self.config_builder.http_port(port);
239        self
240    }
241
242    /// Set WebSocket port
243    pub fn ws_port(mut self, port: u16) -> Self {
244        self.config_builder = self.config_builder.ws_port(port);
245        self
246    }
247
248    /// Set gRPC port
249    pub fn grpc_port(mut self, port: u16) -> Self {
250        self.config_builder = self.config_builder.grpc_port(port);
251        self
252    }
253
254    /// Set admin UI port
255    pub fn admin_port(mut self, port: u16) -> Self {
256        self.config_builder = self.config_builder.admin_port(port);
257        self
258    }
259
260    /// Set metrics port
261    pub fn metrics_port(mut self, port: u16) -> Self {
262        self.config_builder = self.config_builder.metrics_port(port);
263        self
264    }
265
266    /// Set OpenAPI specification file
267    pub fn spec_file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
268        self.config_builder = self.config_builder.spec_file(path);
269        self
270    }
271
272    /// Set workspace directory
273    pub fn workspace_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
274        self.config_builder = self.config_builder.workspace_dir(path);
275        self
276    }
277
278    /// Set profile name
279    pub fn profile(mut self, profile: impl Into<String>) -> Self {
280        self.config_builder = self.config_builder.profile(profile);
281        self
282    }
283
284    /// Enable admin UI
285    pub fn enable_admin(mut self, enable: bool) -> Self {
286        self.config_builder = self.config_builder.enable_admin(enable);
287        self
288    }
289
290    /// Enable metrics endpoint
291    pub fn enable_metrics(mut self, enable: bool) -> Self {
292        self.config_builder = self.config_builder.enable_metrics(enable);
293        self
294    }
295
296    /// Add extra CLI argument
297    pub fn extra_arg(mut self, arg: impl Into<String>) -> Self {
298        self.config_builder = self.config_builder.extra_arg(arg);
299        self
300    }
301
302    /// Set health check timeout
303    pub fn health_timeout(mut self, timeout: std::time::Duration) -> Self {
304        self.config_builder = self.config_builder.health_timeout(timeout);
305        self
306    }
307
308    /// Set working directory
309    pub fn working_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
310        self.config_builder = self.config_builder.working_dir(path);
311        self
312    }
313
314    /// Add environment variable
315    pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
316        self.config_builder = self.config_builder.env_var(key, value);
317        self
318    }
319
320    /// Set path to mockforge binary
321    pub fn binary_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
322        self.config_builder = self.config_builder.binary_path(path);
323        self
324    }
325
326    /// Build and start the MockForge server
327    pub async fn build(self) -> Result<MockForgeServer> {
328        let config = self.config_builder.build();
329        MockForgeServer::start(config).await
330    }
331}
332
333// Helper function for use with test frameworks
334/// Create a test server with default configuration
335pub async fn with_mockforge<F, Fut>(test: F) -> Result<()>
336where
337    F: FnOnce(MockForgeServer) -> Fut,
338    Fut: std::future::Future<Output = Result<()>>,
339{
340    let server = MockForgeServer::builder().build().await?;
341    test(server).await
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use std::time::Duration;
348
349    #[test]
350    fn test_builder_creation() {
351        let builder = MockForgeServer::builder().http_port(3000).enable_admin(true).profile("test");
352        // Verify builder captured the configuration
353        drop(builder);
354    }
355
356    #[test]
357    fn test_builder_default() {
358        let _builder = MockForgeServerBuilder::default();
359        // Default builder should be created
360    }
361
362    #[test]
363    fn test_builder_http_port() {
364        let builder = MockForgeServer::builder().http_port(8080);
365        // Chain continues
366        let _builder = builder.http_port(9090);
367    }
368
369    #[test]
370    fn test_builder_ws_port() {
371        let _builder = MockForgeServer::builder().ws_port(3001);
372    }
373
374    #[test]
375    fn test_builder_grpc_port() {
376        let _builder = MockForgeServer::builder().grpc_port(50051);
377    }
378
379    #[test]
380    fn test_builder_admin_port() {
381        let _builder = MockForgeServer::builder().admin_port(3002);
382    }
383
384    #[test]
385    fn test_builder_metrics_port() {
386        let _builder = MockForgeServer::builder().metrics_port(9090);
387    }
388
389    #[test]
390    fn test_builder_spec_file() {
391        let _builder = MockForgeServer::builder().spec_file("/path/to/spec.yaml");
392    }
393
394    #[test]
395    fn test_builder_workspace_dir() {
396        let _builder = MockForgeServer::builder().workspace_dir("/path/to/workspace");
397    }
398
399    #[test]
400    fn test_builder_profile() {
401        let _builder = MockForgeServer::builder().profile("production");
402    }
403
404    #[test]
405    fn test_builder_enable_admin() {
406        let _builder = MockForgeServer::builder().enable_admin(true);
407        let _builder2 = MockForgeServer::builder().enable_admin(false);
408    }
409
410    #[test]
411    fn test_builder_enable_metrics() {
412        let _builder = MockForgeServer::builder().enable_metrics(true);
413        let _builder2 = MockForgeServer::builder().enable_metrics(false);
414    }
415
416    #[test]
417    fn test_builder_extra_arg() {
418        let _builder = MockForgeServer::builder().extra_arg("--verbose");
419    }
420
421    #[test]
422    fn test_builder_health_timeout() {
423        let _builder = MockForgeServer::builder().health_timeout(Duration::from_secs(60));
424    }
425
426    #[test]
427    fn test_builder_working_dir() {
428        let _builder = MockForgeServer::builder().working_dir("/tmp/test");
429    }
430
431    #[test]
432    fn test_builder_env_var() {
433        let _builder = MockForgeServer::builder().env_var("RUST_LOG", "debug");
434    }
435
436    #[test]
437    fn test_builder_binary_path() {
438        let _builder = MockForgeServer::builder().binary_path("/usr/local/bin/mockforge");
439    }
440
441    #[test]
442    fn test_builder_full_chain() {
443        let _builder = MockForgeServer::builder()
444            .http_port(3000)
445            .ws_port(3001)
446            .grpc_port(50051)
447            .admin_port(3002)
448            .metrics_port(9090)
449            .spec_file("/spec.yaml")
450            .workspace_dir("/workspace")
451            .profile("test")
452            .enable_admin(true)
453            .enable_metrics(true)
454            .extra_arg("--verbose")
455            .health_timeout(Duration::from_secs(30))
456            .working_dir("/working")
457            .env_var("KEY", "VALUE")
458            .binary_path("/bin/mockforge");
459
460        // Full builder chain should compile
461    }
462}