Skip to main content

rch_common/
mock.rs

1//! Mock transport layer for testing.
2//!
3//! Provides mock implementations of SSH and rsync operations
4//! for deterministic testing without real network dependencies.
5//!
6//! Enable mock mode by setting `RCH_MOCK_SSH=1` environment variable.
7
8use crate::ssh_utils::CommandResult;
9use crate::types::{WorkerConfig, WorkerId};
10use std::cell::Cell;
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicU32, Ordering};
13use std::sync::{Arc, Mutex, OnceLock};
14use std::time::Instant;
15use tracing::{debug, info};
16
17fn env_flag(key: &str) -> bool {
18    std::env::var(key)
19        .map(|v| v == "1" || v.to_lowercase() == "true")
20        .unwrap_or(false)
21}
22
23#[derive(Debug, Default, Clone)]
24struct MockOverrides {
25    enabled: Option<bool>,
26    ssh_config: Option<MockConfig>,
27    rsync_config: Option<MockRsyncConfig>,
28    /// Active "override scopes" to prevent premature clearing.
29    ///
30    /// Workspace tests run in parallel and share these global overrides.
31    /// `set_mock_enabled_override(Some(_))` + `clear_mock_overrides()`
32    /// form a push/pop pair so `clear_mock_overrides` doesn't wipe SSH/rsync
33    /// configs while another test still needs them.
34    active_scopes: usize,
35}
36
37fn overrides() -> &'static Mutex<MockOverrides> {
38    static OVERRIDES: OnceLock<Mutex<MockOverrides>> = OnceLock::new();
39    OVERRIDES.get_or_init(|| Mutex::new(MockOverrides::default()))
40}
41
42// ---------------------------------------------------------------------------
43// Thread-local mock override
44// ---------------------------------------------------------------------------
45//
46// The global `MockOverrides` is shared across all test threads in a crate.
47// When parallel tests write conflicting values to the `enabled` field, they
48// race: test A sets `Some(true)`, test B sets `Some(false)`, and whichever
49// runs last wins — breaking the other test's assertion.
50//
51// A thread-local override eliminates this: each test thread has its own
52// `Option<bool>` that `is_mock_enabled()` checks first, before falling
53// back to the global state. Tests that need deterministic mock-enabled
54// behavior should use `set_thread_mock_override` instead of — or in
55// addition to — the global `set_mock_enabled_override`.
56
57thread_local! {
58    static THREAD_MOCK_ENABLED: Cell<Option<bool>> = const { Cell::new(None) };
59}
60
61/// Set a thread-local mock-enabled override.
62///
63/// Takes priority over the global override and environment variable in
64/// `is_mock_enabled()`. Automatically scoped to the calling thread's
65/// lifetime, so each `#[test]` gets independent control.
66pub fn set_thread_mock_override(enabled: Option<bool>) {
67    THREAD_MOCK_ENABLED.with(|c| c.set(enabled));
68}
69
70/// Clear the thread-local mock-enabled override.
71pub fn clear_thread_mock_override() {
72    THREAD_MOCK_ENABLED.with(|c| c.set(None));
73}
74
75/// Set or clear the global mock enabled override (test helper).
76///
77/// Also sets the thread-local override so that `is_mock_enabled()` returns
78/// the correct value for the calling test thread even when parallel tests
79/// write conflicting values to the global `enabled` field.
80pub fn set_mock_enabled_override(enabled: Option<bool>) {
81    let mut guard = overrides().lock().unwrap();
82    if enabled.is_some() {
83        guard.active_scopes = guard.active_scopes.saturating_add(1);
84    }
85    guard.enabled = enabled;
86    drop(guard);
87    // Mirror into thread-local so the calling thread sees its own value.
88    THREAD_MOCK_ENABLED.with(|c| c.set(enabled));
89}
90
91/// Set or clear the mock SSH config override (test helper).
92pub fn set_mock_ssh_config_override(config: Option<MockConfig>) {
93    overrides().lock().unwrap().ssh_config = config;
94}
95
96/// Set or clear the mock rsync config override (test helper).
97pub fn set_mock_rsync_config_override(config: Option<MockRsyncConfig>) {
98    overrides().lock().unwrap().rsync_config = config;
99}
100
101/// Clear all mock overrides (global and thread-local).
102pub fn clear_mock_overrides() {
103    let mut guard = overrides().lock().unwrap();
104    if guard.active_scopes > 0 {
105        guard.active_scopes -= 1;
106    }
107    if guard.active_scopes == 0 {
108        guard.enabled = None;
109        guard.ssh_config = None;
110        guard.rsync_config = None;
111    }
112    drop(guard);
113    // Clear the thread-local override so the calling thread falls back to
114    // the global state (or env var) on subsequent checks.
115    THREAD_MOCK_ENABLED.with(|c| c.set(None));
116}
117
118/// Check if mock mode is enabled.
119///
120/// Priority order:
121/// 1. Thread-local override (parallel-safe, set by the calling test)
122/// 2. Global `enabled` field (shared across threads)
123/// 3. `RCH_MOCK_SSH` environment variable
124pub fn is_mock_enabled() -> bool {
125    // Thread-local override is checked first — immune to parallel clobbering.
126    if let Some(enabled) = THREAD_MOCK_ENABLED.with(|c| c.get()) {
127        return enabled;
128    }
129    if let Some(enabled) = overrides().lock().unwrap().enabled {
130        return enabled;
131    }
132    std::env::var("RCH_MOCK_SSH")
133        .map(|v| v == "1" || v.to_lowercase() == "true")
134        .unwrap_or(false)
135}
136
137/// Check if a host string indicates mock mode (mock://).
138pub fn is_mock_host(host: &str) -> bool {
139    host.starts_with("mock://")
140}
141
142/// Check if a worker should use mock transport.
143pub fn is_mock_worker(worker: &WorkerConfig) -> bool {
144    is_mock_host(&worker.host)
145}
146
147fn global_ssh_invocations() -> &'static Mutex<Vec<MockInvocation>> {
148    static GLOBAL: OnceLock<Mutex<Vec<MockInvocation>>> = OnceLock::new();
149    GLOBAL.get_or_init(|| Mutex::new(Vec::new()))
150}
151
152fn global_rsync_invocations() -> &'static Mutex<Vec<MockSyncInvocation>> {
153    static GLOBAL: OnceLock<Mutex<Vec<MockSyncInvocation>>> = OnceLock::new();
154    GLOBAL.get_or_init(|| Mutex::new(Vec::new()))
155}
156
157/// Clear global mock invocation logs.
158pub fn clear_global_invocations() {
159    global_ssh_invocations().lock().unwrap().clear();
160    global_rsync_invocations().lock().unwrap().clear();
161}
162
163/// Snapshot global SSH invocations.
164pub fn global_ssh_invocations_snapshot() -> Vec<MockInvocation> {
165    global_ssh_invocations().lock().unwrap().clone()
166}
167
168/// Snapshot global rsync invocations.
169pub fn global_rsync_invocations_snapshot() -> Vec<MockSyncInvocation> {
170    global_rsync_invocations().lock().unwrap().clone()
171}
172
173/// Phase markers for logging.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Phase {
176    /// Project sync phase.
177    Sync,
178    /// Command execution phase.
179    Execute,
180    /// Artifact retrieval phase.
181    Artifacts,
182    /// Connection phase.
183    Connect,
184    /// Disconnect phase.
185    Disconnect,
186}
187
188impl std::fmt::Display for Phase {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        match self {
191            Phase::Sync => write!(f, "SYNC"),
192            Phase::Execute => write!(f, "EXEC"),
193            Phase::Artifacts => write!(f, "ARTIFACTS"),
194            Phase::Connect => write!(f, "CONNECT"),
195            Phase::Disconnect => write!(f, "DISCONNECT"),
196        }
197    }
198}
199
200/// Log a phase event with timestamp.
201pub fn log_phase(phase: Phase, message: &str) {
202    let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
203    info!("[{}] [{}] {}", timestamp, phase, message);
204}
205
206/// Log a phase event with timestamp (debug level).
207pub fn debug_phase(phase: Phase, message: &str) {
208    let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
209    debug!("[{}] [{}] {}", timestamp, phase, message);
210}
211
212/// Recorded invocation for mock verification.
213#[derive(Debug, Clone)]
214pub struct MockInvocation {
215    /// Worker ID the invocation was made against.
216    pub worker_id: WorkerId,
217    /// Command that was executed (if applicable).
218    pub command: Option<String>,
219    /// Phase of the invocation.
220    pub phase: Phase,
221    /// Timestamp of invocation.
222    pub timestamp: std::time::SystemTime,
223}
224
225/// Configuration for mock behavior.
226#[derive(Debug, Clone)]
227pub struct MockConfig {
228    /// Default exit code for commands.
229    pub default_exit_code: i32,
230    /// Default stdout for commands.
231    pub default_stdout: String,
232    /// Default stderr for commands.
233    pub default_stderr: String,
234    /// Simulate connection failure.
235    pub fail_connect: bool,
236    /// Simulate transient connection failures for N attempts (then succeed).
237    pub fail_connect_attempts: u32,
238    /// Simulate command failure.
239    pub fail_execute: bool,
240    /// Simulate transient execution failures for N attempts (then succeed).
241    pub fail_execute_attempts: u32,
242    /// Simulated execution time in milliseconds.
243    pub execution_delay_ms: u64,
244    /// Command-specific results (command -> result).
245    pub command_results: HashMap<String, CommandResult>,
246    /// Simulate toolchain install failure.
247    pub fail_toolchain_install: bool,
248    /// Simulate no rustup available.
249    pub no_rustup: bool,
250}
251
252impl Default for MockConfig {
253    fn default() -> Self {
254        Self {
255            default_exit_code: 0,
256            default_stdout: String::new(),
257            default_stderr: String::new(),
258            fail_connect: false,
259            fail_connect_attempts: 0,
260            fail_execute: false,
261            fail_execute_attempts: 0,
262            execution_delay_ms: 10,
263            command_results: HashMap::new(),
264            fail_toolchain_install: false,
265            no_rustup: false,
266        }
267    }
268}
269
270impl MockConfig {
271    /// Create a config that simulates successful operations.
272    pub fn success() -> Self {
273        Self::default()
274    }
275
276    /// Create a config that simulates connection failure.
277    pub fn connection_failure() -> Self {
278        Self {
279            fail_connect: true,
280            ..Self::default()
281        }
282    }
283
284    /// Create a config that simulates command failure.
285    pub fn command_failure(exit_code: i32, stderr: &str) -> Self {
286        Self {
287            default_exit_code: exit_code,
288            default_stderr: stderr.to_string(),
289            fail_execute: true,
290            ..Self::default()
291        }
292    }
293
294    /// Add a specific result for a command pattern.
295    pub fn with_command_result(mut self, command: &str, result: CommandResult) -> Self {
296        self.command_results.insert(command.to_string(), result);
297        self
298    }
299
300    /// Set default stdout.
301    pub fn with_stdout(mut self, stdout: &str) -> Self {
302        self.default_stdout = stdout.to_string();
303        self
304    }
305
306    /// Build mock config from environment variables.
307    pub fn from_env() -> Self {
308        if let Some(config) = overrides().lock().unwrap().ssh_config.clone() {
309            return config;
310        }
311
312        let mut config = MockConfig::default();
313
314        if let Ok(val) = std::env::var("RCH_MOCK_SSH_EXIT_CODE")
315            && let Ok(code) = val.parse()
316        {
317            config.default_exit_code = code;
318        }
319        if let Ok(val) = std::env::var("RCH_MOCK_SSH_STDOUT") {
320            config.default_stdout = val;
321        }
322        if let Ok(val) = std::env::var("RCH_MOCK_SSH_STDERR") {
323            config.default_stderr = val;
324        }
325        if let Ok(val) = std::env::var("RCH_MOCK_SSH_DELAY_MS")
326            && let Ok(delay) = val.parse()
327        {
328            config.execution_delay_ms = delay;
329        }
330
331        config.fail_connect = env_flag("RCH_MOCK_SSH_FAIL_CONNECT");
332        config.fail_execute = env_flag("RCH_MOCK_SSH_FAIL_EXECUTE");
333
334        if let Ok(val) = std::env::var("RCH_MOCK_SSH_FAIL_CONNECT_ATTEMPTS")
335            && let Ok(count) = val.parse()
336        {
337            config.fail_connect_attempts = count;
338        }
339        if let Ok(val) = std::env::var("RCH_MOCK_SSH_FAIL_EXECUTE_ATTEMPTS")
340            && let Ok(count) = val.parse()
341        {
342            config.fail_execute_attempts = count;
343        }
344
345        config.fail_toolchain_install = env_flag("RCH_MOCK_TOOLCHAIN_INSTALL_FAIL");
346        config.no_rustup = env_flag("RCH_MOCK_NO_RUSTUP");
347
348        config
349    }
350
351    /// Create a config that simulates toolchain install failure.
352    pub fn toolchain_install_failure() -> Self {
353        Self {
354            fail_toolchain_install: true,
355            ..Self::default()
356        }
357    }
358
359    /// Create a config that simulates no rustup available.
360    pub fn no_rustup() -> Self {
361        Self {
362            no_rustup: true,
363            ..Self::default()
364        }
365    }
366}
367
368/// Mock SSH client for testing.
369pub struct MockSshClient {
370    /// Worker configuration.
371    config: WorkerConfig,
372    /// Mock behavior configuration.
373    mock_config: MockConfig,
374    /// Whether currently "connected".
375    connected: bool,
376    /// Recorded invocations.
377    invocations: Arc<Mutex<Vec<MockInvocation>>>,
378    /// Remaining transient connect failures to simulate.
379    connect_failures_remaining: AtomicU32,
380    /// Remaining transient execute failures to simulate.
381    execute_failures_remaining: AtomicU32,
382}
383
384impl MockSshClient {
385    /// Create a new mock SSH client.
386    pub fn new(config: WorkerConfig, mock_config: MockConfig) -> Self {
387        Self {
388            config,
389            connect_failures_remaining: AtomicU32::new(mock_config.fail_connect_attempts),
390            execute_failures_remaining: AtomicU32::new(mock_config.fail_execute_attempts),
391            mock_config,
392            connected: false,
393            invocations: Arc::new(Mutex::new(Vec::new())),
394        }
395    }
396
397    /// Create with default mock config.
398    pub fn new_default(config: WorkerConfig) -> Self {
399        Self::new(config, MockConfig::default())
400    }
401
402    /// Get the worker ID.
403    pub fn worker_id(&self) -> &WorkerId {
404        &self.config.id
405    }
406
407    /// Check if "connected".
408    pub fn is_connected(&self) -> bool {
409        self.connected
410    }
411
412    /// Get recorded invocations.
413    pub fn invocations(&self) -> Vec<MockInvocation> {
414        self.invocations.lock().unwrap().clone()
415    }
416
417    /// Clear recorded invocations.
418    pub fn clear_invocations(&self) {
419        self.invocations.lock().unwrap().clear();
420    }
421
422    fn record(&self, phase: Phase, command: Option<String>) {
423        let invocation = MockInvocation {
424            worker_id: self.config.id.clone(),
425            command,
426            phase,
427            timestamp: std::time::SystemTime::now(),
428        };
429
430        let mut invocations = self.invocations.lock().unwrap();
431        invocations.push(invocation.clone());
432
433        let mut global = global_ssh_invocations().lock().unwrap();
434        global.push(invocation);
435    }
436
437    /// Simulate connecting to the worker.
438    pub async fn connect(&mut self) -> anyhow::Result<()> {
439        log_phase(
440            Phase::Connect,
441            &format!("Connecting to mock worker {}", self.config.id),
442        );
443        self.record(Phase::Connect, None);
444
445        if self.mock_config.fail_connect {
446            log_phase(
447                Phase::Connect,
448                &format!("Mock connection failed for {}", self.config.id),
449            );
450            return Err(anyhow::anyhow!(
451                "Mock: Connection failed to {}",
452                self.config.id
453            ));
454        }
455
456        // Transient connect failures (retryable)
457        if self
458            .connect_failures_remaining
459            .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
460                current.checked_sub(1)
461            })
462            .is_ok()
463        {
464            log_phase(
465                Phase::Connect,
466                &format!("Mock transient connect failure for {}", self.config.id),
467            );
468            return Err(anyhow::anyhow!(
469                "Mock: Connection timed out to {}",
470                self.config.id
471            ));
472        }
473
474        // Simulate connection delay
475        tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
476
477        self.connected = true;
478        log_phase(
479            Phase::Connect,
480            &format!("Mock connected to {}", self.config.id),
481        );
482        Ok(())
483    }
484
485    /// Simulate disconnecting from the worker.
486    pub async fn disconnect(&mut self) -> anyhow::Result<()> {
487        log_phase(
488            Phase::Disconnect,
489            &format!("Disconnecting from mock worker {}", self.config.id),
490        );
491        self.record(Phase::Disconnect, None);
492        self.connected = false;
493        Ok(())
494    }
495
496    /// Simulate executing a command.
497    pub async fn execute(&self, command: &str) -> anyhow::Result<CommandResult> {
498        if !self.connected {
499            return Err(anyhow::anyhow!("Mock: Not connected to worker"));
500        }
501
502        log_phase(
503            Phase::Execute,
504            &format!("Executing on {}: {}", self.config.id, command),
505        );
506        self.record(Phase::Execute, Some(command.to_string()));
507
508        if self.mock_config.fail_execute {
509            log_phase(
510                Phase::Execute,
511                &format!("Mock execution failed for {}", self.config.id),
512            );
513            return Err(anyhow::anyhow!("Mock: Command execution failed"));
514        }
515
516        // Transient execute failures (retryable)
517        if self
518            .execute_failures_remaining
519            .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
520                current.checked_sub(1)
521            })
522            .is_ok()
523        {
524            log_phase(
525                Phase::Execute,
526                &format!("Mock transient execute failure for {}", self.config.id),
527            );
528            return Err(anyhow::anyhow!("Mock: Broken pipe"));
529        }
530
531        // Simulate execution delay
532        let start = Instant::now();
533        tokio::time::sleep(tokio::time::Duration::from_millis(
534            self.mock_config.execution_delay_ms,
535        ))
536        .await;
537
538        // Check for toolchain-related failures
539        if self.mock_config.no_rustup && command.contains("rustup") {
540            log_phase(
541                Phase::Execute,
542                "Mock: rustup not available (no_rustup mode)",
543            );
544            return Ok(CommandResult {
545                exit_code: 127,
546                stdout: String::new(),
547                stderr: "rustup: command not found".to_string(),
548                duration_ms: start.elapsed().as_millis() as u64,
549            });
550        }
551
552        if self.mock_config.fail_toolchain_install
553            && command.contains("rustup")
554            && (command.contains("toolchain install") || command.contains("run"))
555        {
556            log_phase(
557                Phase::Execute,
558                "Mock: toolchain install failed (fail_toolchain_install mode)",
559            );
560            return Ok(CommandResult {
561                exit_code: 1,
562                stdout: String::new(),
563                stderr: "error: toolchain 'nightly-2024-01-15' is not installed".to_string(),
564                duration_ms: start.elapsed().as_millis() as u64,
565            });
566        }
567
568        // Check for command-specific result
569        if let Some(result) = self.mock_config.command_results.get(command) {
570            log_phase(
571                Phase::Execute,
572                &format!(
573                    "Mock command completed (specific): exit={}",
574                    result.exit_code
575                ),
576            );
577            return Ok(result.clone());
578        }
579
580        let result = CommandResult {
581            exit_code: self.mock_config.default_exit_code,
582            stdout: self.mock_config.default_stdout.clone(),
583            stderr: self.mock_config.default_stderr.clone(),
584            duration_ms: start.elapsed().as_millis() as u64,
585        };
586
587        log_phase(
588            Phase::Execute,
589            &format!("Mock command completed: exit={}", result.exit_code),
590        );
591        Ok(result)
592    }
593
594    /// Simulate streaming execution.
595    pub async fn execute_streaming<F, G>(
596        &self,
597        command: &str,
598        mut on_stdout: F,
599        mut on_stderr: G,
600    ) -> anyhow::Result<CommandResult>
601    where
602        F: FnMut(&str),
603        G: FnMut(&str),
604    {
605        let result = self.execute(command).await?;
606
607        // Stream the output line by line
608        for line in result.stdout.lines() {
609            on_stdout(&format!("{}\n", line));
610        }
611        for line in result.stderr.lines() {
612            on_stderr(&format!("{}\n", line));
613        }
614
615        Ok(result)
616    }
617
618    /// Simulate health check.
619    pub async fn health_check(&self) -> anyhow::Result<bool> {
620        match self.execute("echo ok").await {
621            Ok(result) => Ok(result.exit_code == 0),
622            Err(_) => Ok(false),
623        }
624    }
625}
626
627/// Mock rsync for testing file transfers.
628#[derive(Debug, Clone)]
629pub struct MockRsyncResult {
630    /// Number of files "transferred".
631    pub files_transferred: u32,
632    /// Bytes "transferred".
633    pub bytes_transferred: u64,
634    /// Duration in milliseconds.
635    pub duration_ms: u64,
636}
637
638/// Mock rsync operations.
639pub struct MockRsync {
640    /// Recorded sync operations.
641    sync_invocations: Arc<Mutex<Vec<MockSyncInvocation>>>,
642    /// Mock configuration.
643    config: MockRsyncConfig,
644    /// Remaining transient sync failures to simulate.
645    sync_failures_remaining: AtomicU32,
646    /// Remaining transient artifact failures to simulate.
647    artifacts_failures_remaining: AtomicU32,
648}
649
650/// Configuration for mock rsync behavior.
651#[derive(Debug, Clone, Default)]
652pub struct MockRsyncConfig {
653    /// Simulate sync failure.
654    pub fail_sync: bool,
655    /// Simulate transient sync failures for N attempts (then succeed).
656    pub fail_sync_attempts: u32,
657    /// Simulate artifact retrieval failure.
658    pub fail_artifacts: bool,
659    /// Simulate transient artifact failures for N attempts (then succeed).
660    pub fail_artifacts_attempts: u32,
661    /// Simulated files per sync.
662    pub files_per_sync: u32,
663    /// Simulated bytes per sync.
664    pub bytes_per_sync: u64,
665}
666
667impl MockRsyncConfig {
668    /// Create default success config.
669    pub fn success() -> Self {
670        Self {
671            fail_sync: false,
672            fail_sync_attempts: 0,
673            fail_artifacts: false,
674            fail_artifacts_attempts: 0,
675            files_per_sync: 10,
676            bytes_per_sync: 1024 * 100,
677        }
678    }
679
680    /// Create config that fails sync.
681    pub fn sync_failure() -> Self {
682        Self {
683            fail_sync: true,
684            ..Self::default()
685        }
686    }
687
688    /// Create config that fails artifact retrieval.
689    pub fn artifact_failure() -> Self {
690        Self {
691            fail_artifacts: true,
692            ..Self::default()
693        }
694    }
695
696    /// Build mock rsync config from environment variables.
697    pub fn from_env() -> Self {
698        if let Some(config) = overrides().lock().unwrap().rsync_config.clone() {
699            return config;
700        }
701
702        let mut config = MockRsyncConfig::success();
703
704        config.fail_sync = env_flag("RCH_MOCK_RSYNC_FAIL_SYNC");
705        config.fail_artifacts = env_flag("RCH_MOCK_RSYNC_FAIL_ARTIFACTS");
706
707        if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_FAIL_SYNC_ATTEMPTS")
708            && let Ok(count) = val.parse()
709        {
710            config.fail_sync_attempts = count;
711        }
712        if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_FAIL_ARTIFACTS_ATTEMPTS")
713            && let Ok(count) = val.parse()
714        {
715            config.fail_artifacts_attempts = count;
716        }
717
718        if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_FILES")
719            && let Ok(files) = val.parse()
720        {
721            config.files_per_sync = files;
722        }
723        if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_BYTES")
724            && let Ok(bytes) = val.parse()
725        {
726            config.bytes_per_sync = bytes;
727        }
728
729        config
730    }
731}
732
733/// Recorded sync invocation.
734#[derive(Debug, Clone)]
735pub struct MockSyncInvocation {
736    /// Source path.
737    pub source: String,
738    /// Destination path.
739    pub destination: String,
740    /// Phase (sync or artifacts).
741    pub phase: Phase,
742    /// Timestamp.
743    pub timestamp: std::time::SystemTime,
744}
745
746impl MockRsync {
747    /// Create new mock rsync.
748    pub fn new(config: MockRsyncConfig) -> Self {
749        let sync_failures_remaining = AtomicU32::new(config.fail_sync_attempts);
750        let artifacts_failures_remaining = AtomicU32::new(config.fail_artifacts_attempts);
751        Self {
752            sync_invocations: Arc::new(Mutex::new(Vec::new())),
753            sync_failures_remaining,
754            artifacts_failures_remaining,
755            config,
756        }
757    }
758
759    /// Create with default config.
760    pub fn new_default() -> Self {
761        Self::new(MockRsyncConfig::success())
762    }
763
764    /// Get recorded invocations.
765    pub fn invocations(&self) -> Vec<MockSyncInvocation> {
766        self.sync_invocations.lock().unwrap().clone()
767    }
768
769    /// Simulate syncing to remote.
770    pub async fn sync_to_remote(
771        &self,
772        source: &str,
773        destination: &str,
774        _exclude_patterns: &[String],
775    ) -> anyhow::Result<MockRsyncResult> {
776        log_phase(
777            Phase::Sync,
778            &format!("Mock sync: {} -> {}", source, destination),
779        );
780
781        {
782            let invocation = MockSyncInvocation {
783                source: source.to_string(),
784                destination: destination.to_string(),
785                phase: Phase::Sync,
786                timestamp: std::time::SystemTime::now(),
787            };
788            let mut invocations = self.sync_invocations.lock().unwrap();
789            invocations.push(invocation.clone());
790            global_rsync_invocations().lock().unwrap().push(invocation);
791        }
792
793        if self.config.fail_sync {
794            log_phase(Phase::Sync, "Mock sync failed");
795            return Err(anyhow::anyhow!("Mock: Sync failed"));
796        }
797
798        // Transient failures (retryable)
799        if self
800            .sync_failures_remaining
801            .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
802                current.checked_sub(1)
803            })
804            .is_ok()
805        {
806            log_phase(Phase::Sync, "Mock transient sync failure");
807            return Err(anyhow::anyhow!(
808                "Mock: Sync failed (transient) - Connection timed out"
809            ));
810        }
811
812        // Simulate transfer delay
813        let start = Instant::now();
814        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
815
816        let result = MockRsyncResult {
817            files_transferred: self.config.files_per_sync,
818            bytes_transferred: self.config.bytes_per_sync,
819            duration_ms: start.elapsed().as_millis() as u64,
820        };
821
822        log_phase(
823            Phase::Sync,
824            &format!(
825                "Mock sync complete: {} files, {} bytes",
826                result.files_transferred, result.bytes_transferred
827            ),
828        );
829
830        Ok(result)
831    }
832
833    /// Simulate retrieving artifacts.
834    pub async fn retrieve_artifacts(
835        &self,
836        source: &str,
837        destination: &str,
838        _artifact_patterns: &[String],
839    ) -> anyhow::Result<MockRsyncResult> {
840        log_phase(
841            Phase::Artifacts,
842            &format!("Mock artifact retrieval: {} -> {}", source, destination),
843        );
844
845        {
846            let invocation = MockSyncInvocation {
847                source: source.to_string(),
848                destination: destination.to_string(),
849                phase: Phase::Artifacts,
850                timestamp: std::time::SystemTime::now(),
851            };
852            let mut invocations = self.sync_invocations.lock().unwrap();
853            invocations.push(invocation.clone());
854            global_rsync_invocations().lock().unwrap().push(invocation);
855        }
856
857        if self.config.fail_artifacts {
858            log_phase(Phase::Artifacts, "Mock artifact retrieval failed");
859            return Err(anyhow::anyhow!("Mock: Artifact retrieval failed"));
860        }
861
862        // Transient failures (retryable)
863        if self
864            .artifacts_failures_remaining
865            .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
866                current.checked_sub(1)
867            })
868            .is_ok()
869        {
870            log_phase(
871                Phase::Artifacts,
872                "Mock transient artifact retrieval failure",
873            );
874            return Err(anyhow::anyhow!(
875                "Mock: Artifact retrieval failed (transient) - Connection reset by peer"
876            ));
877        }
878
879        // Simulate transfer delay
880        let start = Instant::now();
881        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
882
883        let result = MockRsyncResult {
884            files_transferred: self.config.files_per_sync / 2,
885            bytes_transferred: self.config.bytes_per_sync * 2,
886            duration_ms: start.elapsed().as_millis() as u64,
887        };
888
889        log_phase(
890            Phase::Artifacts,
891            &format!(
892                "Mock artifact retrieval complete: {} files, {} bytes",
893                result.files_transferred, result.bytes_transferred
894            ),
895        );
896
897        Ok(result)
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    #[test]
906    fn test_is_mock_enabled_default() {
907        // Thread-local override makes this parallel-safe: the calling thread
908        // sees its own value regardless of what other threads write.
909        set_mock_enabled_override(Some(false));
910        assert!(!is_mock_enabled());
911        clear_mock_overrides();
912    }
913
914    #[test]
915    fn test_mock_config_defaults() {
916        let config = MockConfig::default();
917        assert_eq!(config.default_exit_code, 0);
918        assert!(!config.fail_connect);
919        assert!(!config.fail_execute);
920    }
921
922    #[test]
923    fn test_mock_config_connection_failure() {
924        let config = MockConfig::connection_failure();
925        assert!(config.fail_connect);
926    }
927
928    #[test]
929    fn test_mock_config_command_failure() {
930        let config = MockConfig::command_failure(1, "error message");
931        assert_eq!(config.default_exit_code, 1);
932        assert_eq!(config.default_stderr, "error message");
933    }
934
935    #[tokio::test]
936    async fn test_mock_ssh_client_connect() {
937        let worker_config = WorkerConfig {
938            id: WorkerId::new("mock-worker"),
939            host: "mock.host".to_string(),
940            user: "mockuser".to_string(),
941            identity_file: "~/.ssh/mock".to_string(),
942            total_slots: 8,
943            priority: 100,
944            tags: vec![],
945            tools: Vec::new(),
946        };
947
948        let mut client = MockSshClient::new_default(worker_config);
949        assert!(!client.is_connected());
950
951        client.connect().await.unwrap();
952        assert!(client.is_connected());
953
954        client.disconnect().await.unwrap();
955        assert!(!client.is_connected());
956    }
957
958    #[tokio::test]
959    async fn test_mock_ssh_client_execute() {
960        let worker_config = WorkerConfig {
961            id: WorkerId::new("mock-worker"),
962            host: "mock.host".to_string(),
963            user: "mockuser".to_string(),
964            identity_file: "~/.ssh/mock".to_string(),
965            total_slots: 8,
966            priority: 100,
967            tags: vec![],
968            tools: Vec::new(),
969        };
970
971        let mut client = MockSshClient::new(
972            worker_config,
973            MockConfig::default().with_stdout("test output"),
974        );
975
976        client.connect().await.unwrap();
977
978        let result = client.execute("echo test").await.unwrap();
979        assert_eq!(result.exit_code, 0);
980        assert_eq!(result.stdout, "test output");
981
982        let invocations = client.invocations();
983        assert_eq!(invocations.len(), 2); // connect + execute
984        assert_eq!(invocations[1].command, Some("echo test".to_string()));
985    }
986
987    #[tokio::test]
988    async fn test_mock_ssh_client_connection_failure() {
989        let worker_config = WorkerConfig {
990            id: WorkerId::new("mock-worker"),
991            host: "mock.host".to_string(),
992            user: "mockuser".to_string(),
993            identity_file: "~/.ssh/mock".to_string(),
994            total_slots: 8,
995            priority: 100,
996            tags: vec![],
997            tools: Vec::new(),
998        };
999
1000        let mut client = MockSshClient::new(worker_config, MockConfig::connection_failure());
1001
1002        let result = client.connect().await;
1003        assert!(result.is_err());
1004        assert!(!client.is_connected());
1005    }
1006
1007    #[tokio::test]
1008    async fn test_mock_rsync_sync() {
1009        let rsync = MockRsync::new_default();
1010
1011        let result = rsync
1012            .sync_to_remote("/local/path", "user@host:/remote/path", &[])
1013            .await
1014            .unwrap();
1015
1016        assert!(result.files_transferred > 0);
1017        assert!(result.bytes_transferred > 0);
1018
1019        let invocations = rsync.invocations();
1020        assert_eq!(invocations.len(), 1);
1021        assert_eq!(invocations[0].phase, Phase::Sync);
1022    }
1023
1024    #[tokio::test]
1025    async fn test_mock_rsync_failure() {
1026        let rsync = MockRsync::new(MockRsyncConfig::sync_failure());
1027
1028        let result = rsync
1029            .sync_to_remote("/local/path", "user@host:/remote/path", &[])
1030            .await;
1031
1032        assert!(result.is_err());
1033    }
1034
1035    #[test]
1036    fn test_phase_display() {
1037        assert_eq!(format!("{}", Phase::Sync), "SYNC");
1038        assert_eq!(format!("{}", Phase::Execute), "EXEC");
1039        assert_eq!(format!("{}", Phase::Artifacts), "ARTIFACTS");
1040    }
1041
1042    #[test]
1043    fn test_mock_config_toolchain_install_failure() {
1044        let config = MockConfig::toolchain_install_failure();
1045        assert!(config.fail_toolchain_install);
1046        assert!(!config.no_rustup);
1047    }
1048
1049    #[test]
1050    fn test_mock_config_no_rustup() {
1051        let config = MockConfig::no_rustup();
1052        assert!(config.no_rustup);
1053        assert!(!config.fail_toolchain_install);
1054    }
1055
1056    #[tokio::test]
1057    async fn test_mock_ssh_client_no_rustup() {
1058        let worker_config = WorkerConfig {
1059            id: WorkerId::new("mock-worker"),
1060            host: "mock.host".to_string(),
1061            user: "mockuser".to_string(),
1062            identity_file: "~/.ssh/mock".to_string(),
1063            total_slots: 8,
1064            priority: 100,
1065            tags: vec![],
1066            tools: Vec::new(),
1067        };
1068
1069        let mut client = MockSshClient::new(worker_config, MockConfig::no_rustup());
1070        client.connect().await.unwrap();
1071
1072        // Any rustup command should fail with command not found
1073        let result = client.execute("rustup --version").await.unwrap();
1074        assert_eq!(result.exit_code, 127);
1075        assert!(result.stderr.contains("command not found"));
1076    }
1077
1078    #[tokio::test]
1079    async fn test_mock_ssh_client_toolchain_install_failure() {
1080        let worker_config = WorkerConfig {
1081            id: WorkerId::new("mock-worker"),
1082            host: "mock.host".to_string(),
1083            user: "mockuser".to_string(),
1084            identity_file: "~/.ssh/mock".to_string(),
1085            total_slots: 8,
1086            priority: 100,
1087            tags: vec![],
1088            tools: Vec::new(),
1089        };
1090
1091        let mut client = MockSshClient::new(worker_config, MockConfig::toolchain_install_failure());
1092        client.connect().await.unwrap();
1093
1094        // Toolchain install commands should fail
1095        let result = client
1096            .execute("rustup toolchain install nightly-2024-01-15")
1097            .await
1098            .unwrap();
1099        assert_eq!(result.exit_code, 1);
1100        assert!(result.stderr.contains("is not installed"));
1101
1102        // Rustup run commands should also fail
1103        let result = client
1104            .execute("rustup run nightly-2024-01-15 cargo build")
1105            .await
1106            .unwrap();
1107        assert_eq!(result.exit_code, 1);
1108        assert!(result.stderr.contains("is not installed"));
1109    }
1110
1111    #[tokio::test]
1112    async fn test_mock_ssh_client_normal_command_with_toolchain_failure() {
1113        let worker_config = WorkerConfig {
1114            id: WorkerId::new("mock-worker"),
1115            host: "mock.host".to_string(),
1116            user: "mockuser".to_string(),
1117            identity_file: "~/.ssh/mock".to_string(),
1118            total_slots: 8,
1119            priority: 100,
1120            tags: vec![],
1121            tools: Vec::new(),
1122        };
1123
1124        let mut client = MockSshClient::new(worker_config, MockConfig::toolchain_install_failure());
1125        client.connect().await.unwrap();
1126
1127        // Non-rustup commands should still succeed
1128        let result = client.execute("cargo build").await.unwrap();
1129        assert_eq!(result.exit_code, 0);
1130    }
1131
1132    #[test]
1133    fn test_is_mock_host() {
1134        assert!(is_mock_host("mock://localhost"));
1135        assert!(is_mock_host("mock://worker-1"));
1136        assert!(!is_mock_host("localhost"));
1137        assert!(!is_mock_host("192.168.1.1"));
1138        assert!(!is_mock_host(""));
1139    }
1140
1141    #[test]
1142    fn test_is_mock_worker() {
1143        let mock_worker = WorkerConfig {
1144            id: WorkerId::new("mock-worker"),
1145            host: "mock://localhost".to_string(),
1146            user: "user".to_string(),
1147            identity_file: "~/.ssh/id_rsa".to_string(),
1148            total_slots: 4,
1149            priority: 100,
1150            tags: vec![],
1151            tools: Vec::new(),
1152        };
1153        assert!(is_mock_worker(&mock_worker));
1154
1155        let real_worker = WorkerConfig {
1156            id: WorkerId::new("real-worker"),
1157            host: "192.168.1.1".to_string(),
1158            user: "user".to_string(),
1159            identity_file: "~/.ssh/id_rsa".to_string(),
1160            total_slots: 4,
1161            priority: 100,
1162            tags: vec![],
1163            tools: Vec::new(),
1164        };
1165        assert!(!is_mock_worker(&real_worker));
1166    }
1167
1168    #[test]
1169    fn test_mock_config_success() {
1170        let config = MockConfig::success();
1171        assert_eq!(config.default_exit_code, 0);
1172        assert!(!config.fail_connect);
1173        assert!(!config.fail_execute);
1174        assert!(config.default_stdout.is_empty());
1175        assert!(config.default_stderr.is_empty());
1176    }
1177
1178    #[test]
1179    fn test_mock_config_with_command_result() {
1180        let custom_result = CommandResult {
1181            exit_code: 42,
1182            stdout: "custom stdout".to_string(),
1183            stderr: "custom stderr".to_string(),
1184            duration_ms: 100,
1185        };
1186
1187        let config =
1188            MockConfig::success().with_command_result("special_cmd", custom_result.clone());
1189
1190        assert!(config.command_results.contains_key("special_cmd"));
1191        let result = config.command_results.get("special_cmd").unwrap();
1192        assert_eq!(result.exit_code, 42);
1193        assert_eq!(result.stdout, "custom stdout");
1194    }
1195
1196    #[test]
1197    fn test_mock_config_with_stdout() {
1198        let config = MockConfig::default().with_stdout("hello world");
1199        assert_eq!(config.default_stdout, "hello world");
1200    }
1201
1202    #[test]
1203    fn test_mock_rsync_config_success() {
1204        let config = MockRsyncConfig::success();
1205        assert!(!config.fail_sync);
1206        assert!(!config.fail_artifacts);
1207        assert_eq!(config.files_per_sync, 10);
1208        assert_eq!(config.bytes_per_sync, 1024 * 100);
1209    }
1210
1211    #[test]
1212    fn test_mock_rsync_config_default() {
1213        let config = MockRsyncConfig::default();
1214        assert!(!config.fail_sync);
1215        assert!(!config.fail_artifacts);
1216        assert_eq!(config.fail_sync_attempts, 0);
1217        assert_eq!(config.fail_artifacts_attempts, 0);
1218    }
1219
1220    #[test]
1221    fn test_mock_rsync_config_artifact_failure() {
1222        let config = MockRsyncConfig::artifact_failure();
1223        assert!(config.fail_artifacts);
1224        assert!(!config.fail_sync);
1225    }
1226
1227    #[tokio::test]
1228    async fn test_mock_rsync_retrieve_artifacts() {
1229        let rsync = MockRsync::new_default();
1230
1231        let result = rsync
1232            .retrieve_artifacts("user@host:/remote/path", "/local/path", &[])
1233            .await
1234            .unwrap();
1235
1236        assert!(result.files_transferred > 0);
1237        assert!(result.bytes_transferred > 0);
1238
1239        let invocations = rsync.invocations();
1240        assert_eq!(invocations.len(), 1);
1241        assert_eq!(invocations[0].phase, Phase::Artifacts);
1242    }
1243
1244    #[tokio::test]
1245    async fn test_mock_rsync_artifact_failure() {
1246        let rsync = MockRsync::new(MockRsyncConfig::artifact_failure());
1247
1248        let result = rsync
1249            .retrieve_artifacts("user@host:/remote/path", "/local/path", &[])
1250            .await;
1251
1252        assert!(result.is_err());
1253    }
1254
1255    #[test]
1256    fn test_phase_equality() {
1257        assert_eq!(Phase::Sync, Phase::Sync);
1258        assert_eq!(Phase::Execute, Phase::Execute);
1259        assert_ne!(Phase::Sync, Phase::Execute);
1260        assert_ne!(Phase::Artifacts, Phase::Connect);
1261    }
1262
1263    #[test]
1264    fn test_phase_copy() {
1265        let phase = Phase::Disconnect;
1266        let copy = phase; // Copy trait
1267        assert_eq!(phase, copy);
1268    }
1269
1270    #[test]
1271    fn test_phase_clone() {
1272        fn assert_clone<T: Clone>() {}
1273        assert_clone::<Phase>();
1274    }
1275
1276    #[test]
1277    fn test_phase_display_all_variants() {
1278        assert_eq!(format!("{}", Phase::Sync), "SYNC");
1279        assert_eq!(format!("{}", Phase::Execute), "EXEC");
1280        assert_eq!(format!("{}", Phase::Artifacts), "ARTIFACTS");
1281        assert_eq!(format!("{}", Phase::Connect), "CONNECT");
1282        assert_eq!(format!("{}", Phase::Disconnect), "DISCONNECT");
1283    }
1284
1285    #[test]
1286    fn test_mock_invocation_debug() {
1287        let invocation = MockInvocation {
1288            worker_id: WorkerId::new("test-worker"),
1289            command: Some("echo hello".to_string()),
1290            phase: Phase::Execute,
1291            timestamp: std::time::SystemTime::now(),
1292        };
1293
1294        let debug = format!("{:?}", invocation);
1295        assert!(debug.contains("MockInvocation"));
1296        assert!(debug.contains("test-worker"));
1297    }
1298
1299    #[test]
1300    fn test_mock_invocation_clone() {
1301        let invocation = MockInvocation {
1302            worker_id: WorkerId::new("worker-1"),
1303            command: None,
1304            phase: Phase::Connect,
1305            timestamp: std::time::SystemTime::now(),
1306        };
1307
1308        let cloned = invocation.clone();
1309        assert_eq!(invocation.phase, cloned.phase);
1310    }
1311
1312    #[test]
1313    fn test_mock_sync_invocation_debug() {
1314        let invocation = MockSyncInvocation {
1315            source: "/local/path".to_string(),
1316            destination: "user@host:/remote".to_string(),
1317            phase: Phase::Sync,
1318            timestamp: std::time::SystemTime::now(),
1319        };
1320
1321        let debug = format!("{:?}", invocation);
1322        assert!(debug.contains("MockSyncInvocation"));
1323        assert!(debug.contains("/local/path"));
1324    }
1325
1326    #[test]
1327    fn test_mock_sync_invocation_clone() {
1328        let invocation = MockSyncInvocation {
1329            source: "src".to_string(),
1330            destination: "dst".to_string(),
1331            phase: Phase::Artifacts,
1332            timestamp: std::time::SystemTime::now(),
1333        };
1334
1335        let cloned = invocation.clone();
1336        assert_eq!(invocation.source, cloned.source);
1337        assert_eq!(invocation.destination, cloned.destination);
1338        assert_eq!(invocation.phase, cloned.phase);
1339    }
1340
1341    #[test]
1342    fn test_mock_rsync_result_debug() {
1343        let result = MockRsyncResult {
1344            files_transferred: 5,
1345            bytes_transferred: 1024,
1346            duration_ms: 50,
1347        };
1348
1349        let debug = format!("{:?}", result);
1350        assert!(debug.contains("MockRsyncResult"));
1351        assert!(debug.contains("1024"));
1352    }
1353
1354    #[test]
1355    fn test_mock_rsync_result_clone() {
1356        let result = MockRsyncResult {
1357            files_transferred: 10,
1358            bytes_transferred: 2048,
1359            duration_ms: 100,
1360        };
1361
1362        let cloned = result.clone();
1363        assert_eq!(result.files_transferred, cloned.files_transferred);
1364        assert_eq!(result.bytes_transferred, cloned.bytes_transferred);
1365        assert_eq!(result.duration_ms, cloned.duration_ms);
1366    }
1367
1368    #[test]
1369    fn test_global_invocations_clear() {
1370        // Clear any existing invocations
1371        clear_global_invocations();
1372
1373        let ssh = global_ssh_invocations_snapshot();
1374        let rsync = global_rsync_invocations_snapshot();
1375
1376        assert!(ssh.is_empty());
1377        assert!(rsync.is_empty());
1378    }
1379
1380    #[tokio::test]
1381    async fn test_mock_ssh_client_execute_not_connected() {
1382        let worker_config = WorkerConfig {
1383            id: WorkerId::new("mock-worker"),
1384            host: "mock.host".to_string(),
1385            user: "mockuser".to_string(),
1386            identity_file: "~/.ssh/mock".to_string(),
1387            total_slots: 8,
1388            priority: 100,
1389            tags: vec![],
1390            tools: Vec::new(),
1391        };
1392
1393        let client = MockSshClient::new_default(worker_config);
1394        // Don't call connect()
1395
1396        let result = client.execute("echo test").await;
1397        assert!(result.is_err());
1398        let err_msg = result.unwrap_err().to_string();
1399        assert!(err_msg.contains("Not connected"));
1400    }
1401
1402    #[tokio::test]
1403    async fn test_mock_ssh_client_streaming() {
1404        let worker_config = WorkerConfig {
1405            id: WorkerId::new("mock-worker"),
1406            host: "mock.host".to_string(),
1407            user: "mockuser".to_string(),
1408            identity_file: "~/.ssh/mock".to_string(),
1409            total_slots: 8,
1410            priority: 100,
1411            tags: vec![],
1412            tools: Vec::new(),
1413        };
1414
1415        let mut client = MockSshClient::new(
1416            worker_config,
1417            MockConfig::default().with_stdout("line1\nline2\nline3"),
1418        );
1419        client.connect().await.unwrap();
1420
1421        let mut stdout_lines = Vec::new();
1422        let mut stderr_lines = Vec::new();
1423
1424        let result = client
1425            .execute_streaming(
1426                "echo test",
1427                |line| stdout_lines.push(line.to_string()),
1428                |line| stderr_lines.push(line.to_string()),
1429            )
1430            .await
1431            .unwrap();
1432
1433        assert_eq!(result.exit_code, 0);
1434        assert_eq!(stdout_lines.len(), 3);
1435    }
1436
1437    #[tokio::test]
1438    async fn test_mock_ssh_client_health_check_success() {
1439        let worker_config = WorkerConfig {
1440            id: WorkerId::new("mock-worker"),
1441            host: "mock.host".to_string(),
1442            user: "mockuser".to_string(),
1443            identity_file: "~/.ssh/mock".to_string(),
1444            total_slots: 8,
1445            priority: 100,
1446            tags: vec![],
1447            tools: Vec::new(),
1448        };
1449
1450        let mut client = MockSshClient::new_default(worker_config);
1451        client.connect().await.unwrap();
1452
1453        let healthy = client.health_check().await.unwrap();
1454        assert!(healthy);
1455    }
1456
1457    #[tokio::test]
1458    async fn test_mock_ssh_client_health_check_failure() {
1459        let worker_config = WorkerConfig {
1460            id: WorkerId::new("mock-worker"),
1461            host: "mock.host".to_string(),
1462            user: "mockuser".to_string(),
1463            identity_file: "~/.ssh/mock".to_string(),
1464            total_slots: 8,
1465            priority: 100,
1466            tags: vec![],
1467            tools: Vec::new(),
1468        };
1469
1470        let mut client = MockSshClient::new(
1471            worker_config,
1472            MockConfig::command_failure(1, "health check failed"),
1473        );
1474        client.connect().await.unwrap();
1475
1476        let healthy = client.health_check().await.unwrap();
1477        assert!(!healthy);
1478    }
1479
1480    #[test]
1481    fn test_mock_ssh_client_worker_id() {
1482        let worker_config = WorkerConfig {
1483            id: WorkerId::new("my-worker-id"),
1484            host: "mock.host".to_string(),
1485            user: "mockuser".to_string(),
1486            identity_file: "~/.ssh/mock".to_string(),
1487            total_slots: 8,
1488            priority: 100,
1489            tags: vec![],
1490            tools: Vec::new(),
1491        };
1492
1493        let client = MockSshClient::new_default(worker_config);
1494        assert_eq!(client.worker_id().as_str(), "my-worker-id");
1495    }
1496
1497    #[test]
1498    fn test_mock_ssh_client_clear_invocations() {
1499        let worker_config = WorkerConfig {
1500            id: WorkerId::new("mock-worker"),
1501            host: "mock.host".to_string(),
1502            user: "mockuser".to_string(),
1503            identity_file: "~/.ssh/mock".to_string(),
1504            total_slots: 8,
1505            priority: 100,
1506            tags: vec![],
1507            tools: Vec::new(),
1508        };
1509
1510        let client = MockSshClient::new_default(worker_config);
1511        // Invocations would be empty initially
1512        assert!(client.invocations().is_empty());
1513        client.clear_invocations();
1514        assert!(client.invocations().is_empty());
1515    }
1516
1517    #[test]
1518    fn test_mock_config_clone() {
1519        let config = MockConfig::default()
1520            .with_stdout("test")
1521            .with_command_result(
1522                "cmd",
1523                CommandResult {
1524                    exit_code: 0,
1525                    stdout: "out".to_string(),
1526                    stderr: "err".to_string(),
1527                    duration_ms: 10,
1528                },
1529            );
1530
1531        let cloned = config.clone();
1532        assert_eq!(config.default_stdout, cloned.default_stdout);
1533        assert_eq!(config.command_results.len(), cloned.command_results.len());
1534    }
1535
1536    #[test]
1537    fn test_mock_config_debug() {
1538        let config = MockConfig::default();
1539        let debug = format!("{:?}", config);
1540        assert!(debug.contains("MockConfig"));
1541    }
1542
1543    #[test]
1544    fn test_mock_rsync_config_debug() {
1545        let config = MockRsyncConfig::default();
1546        let debug = format!("{:?}", config);
1547        assert!(debug.contains("MockRsyncConfig"));
1548    }
1549
1550    #[test]
1551    fn test_mock_rsync_config_clone() {
1552        let config = MockRsyncConfig {
1553            fail_sync: true,
1554            fail_sync_attempts: 3,
1555            fail_artifacts: false,
1556            fail_artifacts_attempts: 0,
1557            files_per_sync: 20,
1558            bytes_per_sync: 5000,
1559        };
1560
1561        let cloned = config.clone();
1562        assert_eq!(config.fail_sync, cloned.fail_sync);
1563        assert_eq!(config.fail_sync_attempts, cloned.fail_sync_attempts);
1564        assert_eq!(config.files_per_sync, cloned.files_per_sync);
1565    }
1566}