Skip to main content

mockforge_ws/
lib.rs

1//! # MockForge WebSocket
2//!
3//! WebSocket mocking library for MockForge with replay, proxy, and AI-powered event generation.
4//!
5//! This crate provides comprehensive WebSocket mocking capabilities, including:
6//!
7//! - **Replay Mode**: Script and replay WebSocket message sequences
8//! - **Interactive Mode**: Dynamic responses based on client messages
9//! - **AI Event Streams**: Generate narrative-driven event sequences
10//! - **Proxy Mode**: Forward messages to real WebSocket backends
11//! - **JSONPath Matching**: Sophisticated message matching with JSONPath queries
12//!
13//! ## Overview
14//!
15//! MockForge WebSocket supports multiple operational modes:
16//!
17//! ### 1. Replay Mode
18//! Play back pre-recorded WebSocket interactions from JSONL files with template expansion.
19//!
20//! ### 2. Proxy Mode
21//! Forward WebSocket messages to upstream servers with optional message transformation.
22//!
23//! ### 3. AI Event Generation
24//! Generate realistic event streams using LLMs based on narrative descriptions.
25//!
26//! ## Quick Start
27//!
28//! ### Basic WebSocket Server
29//!
30//! ```rust,no_run
31//! use mockforge_ws::router;
32//! use std::net::SocketAddr;
33//!
34//! #[tokio::main]
35//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
36//!     // Create WebSocket router
37//!     let app = router();
38//!
39//!     // Start server
40//!     let addr: SocketAddr = "0.0.0.0:3001".parse()?;
41//!     let listener = tokio::net::TcpListener::bind(addr).await?;
42//!     axum::serve(listener, app).await?;
43//!
44//!     Ok(())
45//! }
46//! ```
47//!
48//! ### With Latency Simulation
49//!
50//! ```rust,no_run
51//! use mockforge_ws::router_with_latency;
52//! use mockforge_core::latency::{FaultConfig, LatencyInjector};
53//! use mockforge_core::LatencyProfile;
54//!
55//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
56//! let latency = LatencyProfile::with_normal_distribution(250, 75.0)
57//!     .with_min_ms(100)
58//!     .with_max_ms(500);
59//! let injector = LatencyInjector::new(latency, FaultConfig::default());
60//! let app = router_with_latency(injector);
61//! # Ok(())
62//! # }
63//! ```
64//!
65//! ### With Proxy Support
66//!
67//! ```rust,no_run
68//! use mockforge_ws::router_with_proxy;
69//! use mockforge_core::{WsProxyHandler, WsProxyConfig};
70//!
71//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
72//! let proxy_config = WsProxyConfig {
73//!     upstream_url: "wss://api.example.com/ws".to_string(),
74//!     ..Default::default()
75//! };
76//! let proxy = WsProxyHandler::new(proxy_config);
77//! let app = router_with_proxy(proxy);
78//! # Ok(())
79//! # }
80//! ```
81//!
82//! ### AI Event Generation
83//!
84//! Generate realistic event streams from narrative descriptions:
85//!
86//! ```rust,no_run
87//! use mockforge_ws::{AiEventGenerator, WebSocketAiConfig};
88//! use mockforge_data::replay_augmentation::{scenarios, ReplayMode};
89//!
90//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
91//! let ai_config = WebSocketAiConfig {
92//!     enabled: true,
93//!     replay: Some(scenarios::stock_market_scenario()),
94//!     max_events: Some(30),
95//!     event_rate: Some(1.5),
96//! };
97//!
98//! let generator = AiEventGenerator::new(ai_config.replay.clone().unwrap())?;
99//! let _events = generator; // use the generator with `stream_events` in your handler
100//! # Ok(())
101//! # }
102//! ```
103//!
104//! ## Replay File Format
105//!
106//! WebSocket replay files use JSON Lines (JSONL) format:
107//!
108//! ```json
109//! {"ts":0,"dir":"out","text":"HELLO {{uuid}}","waitFor":"^CLIENT_READY$"}
110//! {"ts":10,"dir":"out","text":"{\"type\":\"welcome\",\"sessionId\":\"{{uuid}}\"}"}
111//! {"ts":20,"dir":"out","text":"{\"data\":{{randInt 1 100}}}","waitFor":"^ACK$"}
112//! ```
113//!
114//! Fields:
115//! - `ts`: Timestamp in milliseconds
116//! - `dir`: Direction ("in" = received, "out" = sent)
117//! - `text`: Message content (supports template expansion)
118//! - `waitFor`: Optional regex/JSONPath pattern to wait for
119//!
120//! ## JSONPath Message Matching
121//!
122//! Match messages using JSONPath queries:
123//!
124//! ```json
125//! {"waitFor": "$.type", "text": "Type received"}
126//! {"waitFor": "$.user.id", "text": "User authenticated"}
127//! {"waitFor": "$.order.status", "text": "Order updated"}
128//! ```
129//!
130//! ## Key Modules
131//!
132//! - [`ai_event_generator`]: AI-powered event stream generation
133//! - [`ws_tracing`]: Distributed tracing integration
134//!
135//! ## Examples
136//!
137//! See the [examples directory](https://github.com/SaaSy-Solutions/mockforge/tree/main/examples)
138//! for complete working examples.
139//!
140//! ## Related Crates
141//!
142//! - [`mockforge-core`](https://docs.rs/mockforge-core): Core mocking functionality
143//! - [`mockforge-data`](https://docs.rs/mockforge-data): Synthetic data generation
144//!
145//! ## Documentation
146//!
147//! - [MockForge Book](https://docs.mockforge.dev/)
148//! - [WebSocket Mocking Guide](https://docs.mockforge.dev/user-guide/websocket-mocking.html)
149//! - [API Reference](https://docs.rs/mockforge-ws)
150
151pub mod ai_event_generator;
152pub mod handlers;
153/// Unified protocol server lifecycle implementation
154pub mod protocol_server;
155pub mod ws_tracing;
156
157use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
158use axum::extract::{Path, State};
159use axum::{response::IntoResponse, routing::get, Router};
160use futures::sink::SinkExt;
161use futures::stream::StreamExt;
162use mockforge_core::{latency::LatencyInjector, LatencyProfile, WsProxyHandler};
163#[cfg(feature = "data-faker")]
164use mockforge_data::provider::register_core_faker_provider;
165use mockforge_observability::get_global_registry;
166use serde_json::Value;
167use tokio::fs;
168use tokio::time::{sleep, Duration};
169use tracing::*;
170
171// Re-export AI event generator utilities
172pub use ai_event_generator::{AiEventGenerator, WebSocketAiConfig};
173
174// Re-export tracing utilities
175pub use ws_tracing::{
176    create_ws_connection_span, create_ws_message_span, record_ws_connection_success,
177    record_ws_error, record_ws_message_success,
178};
179
180// Re-export handler utilities
181pub use handlers::{
182    HandlerError, HandlerRegistry, HandlerResult, MessagePattern, MessageRouter, PassthroughConfig,
183    PassthroughHandler, RoomManager, WsContext, WsHandler, WsMessage,
184};
185
186/// Build the WebSocket router (exposed for tests and embedding)
187pub fn router() -> Router {
188    #[cfg(feature = "data-faker")]
189    register_core_faker_provider();
190
191    Router::new().route("/ws", get(ws_handler_no_state))
192}
193
194/// Build the WebSocket router with latency injector state
195pub fn router_with_latency(latency_injector: LatencyInjector) -> Router {
196    #[cfg(feature = "data-faker")]
197    register_core_faker_provider();
198
199    Router::new()
200        .route("/ws", get(ws_handler_with_state))
201        .with_state(latency_injector)
202}
203
204/// Build the WebSocket router with proxy handler
205pub fn router_with_proxy(proxy_handler: WsProxyHandler) -> Router {
206    #[cfg(feature = "data-faker")]
207    register_core_faker_provider();
208
209    Router::new()
210        .route("/ws", get(ws_handler_with_proxy))
211        .route("/ws/{*path}", get(ws_handler_with_proxy_path))
212        .with_state(proxy_handler)
213}
214
215/// Build the WebSocket router with handler registry
216pub fn router_with_handlers(registry: std::sync::Arc<HandlerRegistry>) -> Router {
217    #[cfg(feature = "data-faker")]
218    register_core_faker_provider();
219
220    Router::new()
221        .route("/ws", get(ws_handler_with_registry))
222        .route("/ws/{*path}", get(ws_handler_with_registry_path))
223        .with_state(registry)
224}
225
226/// Start WebSocket server with latency simulation
227pub async fn start_with_latency(
228    port: u16,
229    latency: Option<LatencyProfile>,
230) -> Result<(), Box<dyn std::error::Error>> {
231    start_with_latency_and_host(port, "0.0.0.0", latency).await
232}
233
234/// Start WebSocket server with latency simulation and custom host
235pub async fn start_with_latency_and_host(
236    port: u16,
237    host: &str,
238    latency: Option<LatencyProfile>,
239) -> Result<(), Box<dyn std::error::Error>> {
240    let latency_injector = latency.map(|profile| LatencyInjector::new(profile, Default::default()));
241    let router = if let Some(injector) = latency_injector {
242        router_with_latency(injector)
243    } else {
244        router()
245    };
246
247    let addr: std::net::SocketAddr = format!("{}:{}", host, port).parse()?;
248    info!("WebSocket server listening on {}", addr);
249
250    let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
251        format!(
252            "Failed to bind WebSocket server to port {}: {}\n\
253             Hint: The port may already be in use. Try using a different port with --ws-port or check if another process is using this port with: lsof -i :{} or netstat -tulpn | grep {}",
254            port, e, port, port
255        )
256    })?;
257
258    axum::serve(listener, router).await?;
259    Ok(())
260}
261
262// WebSocket handlers
263async fn ws_handler_no_state(ws: WebSocketUpgrade) -> impl IntoResponse {
264    ws.on_upgrade(handle_socket)
265}
266
267async fn ws_handler_with_state(
268    ws: WebSocketUpgrade,
269    State(_latency): State<LatencyInjector>,
270) -> impl IntoResponse {
271    ws.on_upgrade(handle_socket)
272}
273
274async fn ws_handler_with_proxy(
275    ws: WebSocketUpgrade,
276    State(proxy): State<WsProxyHandler>,
277) -> impl IntoResponse {
278    ws.on_upgrade(move |socket| handle_socket_with_proxy(socket, proxy, "/ws".to_string()))
279}
280
281async fn ws_handler_with_proxy_path(
282    Path(path): Path<String>,
283    ws: WebSocketUpgrade,
284    State(proxy): State<WsProxyHandler>,
285) -> impl IntoResponse {
286    let full_path = format!("/ws/{}", path);
287    ws.on_upgrade(move |socket| handle_socket_with_proxy(socket, proxy, full_path))
288}
289
290async fn ws_handler_with_registry(
291    ws: WebSocketUpgrade,
292    State(registry): State<std::sync::Arc<HandlerRegistry>>,
293) -> impl IntoResponse {
294    ws.on_upgrade(move |socket| handle_socket_with_handlers(socket, registry, "/ws".to_string()))
295}
296
297async fn ws_handler_with_registry_path(
298    Path(path): Path<String>,
299    ws: WebSocketUpgrade,
300    State(registry): State<std::sync::Arc<HandlerRegistry>>,
301) -> impl IntoResponse {
302    let full_path = format!("/ws/{}", path);
303    ws.on_upgrade(move |socket| handle_socket_with_handlers(socket, registry, full_path))
304}
305
306async fn handle_socket(mut socket: WebSocket) {
307    use std::time::Instant;
308
309    // Track WebSocket connection
310    let registry = get_global_registry();
311    let connection_start = Instant::now();
312    registry.record_ws_connection_established();
313    debug!("WebSocket connection established, tracking metrics");
314
315    // Track connection status (for metrics reporting)
316    let mut status = "normal";
317
318    // Check if replay mode is enabled
319    if let Ok(replay_file) = std::env::var("MOCKFORGE_WS_REPLAY_FILE") {
320        info!("WebSocket replay mode enabled with file: {}", replay_file);
321        handle_socket_with_replay(socket, &replay_file).await;
322    } else {
323        // Normal echo mode
324        while let Some(msg) = socket.recv().await {
325            match msg {
326                Ok(Message::Text(text)) => {
327                    registry.record_ws_message_received();
328
329                    // Echo the message back with "echo: " prefix
330                    let response = format!("echo: {}", text);
331                    if socket.send(Message::Text(response.into())).await.is_err() {
332                        status = "send_error";
333                        break;
334                    }
335                    registry.record_ws_message_sent();
336                }
337                Ok(Message::Close(_)) => {
338                    status = "client_close";
339                    break;
340                }
341                Err(e) => {
342                    error!("WebSocket error: {}", e);
343                    registry.record_ws_error();
344                    status = "error";
345                    break;
346                }
347                _ => {}
348            }
349        }
350    }
351
352    // Connection closed - record duration
353    let duration = connection_start.elapsed().as_secs_f64();
354    registry.record_ws_connection_closed(duration, status);
355    debug!("WebSocket connection closed (status: {}, duration: {:.2}s)", status, duration);
356}
357
358async fn handle_socket_with_replay(mut socket: WebSocket, replay_file: &str) {
359    let _registry = get_global_registry(); // Available for future message tracking
360
361    // Read the replay file
362    let content = match fs::read_to_string(replay_file).await {
363        Ok(content) => content,
364        Err(e) => {
365            error!("Failed to read replay file {}: {}", replay_file, e);
366            return;
367        }
368    };
369
370    // Parse JSONL file
371    let mut replay_entries = Vec::new();
372    for line in content.lines() {
373        if let Ok(entry) = serde_json::from_str::<Value>(line) {
374            replay_entries.push(entry);
375        }
376    }
377
378    info!("Loaded {} replay entries", replay_entries.len());
379
380    // Process replay entries
381    for entry in replay_entries {
382        // Check if we need to wait for a specific message
383        if let Some(wait_for) = entry.get("waitFor") {
384            if let Some(wait_pattern) = wait_for.as_str() {
385                info!("Waiting for pattern: {}", wait_pattern);
386                // Wait for matching message from client
387                let mut found = false;
388                while let Some(msg) = socket.recv().await {
389                    if let Ok(Message::Text(text)) = msg {
390                        if text.contains(wait_pattern) || wait_pattern == "^CLIENT_READY$" {
391                            found = true;
392                            break;
393                        }
394                    }
395                }
396                if !found {
397                    break;
398                }
399            }
400        }
401
402        // Get the message text
403        if let Some(text) = entry.get("text").and_then(|v| v.as_str()) {
404            // Expand tokens if enabled
405            let expanded_text = if std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
406                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
407                .unwrap_or(false)
408            {
409                expand_tokens(text)
410            } else {
411                text.to_string()
412            };
413
414            info!("Sending replay message: {}", expanded_text);
415            if socket.send(Message::Text(expanded_text.into())).await.is_err() {
416                break;
417            }
418        }
419
420        // Wait for the specified time
421        if let Some(ts) = entry.get("ts").and_then(|v| v.as_u64()) {
422            sleep(Duration::from_millis(ts * 10)).await; // Convert to milliseconds
423        }
424    }
425}
426
427fn expand_tokens(text: &str) -> String {
428    let mut result = text.to_string();
429
430    // Expand {{uuid}}
431    result = result.replace("{{uuid}}", &uuid::Uuid::new_v4().to_string());
432
433    // Expand {{now}}
434    result = result.replace("{{now}}", &chrono::Utc::now().to_rfc3339());
435
436    // Expand {{now+1m}} (add 1 minute)
437    if result.contains("{{now+1m}}") {
438        let now_plus_1m = chrono::Utc::now() + chrono::Duration::minutes(1);
439        result = result.replace("{{now+1m}}", &now_plus_1m.to_rfc3339());
440    }
441
442    // Expand {{now+1h}} (add 1 hour)
443    if result.contains("{{now+1h}}") {
444        let now_plus_1h = chrono::Utc::now() + chrono::Duration::hours(1);
445        result = result.replace("{{now+1h}}", &now_plus_1h.to_rfc3339());
446    }
447
448    // Expand {{randInt min max}}
449    while result.contains("{{randInt") {
450        if let Some(start) = result.find("{{randInt") {
451            if let Some(end) = result[start..].find("}}") {
452                let full_match = &result[start..start + end + 2];
453                let content = &result[start + 9..start + end]; // Skip "{{randInt"
454
455                if let Some(space_pos) = content.find(' ') {
456                    let min_str = &content[..space_pos];
457                    let max_str = &content[space_pos + 1..];
458
459                    if let (Ok(min), Ok(max)) = (min_str.parse::<i32>(), max_str.parse::<i32>()) {
460                        let random_value = fastrand::i32(min..=max);
461                        result = result.replace(full_match, &random_value.to_string());
462                    } else {
463                        result = result.replace(full_match, "0");
464                    }
465                } else {
466                    result = result.replace(full_match, "0");
467                }
468            } else {
469                break;
470            }
471        } else {
472            break;
473        }
474    }
475
476    result
477}
478
479async fn handle_socket_with_proxy(socket: WebSocket, proxy: WsProxyHandler, path: String) {
480    use std::time::Instant;
481
482    let registry = get_global_registry();
483    let connection_start = Instant::now();
484    registry.record_ws_connection_established();
485
486    let mut status = "normal";
487
488    // Check if this connection should be proxied
489    if proxy.config.should_proxy(&path) {
490        info!("Proxying WebSocket connection for path: {}", path);
491        if let Err(e) = proxy.proxy_connection(&path, socket).await {
492            error!("Failed to proxy WebSocket connection: {}", e);
493            registry.record_ws_error();
494            status = "proxy_error";
495        }
496    } else {
497        info!("Handling WebSocket connection locally for path: {}", path);
498        // Handle locally by echoing messages
499        // Note: handle_socket already tracks its own connection metrics,
500        // so we need to avoid double-counting
501        registry.record_ws_connection_closed(0.0, ""); // Decrement the one we just added
502        handle_socket(socket).await;
503        return; // Early return to avoid double-tracking
504    }
505
506    let duration = connection_start.elapsed().as_secs_f64();
507    registry.record_ws_connection_closed(duration, status);
508    debug!(
509        "Proxied WebSocket connection closed (status: {}, duration: {:.2}s)",
510        status, duration
511    );
512}
513
514async fn handle_socket_with_handlers(
515    socket: WebSocket,
516    registry: std::sync::Arc<HandlerRegistry>,
517    path: String,
518) {
519    use std::time::Instant;
520
521    let metrics_registry = get_global_registry();
522    let connection_start = Instant::now();
523    metrics_registry.record_ws_connection_established();
524
525    let mut status = "normal";
526
527    // Generate unique connection ID
528    let connection_id = uuid::Uuid::new_v4().to_string();
529
530    // Get handlers for this path
531    let handlers = registry.get_handlers(&path);
532    if handlers.is_empty() {
533        info!("No handlers found for path: {}, falling back to echo mode", path);
534        metrics_registry.record_ws_connection_closed(0.0, "");
535        handle_socket(socket).await;
536        return;
537    }
538
539    info!(
540        "Handling WebSocket connection with {} handler(s) for path: {}",
541        handlers.len(),
542        path
543    );
544
545    // Create room manager
546    let room_manager = RoomManager::new();
547
548    // Split socket for concurrent send/receive
549    let (mut socket_sender, mut socket_receiver) = socket.split();
550
551    // Create message channel for handlers to send messages
552    let (message_tx, mut message_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
553
554    // Create context
555    let mut ctx =
556        WsContext::new(connection_id.clone(), path.clone(), room_manager.clone(), message_tx);
557
558    // Call on_connect for all handlers
559    for handler in &handlers {
560        if let Err(e) = handler.on_connect(&mut ctx).await {
561            error!("Handler on_connect error: {}", e);
562            status = "handler_error";
563        }
564    }
565
566    // Spawn task to send messages from handlers to the socket
567    let send_task = tokio::spawn(async move {
568        while let Some(msg) = message_rx.recv().await {
569            if socket_sender.send(msg).await.is_err() {
570                break;
571            }
572        }
573    });
574
575    // Handle incoming messages
576    while let Some(msg) = socket_receiver.next().await {
577        match msg {
578            Ok(axum_msg) => {
579                metrics_registry.record_ws_message_received();
580
581                let ws_msg: WsMessage = axum_msg.into();
582
583                // Check for close message
584                if matches!(ws_msg, WsMessage::Close) {
585                    status = "client_close";
586                    break;
587                }
588
589                // Pass message through all handlers
590                for handler in &handlers {
591                    if let Err(e) = handler.on_message(&mut ctx, ws_msg.clone()).await {
592                        error!("Handler on_message error: {}", e);
593                        status = "handler_error";
594                    }
595                }
596
597                metrics_registry.record_ws_message_sent();
598            }
599            Err(e) => {
600                error!("WebSocket error: {}", e);
601                metrics_registry.record_ws_error();
602                status = "error";
603                break;
604            }
605        }
606    }
607
608    // Call on_disconnect for all handlers
609    for handler in &handlers {
610        if let Err(e) = handler.on_disconnect(&mut ctx).await {
611            error!("Handler on_disconnect error: {}", e);
612        }
613    }
614
615    // Clean up room memberships
616    let _ = room_manager.leave_all(&connection_id).await;
617
618    // Abort send task
619    send_task.abort();
620
621    let duration = connection_start.elapsed().as_secs_f64();
622    metrics_registry.record_ws_connection_closed(duration, status);
623    debug!(
624        "Handler-based WebSocket connection closed (status: {}, duration: {:.2}s)",
625        status, duration
626    );
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    // ==================== Router Tests ====================
634
635    #[test]
636    fn test_router_creation() {
637        let _router = router();
638        // Router should be created successfully
639    }
640
641    #[test]
642    fn test_router_with_latency_creation() {
643        let latency_profile = LatencyProfile::default();
644        let latency_injector = LatencyInjector::new(latency_profile, Default::default());
645        let _router = router_with_latency(latency_injector);
646        // Router should be created successfully
647    }
648
649    #[test]
650    fn test_router_with_proxy_creation() {
651        let config = mockforge_core::WsProxyConfig {
652            upstream_url: "ws://localhost:8080".to_string(),
653            ..Default::default()
654        };
655        let proxy_handler = WsProxyHandler::new(config);
656        let _router = router_with_proxy(proxy_handler);
657        // Router should be created successfully
658    }
659
660    #[test]
661    fn test_router_with_handlers_creation() {
662        let registry = std::sync::Arc::new(HandlerRegistry::new());
663        let _router = router_with_handlers(registry);
664        // Router should be created successfully
665    }
666
667    #[tokio::test]
668    async fn test_start_with_latency_config_none() {
669        // Test that we can create the router without latency
670        let result = std::panic::catch_unwind(|| {
671            let _router = router();
672        });
673        assert!(result.is_ok());
674    }
675
676    #[tokio::test]
677    async fn test_start_with_latency_config_some() {
678        // Test that we can create the router with latency
679        let latency_profile = LatencyProfile::default();
680        let latency_injector = LatencyInjector::new(latency_profile, Default::default());
681
682        let result = std::panic::catch_unwind(|| {
683            let _router = router_with_latency(latency_injector);
684        });
685        assert!(result.is_ok());
686    }
687
688    // ==================== Token Expansion Tests ====================
689
690    #[test]
691    fn test_expand_tokens_uuid() {
692        let text = "session-{{uuid}}";
693        let expanded = expand_tokens(text);
694        assert!(!expanded.contains("{{uuid}}"));
695        assert!(expanded.starts_with("session-"));
696        // UUID format check (36 chars with hyphens)
697        let uuid_part = &expanded[8..];
698        assert_eq!(uuid_part.len(), 36);
699    }
700
701    #[test]
702    fn test_expand_tokens_now() {
703        let text = "time: {{now}}";
704        let expanded = expand_tokens(text);
705        assert!(!expanded.contains("{{now}}"));
706        assert!(expanded.starts_with("time: "));
707        // Should be ISO 8601 format
708        assert!(expanded.contains("T"));
709    }
710
711    #[test]
712    fn test_expand_tokens_now_plus_1m() {
713        let text = "expires: {{now+1m}}";
714        let expanded = expand_tokens(text);
715        assert!(!expanded.contains("{{now+1m}}"));
716        assert!(expanded.starts_with("expires: "));
717    }
718
719    #[test]
720    fn test_expand_tokens_now_plus_1h() {
721        let text = "expires: {{now+1h}}";
722        let expanded = expand_tokens(text);
723        assert!(!expanded.contains("{{now+1h}}"));
724        assert!(expanded.starts_with("expires: "));
725    }
726
727    #[test]
728    fn test_expand_tokens_randint() {
729        let text = "value: {{randInt 1 100}}";
730        let expanded = expand_tokens(text);
731        assert!(!expanded.contains("{{randInt"), "Token should be expanded");
732        assert!(expanded.starts_with("value: "));
733        // The implementation replaces randInt with a number (or fallback)
734    }
735
736    #[test]
737    fn test_expand_tokens_randint_multiple() {
738        let text = "a: {{randInt 1 10}}, b: {{randInt 20 30}}";
739        let expanded = expand_tokens(text);
740        assert!(!expanded.contains("{{randInt"));
741        assert!(expanded.contains("a: "));
742        assert!(expanded.contains("b: "));
743    }
744
745    #[test]
746    fn test_expand_tokens_mixed() {
747        let text = "id: {{uuid}}, time: {{now}}, rand: {{randInt 1 10}}";
748        let expanded = expand_tokens(text);
749        assert!(!expanded.contains("{{uuid}}"));
750        assert!(!expanded.contains("{{now}}"));
751        assert!(!expanded.contains("{{randInt"));
752    }
753
754    #[test]
755    fn test_expand_tokens_no_tokens() {
756        let text = "plain text without tokens";
757        let expanded = expand_tokens(text);
758        assert_eq!(expanded, text);
759    }
760
761    // ==================== Latency Profile Tests ====================
762
763    #[test]
764    fn test_latency_profile_default() {
765        let profile = LatencyProfile::default();
766        // Default profile should be valid
767        let injector = LatencyInjector::new(profile, Default::default());
768        let _router = router_with_latency(injector);
769    }
770
771    #[test]
772    fn test_latency_profile_with_normal_distribution() {
773        let profile = LatencyProfile::with_normal_distribution(100, 25.0)
774            .with_min_ms(50)
775            .with_max_ms(200);
776        let injector = LatencyInjector::new(profile, Default::default());
777        let _router = router_with_latency(injector);
778    }
779
780    // ==================== Proxy Config Tests ====================
781
782    #[test]
783    fn test_ws_proxy_config_default() {
784        let config = mockforge_core::WsProxyConfig::default();
785        // Just verify the default can be created
786        let _url = &config.upstream_url;
787    }
788
789    #[test]
790    fn test_ws_proxy_config_custom() {
791        let config = mockforge_core::WsProxyConfig {
792            upstream_url: "wss://api.example.com/ws".to_string(),
793            ..Default::default()
794        };
795        assert_eq!(config.upstream_url, "wss://api.example.com/ws");
796    }
797
798    // ==================== Re-export Tests ====================
799
800    #[test]
801    fn test_reexports_available() {
802        // Test that public re-exports work
803        let _ = create_ws_connection_span("conn-123");
804
805        // Handler types
806        let _registry = HandlerRegistry::new();
807        let _pattern = MessagePattern::any();
808    }
809}