Skip to main content

mockforge_test/
server.rs

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