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