//! L9 tests for [`ConnectRemote`] with a mock [`RemoteHost`]. The same use case //! must behave identically whatever the host kind (Liskov), so we drive it with a //! fake host parameterised by kind + root reachability. use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::events::DomainEvent; use domain::ports::{ DirEntry, EventBus, EventStream, FileSystem, FsError, ProcessSpawner, PtyPort, RemoteError, RemoteHost, RemotePath, }; use domain::{ProjectId, RemoteKind}; use uuid::Uuid; use application::{ConnectRemote, ConnectRemoteInput}; // --- Fake filesystem (only `exists` matters here) ------------------------- struct FakeFs { existing_root: Option, } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, p: &RemotePath) -> Result, FsError> { Err(FsError::NotFound(p.as_str().to_owned())) } async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { Ok(()) } async fn exists(&self, p: &RemotePath) -> Result { Ok(self.existing_root.as_deref() == Some(p.as_str())) } async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, _p: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { Ok(()) } } // --- Fake remote host ----------------------------------------------------- struct FakeHost { kind: RemoteKind, connect_ok: bool, fs: Arc, } impl FakeHost { fn make(kind: RemoteKind, connect_ok: bool, existing_root: Option<&str>) -> Arc { Arc::new(Self { kind, connect_ok, fs: Arc::new(FakeFs { existing_root: existing_root.map(ToOwned::to_owned), }), }) } } #[async_trait] impl RemoteHost for FakeHost { fn kind(&self) -> RemoteKind { self.kind } async fn connect(&self) -> Result<(), RemoteError> { if self.connect_ok { Ok(()) } else { Err(RemoteError::Connection("refused".to_owned())) } } fn file_system(&self) -> Arc { Arc::clone(&self.fs) } fn process_spawner(&self) -> Arc { unreachable!("ConnectRemote does not use the spawner") } fn pty(&self) -> Arc { unreachable!("ConnectRemote does not use the pty") } } #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, e: DomainEvent) { self.0.lock().unwrap().push(e); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } fn pid() -> ProjectId { ProjectId::from_uuid(Uuid::from_u128(1)) } #[tokio::test] async fn connect_succeeds_and_emits_event_for_any_host_kind() { // Liskov: identical behaviour for Local, Ssh and Wsl hosts. for kind in [RemoteKind::Local, RemoteKind::Ssh, RemoteKind::Wsl] { let host = FakeHost::make(kind, true, Some("/srv/app")); let bus = SpyBus::default(); let out = ConnectRemote::new(Arc::new(bus.clone())) .execute(ConnectRemoteInput { host, project_id: pid(), root: "/srv/app".to_owned(), }) .await .unwrap(); assert_eq!(out.kind, kind); assert_eq!( bus.events(), vec![DomainEvent::RemoteConnected { project_id: pid() }] ); } } #[tokio::test] async fn connect_propagates_connection_failure() { let host = FakeHost::make(RemoteKind::Ssh, false, Some("/srv/app")); let bus = SpyBus::default(); let err = ConnectRemote::new(Arc::new(bus.clone())) .execute(ConnectRemoteInput { host, project_id: pid(), root: "/srv/app".to_owned(), }) .await .unwrap_err(); assert_eq!(err.code(), "REMOTE", "got {err:?}"); assert!(bus.events().is_empty()); } #[tokio::test] async fn connect_fails_when_root_unreachable() { let host = FakeHost::make(RemoteKind::Local, true, Some("/other")); let bus = SpyBus::default(); let err = ConnectRemote::new(Arc::new(bus.clone())) .execute(ConnectRemoteInput { host, project_id: pid(), root: "/srv/app".to_owned(), }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert!(bus.events().is_empty(), "no event when root is missing"); }