import pytest from pathlib import Path from sftp_transfer import ( SFTPConfig, SFTPTransferManager, SFTPConfigurationError, ) class DummySFTPClient: def __init__(self, fail_first: bool = False): self.fail_first = fail_first self.calls = 0 self.uploaded: list[tuple[str, str]] = [] def put(self, local, remote): # mimic paramiko SFTPClient.put self.calls += 1 if self.fail_first and self.calls == 1: raise RuntimeError("Simulated failure") self.uploaded.append((local, remote)) def close(self): # pragma: no cover - trivial pass class DummySSHClient: def __init__(self, sftp_client): self._sftp_client = sftp_client self.closed = False def load_system_host_keys(self): # pragma: no cover - trivial pass def set_missing_host_key_policy(self, policy): # pragma: no cover pass def connect(self, **kwargs): # pragma: no cover - trivial # no-op; assume success return None def open_sftp(self): return self._sftp_client def close(self): # pragma: no cover - trivial self.closed = True @pytest.fixture def config(): return SFTPConfig( host="example.test", port=22, username="user", password="pw", # no logging ensures safe test remote_dir="monthly", retries=1, backoff_sec=0.01, timeout=5, strict_host_key=False, continue_on_error=False, ) def _inject_mock_client(mgr: SFTPTransferManager, sftp_client): # Bypass real connect; assign dummy clients mgr._ssh_client = DummySSHClient(sftp_client) mgr._sftp_client = sftp_client def test_config_sanitization(): cfg = SFTPConfig(host="h", username="u", remote_dir="/path/sub") assert cfg.sanitized_remote_dir == "path/sub" def test_config_invalid_traversal(): with pytest.raises(SFTPConfigurationError): SFTPConfig(host="h", username="u", remote_dir="../bad") def test_upload_success(config, tmp_path): f = tmp_path / "sample.txt" f.write_text("data") mgr = SFTPTransferManager(config) dummy = DummySFTPClient() _inject_mock_client(mgr, dummy) res = mgr.upload_files([f]) assert res.uploaded == 1 assert res.failed == [] assert dummy.uploaded[0][1].endswith("sample.txt") def test_upload_retry_then_success(config, tmp_path): cfg = SFTPConfig( host=config.host, port=config.port, username=config.username, password=config.password, remote_dir=config.remote_dir, retries=1, backoff_sec=config.backoff_sec, timeout=config.timeout, strict_host_key=config.strict_host_key, continue_on_error=config.continue_on_error, ) f = tmp_path / "retry.txt" f.write_text("x") mgr = SFTPTransferManager(cfg) dummy = DummySFTPClient(fail_first=True) _inject_mock_client(mgr, dummy) res = mgr.upload_files([f]) assert res.uploaded == 1 assert dummy.calls == 2 # first fail + retry def test_upload_fail_exhaust_retries(config, tmp_path): cfg = SFTPConfig( host=config.host, port=config.port, username=config.username, password=config.password, remote_dir=config.remote_dir, retries=0, backoff_sec=config.backoff_sec, timeout=config.timeout, strict_host_key=config.strict_host_key, continue_on_error=config.continue_on_error, ) f = tmp_path / "bad.txt" f.write_text("x") mgr = SFTPTransferManager(cfg) # Always failing client class AlwaysFail(DummySFTPClient): def put(self, local, remote): # noqa: D401 raise RuntimeError("nope") dummy = AlwaysFail() _inject_mock_client(mgr, dummy) # upload_files records failures and stops (no exception raised here) res = mgr.upload_files([f]) assert res.uploaded == 0 assert len(res.failed) == 1 def test_continue_on_error(config, tmp_path): cfg = SFTPConfig( host=config.host, port=config.port, username=config.username, password=config.password, remote_dir=config.remote_dir, retries=config.retries, backoff_sec=config.backoff_sec, timeout=config.timeout, strict_host_key=config.strict_host_key, continue_on_error=True, ) f1 = tmp_path / "a.txt" f2 = tmp_path / "b.txt" f1.write_text("a") f2.write_text("b") mgr = SFTPTransferManager(cfg) class FailFirstThenOk(DummySFTPClient): def put(self, local, remote): if Path(local).name == "a.txt": raise RuntimeError("fail a") self.uploaded.append((local, remote)) dummy = FailFirstThenOk() _inject_mock_client(mgr, dummy) res = mgr.upload_files([f1, f2]) assert res.uploaded == 1 assert len(res.failed) == 1 assert res.total == 2