mod docker_integration;
pub use docker_integration::common::*;
#[tokio::test]
async fn test_mcp_tools_with_docker() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
tracing::info!("SSH container started at {}:{}", host, port);
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: Some("secret".to_string()),
key: None,
su_password: None,
sudo_password: Some("secret".to_string()),
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: false,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
tracing::info!("SshMcpServer created successfully");
let exec_result = server
.test_execute_command("whoami")
.await
.expect("exec command failed");
let exec_output = extract_text_from_result(&exec_result);
let exec_output = exec_output.trim();
assert!(
exec_output.contains("test"),
"exec 'whoami' should return 'test', got: '{}'",
exec_output
);
tracing::info!("shell behavior verified: whoami returned 'test'");
server
.test_execute_command("printf 'read-file smoke\\n' > /tmp/ssh-mcp-read-file.txt")
.await
.expect("failed to create remote file for read-file test");
let read_file_result = server
.test_read_file("/tmp/ssh-mcp-read-file.txt", None)
.await
.expect("read-file command failed");
let read_file_text = extract_text_from_result(&read_file_result);
let read_file_json: serde_json::Value =
serde_json::from_str(read_file_text.trim()).expect("read-file result should be valid JSON");
assert_eq!(
read_file_json.get("path").and_then(|v| v.as_str()),
Some("/tmp/ssh-mcp-read-file.txt")
);
assert_eq!(
read_file_json.get("content").and_then(|v| v.as_str()),
Some("read-file smoke\n")
);
tracing::info!("read behavior verified: content returned as JSON");
let missing_file_result = server
.test_read_file("/tmp/ssh-mcp-read-file-missing.txt", None)
.await
.expect("read-file missing-file call failed");
assert!(
missing_file_result.is_error.unwrap_or(false),
"missing file should return an error"
);
let missing_file_text = extract_text_from_result(&missing_file_result);
assert!(
missing_file_text.contains("remote_path does not exist"),
"unexpected missing-file error: {missing_file_text}"
);
let non_regular_result = server
.test_read_file("/tmp", None)
.await
.expect("read-file non-regular call failed");
assert!(
non_regular_result.is_error.unwrap_or(false),
"directory path should return an error"
);
let non_regular_text = extract_text_from_result(&non_regular_result);
assert!(
non_regular_text.contains("remote_path is not a regular file"),
"unexpected non-regular-file error: {non_regular_text}"
);
server
.test_execute_command("head -c 48001 /dev/zero > /tmp/ssh-mcp-read-file-too-large.bin")
.await
.expect("failed to create oversized fixture file");
let oversized_result = server
.test_read_file("/tmp/ssh-mcp-read-file-too-large.bin", None)
.await
.expect("read-file oversized call failed");
assert!(
oversized_result.is_error.unwrap_or(false),
"oversized file should return an error"
);
let oversized_text = extract_text_from_result(&oversized_result);
assert!(
oversized_text.contains(
"Error: remote file exceeds read size limit (48000 bytes). Use transfer for large files"
),
"unexpected oversized-file error: {oversized_text}"
);
assert!(
!oversized_text.contains("not valid UTF-8"),
"oversized-file path should fail on size before UTF-8 decode: {oversized_text}"
);
server
.test_execute_command("printf '\\377\\376\\375' > /tmp/ssh-mcp-read-file-binary.bin")
.await
.expect("failed to create non-UTF8 fixture file");
let non_utf8_result = server
.test_read_file("/tmp/ssh-mcp-read-file-binary.bin", None)
.await
.expect("read-file non-UTF8 call failed");
assert!(
non_utf8_result.is_error.unwrap_or(false),
"non-UTF8 file should return an error"
);
let non_utf8_text = extract_text_from_result(&non_utf8_result);
assert!(
non_utf8_text.contains("not valid UTF-8 text"),
"unexpected non-UTF8 error: {non_utf8_text}"
);
let long_read_path = "/tmp/ssh-mcp-read-file-long.txt";
server
.test_execute_command(
r#"sh -c 'set -eu; out=/tmp/ssh-mcp-read-file-long.txt; : > "$out"; i=1; while [ "$i" -le 1200 ]; do printf "line-%04d\n" "$i" >> "$out"; i=$((i+1)); done'"#,
)
.await
.expect("failed to create long read-file fixture");
let preview_result = server
.test_read_file(long_read_path, None)
.await
.expect("read-file preview call failed");
let preview_text = extract_text_from_result(&preview_result);
let preview_json: serde_json::Value = serde_json::from_str(preview_text.trim())
.expect("read-file preview response should be valid JSON");
assert_eq!(
preview_json.get("path").and_then(|v| v.as_str()),
Some(long_read_path)
);
assert_eq!(
preview_json.get("mode").and_then(|v| v.as_str()),
Some("preview")
);
assert_eq!(
preview_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(800)
);
assert_eq!(
preview_json.get("truncated").and_then(|v| v.as_bool()),
Some(true)
);
let preview_hint = preview_json
.get("hint")
.and_then(|v| v.as_str())
.expect("preview response should include hint");
assert!(
preview_hint.contains("mode=\"full\""),
"preview hint should include full-read guidance: {preview_hint}"
);
let preview_content = preview_json
.get("content")
.and_then(|v| v.as_str())
.expect("preview response should include content");
assert_eq!(preview_content.lines().count(), 800);
assert!(preview_content.starts_with("line-0001\n"));
assert!(preview_content.ends_with("line-0800\n"));
let preview_tokens = preview_json
.get("approx_tokens_returned")
.and_then(|v| v.as_u64())
.expect("preview response should include approx_tokens_returned");
let preview_total_tokens = preview_json
.get("approx_tokens_total_estimate")
.and_then(|v| v.as_u64())
.expect("preview response should include approx_tokens_total_estimate");
assert!(preview_tokens > 0);
assert!(preview_total_tokens > preview_tokens);
let head_result = server
.test_read_file_with_options(
long_read_path,
ssh_mcp::tools::ReadFileMode::Head,
Some(5),
None,
)
.await
.expect("read-file head call failed");
let head_text = extract_text_from_result(&head_result);
let head_json: serde_json::Value =
serde_json::from_str(head_text.trim()).expect("head response should be valid JSON");
assert_eq!(head_json.get("mode").and_then(|v| v.as_str()), Some("head"));
assert_eq!(
head_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(5)
);
assert_eq!(
head_json.get("content").and_then(|v| v.as_str()),
Some("line-0001\nline-0002\nline-0003\nline-0004\nline-0005\n")
);
let tail_result = server
.test_read_file_with_options(
long_read_path,
ssh_mcp::tools::ReadFileMode::Tail,
Some(4),
None,
)
.await
.expect("read-file tail call failed");
let tail_text = extract_text_from_result(&tail_result);
let tail_json: serde_json::Value =
serde_json::from_str(tail_text.trim()).expect("tail response should be valid JSON");
assert_eq!(tail_json.get("mode").and_then(|v| v.as_str()), Some("tail"));
assert_eq!(
tail_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(4)
);
assert_eq!(
tail_json.get("content").and_then(|v| v.as_str()),
Some("line-1197\nline-1198\nline-1199\nline-1200\n")
);
let full_result = server
.test_read_file_with_options(
long_read_path,
ssh_mcp::tools::ReadFileMode::Full,
None,
None,
)
.await
.expect("read-file full call failed");
let full_text = extract_text_from_result(&full_result);
let full_json: serde_json::Value =
serde_json::from_str(full_text.trim()).expect("full response should be valid JSON");
assert_eq!(full_json.get("mode").and_then(|v| v.as_str()), Some("full"));
assert_eq!(
full_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(1200)
);
assert_eq!(
full_json.get("truncated").and_then(|v| v.as_bool()),
Some(false)
);
assert!(full_json.get("hint").is_none());
let full_content = full_json
.get("content")
.and_then(|v| v.as_str())
.expect("full response should include content");
assert_eq!(full_content.lines().count(), 1200);
let full_tokens = full_json
.get("approx_tokens_returned")
.and_then(|v| v.as_u64())
.expect("full response should include approx_tokens_returned");
let full_total_tokens = full_json
.get("approx_tokens_total_estimate")
.and_then(|v| v.as_u64())
.expect("full response should include approx_tokens_total_estimate");
assert!(full_tokens > 0);
assert_eq!(full_tokens, full_total_tokens);
let huge_path = "/tmp/ssh-mcp-read-file-huge.txt";
server
.test_execute_command(
r#"sh -c 'set -eu; out=/tmp/ssh-mcp-read-file-huge.txt; : > "$out"; i=1; while [ "$i" -le 200000 ]; do printf "huge-line-%06d\n" "$i" >> "$out"; i=$((i+1)); done'"#,
)
.await
.expect("failed to create huge read-file fixture");
let huge_preview_result = server
.test_read_file(huge_path, None)
.await
.expect("read-file preview on huge file failed");
let huge_preview_text = extract_text_from_result(&huge_preview_result);
let huge_preview_json: serde_json::Value = serde_json::from_str(huge_preview_text.trim())
.expect("huge preview response should be valid JSON");
assert_eq!(
huge_preview_json.get("mode").and_then(|v| v.as_str()),
Some("preview")
);
assert_eq!(
huge_preview_json
.get("returned_lines")
.and_then(|v| v.as_u64()),
Some(800)
);
assert_eq!(
huge_preview_json.get("truncated").and_then(|v| v.as_bool()),
Some(true)
);
let huge_preview_content = huge_preview_json
.get("content")
.and_then(|v| v.as_str())
.expect("huge preview should include content");
assert!(huge_preview_content.starts_with("huge-line-000001\n"));
assert!(huge_preview_content.ends_with("huge-line-000800\n"));
let huge_preview_returned = huge_preview_json
.get("approx_tokens_returned")
.and_then(|v| v.as_u64())
.expect("huge preview should include approx_tokens_returned");
let huge_preview_total = huge_preview_json
.get("approx_tokens_total_estimate")
.and_then(|v| v.as_u64())
.expect("huge preview should include approx_tokens_total_estimate");
assert!(
huge_preview_total > huge_preview_returned,
"total token estimate must exceed returned for truncated preview"
);
let huge_head_result = server
.test_read_file_with_options(huge_path, ssh_mcp::tools::ReadFileMode::Head, Some(5), None)
.await
.expect("read-file head on huge file failed");
let huge_head_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&huge_head_result).trim())
.expect("huge head response should be valid JSON");
assert_eq!(
huge_head_json
.get("returned_lines")
.and_then(|v| v.as_u64()),
Some(5)
);
assert_eq!(
huge_head_json.get("content").and_then(|v| v.as_str()),
Some(
"huge-line-000001\nhuge-line-000002\nhuge-line-000003\nhuge-line-000004\nhuge-line-000005\n"
)
);
let huge_tail_result = server
.test_read_file_with_options(huge_path, ssh_mcp::tools::ReadFileMode::Tail, Some(4), None)
.await
.expect("read-file tail on huge file failed");
let huge_tail_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&huge_tail_result).trim())
.expect("huge tail response should be valid JSON");
assert_eq!(
huge_tail_json
.get("returned_lines")
.and_then(|v| v.as_u64()),
Some(4)
);
assert_eq!(
huge_tail_json.get("content").and_then(|v| v.as_str()),
Some("huge-line-199997\nhuge-line-199998\nhuge-line-199999\nhuge-line-200000\n")
);
let huge_full_result = server
.test_read_file_with_options(huge_path, ssh_mcp::tools::ReadFileMode::Full, None, None)
.await
.expect("read-file full on huge file failed");
let huge_full_text = extract_text_from_result(&huge_full_result);
assert!(
huge_full_result.is_error.unwrap_or(false),
"full read of oversized file should error"
);
assert!(
huge_full_text.contains("exceeds read size limit"),
"oversized full read should mention size limit: {huge_full_text}"
);
server
.test_execute_command(&format!(
"rm -f -- {}",
ssh_mcp::escape_for_shell(huge_path)
))
.await
.expect("failed to clean up huge file");
let patch_dir = "/tmp/ssh-mcp-apply-patch";
let add_path = "/tmp/ssh-mcp-apply-patch/added.txt";
let edit_path = "/tmp/ssh-mcp-apply-patch/edit.txt";
let delete_path = "/tmp/ssh-mcp-apply-patch/delete.txt";
server
.test_execute_command(&format!(
r#"sh -c 'set -eu; rm -rf -- {dir}; mkdir -p -- {dir}; printf "alpha\nbeta\nomega\n" > {edit}; printf "remove me\n" > {delete}'"#,
dir = ssh_mcp::escape_for_shell(patch_dir),
edit = ssh_mcp::escape_for_shell(edit_path),
delete = ssh_mcp::escape_for_shell(delete_path),
))
.await
.expect("failed to prepare apply_patch fixtures");
let add_patch = format!("*** Begin Patch\n*** Add File: {add_path}\n+created\n*** End Patch");
let add_result = server
.test_apply_patch(&add_patch)
.await
.expect("apply_patch Add call failed");
assert!(!add_result.is_error.unwrap_or(false));
let add_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&add_result).trim())
.expect("Add response should be valid JSON");
assert_eq!(
add_json,
serde_json::json!({"ok": true, "path": add_path, "operation": "add"})
);
let update_patch = format!(
"*** Begin Patch\n*** Update File: {edit_path}\n@@\n alpha\n-beta\n+gamma\n omega\n*** End Patch"
);
let update_result = server
.test_apply_patch(&update_patch)
.await
.expect("apply_patch Update call failed");
assert!(!update_result.is_error.unwrap_or(false));
let updated = server
.test_execute_command(&format!("cat -- {}", ssh_mcp::escape_for_shell(edit_path)))
.await
.expect("failed to inspect updated file");
assert_eq!(extract_text_from_result(&updated), "alpha\ngamma\nomega\n");
let conflict_patch =
format!("*** Begin Patch\n*** Update File: {edit_path}\n@@\n-gamma\n+delta\n*** End Patch");
let race_result = server
.test_apply_patch_mutate_before_commit(&conflict_patch)
.await
.expect("apply_patch race call failed");
assert!(race_result.is_error.unwrap_or(false));
let race_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&race_result).trim())
.expect("race response should be valid JSON");
assert_eq!(
race_json.get("error").and_then(|v| v.as_str()),
Some("conflict")
);
let delete_patch = format!("*** Begin Patch\n*** Delete File: {delete_path}\n*** End Patch");
let delete_result = server
.test_apply_patch(&delete_patch)
.await
.expect("apply_patch Delete call failed");
assert!(!delete_result.is_error.unwrap_or(false));
let delete_probe = server
.test_execute_command(&format!(
"test ! -e {} && printf deleted",
ssh_mcp::escape_for_shell(delete_path)
))
.await
.expect("failed to verify deleted file");
assert_eq!(extract_text_from_result(&delete_probe), "deleted");
let timeout_result = server
.test_execute_command_with_timeout_ms("sleep 2; echo done", 1100)
.await
.expect("exec command with timeout override failed");
let timeout_text = extract_text_from_result(&timeout_result);
let timeout_json: serde_json::Value = serde_json::from_str(timeout_text.trim())
.expect("timeout->detach response should be valid JSON");
assert_eq!(
timeout_json.get("ok").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
timeout_json.get("timeout").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
timeout_json.get("background").and_then(|v| v.as_bool()),
Some(true)
);
assert!(
timeout_json.get("hint").and_then(|v| v.as_str()).is_some(),
"timeout->detach response should include a pragmatic hint"
);
let log_path = timeout_json
.get("log_path")
.and_then(|v| v.as_str())
.expect("timeout->detach response should include log_path");
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
let poll_interval = tokio::time::Duration::from_millis(250);
let mut last_log_text = String::new();
loop {
if tokio::time::Instant::now() >= deadline {
panic!(
"detached job log did not contain 'done' within deadline; last log: '{}'",
last_log_text
);
}
let log_text = match tokio::fs::read_to_string(log_path).await {
Ok(s) => s,
Err(e) => {
last_log_text = format!("<read error: {e}>");
tokio::time::sleep(poll_interval).await;
continue;
}
};
last_log_text = log_text.clone();
if log_text.contains("done") {
break;
}
tokio::time::sleep(poll_interval).await;
}
let sudo_result = server
.test_execute_sudo_command("whoami")
.await
.expect("sudo command failed");
let sudo_output = extract_text_from_result(&sudo_result);
let sudo_output = sudo_output.trim();
if sudo_output.contains("root") {
tracing::info!("sudo_shell behavior verified: whoami returned 'root'");
} else {
tracing::warn!(
"sudo_shell 'whoami' did not return 'root', got: '{}'",
sudo_output
);
tracing::warn!("This may be due to container sudo configuration limitations");
}
server.shutdown().await;
tracing::info!("Server shut down successfully");
}
#[cfg(unix)]
mod unix_transfer_tests {
use super::*;
#[tokio::test]
async fn test_transfer_auto_uses_sftp_when_available() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
if !check_openssh_client("sftp") {
tracing::warn!("skipping: local 'sftp' client unavailable");
return;
}
if !check_openssh_client("scp") {
tracing::warn!("skipping: local 'scp' client unavailable");
return;
}
let key_dir = tempfile::TempDir::new().expect("tempdir");
let key_path = key_dir.path().join("id_ed25519");
std::fs::write(&key_path, TEST_PRIVATE_KEY).expect("write private key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&key_path, perms).expect("chmod key");
}
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: None,
key: Some(key_path.clone()),
su_password: None,
sudo_password: None,
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: true,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
assert!(!remote_home.is_empty(), "remote HOME should not be empty");
let unique = format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_base =
std::path::PathBuf::from("target/tmp").join(format!("transfer-it-{unique}"));
std::fs::create_dir_all(&local_base).expect("create local base");
let local_file = local_base.join("hello.txt");
std::fs::write(&local_file, "hello via transfer\n").expect("write local file");
let local_path_param = local_file.to_string_lossy().to_string();
let remote_file = format!("{remote_home}/hello.txt");
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param,
remote_path: remote_file.clone(),
transport: TransferTransport::Auto,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(resp.ok, "transfer should succeed: {:?}", resp.error);
assert_eq!(
resp.transport_used,
TransferTransport::Sftp,
"auto should prefer sftp when available"
);
let verify = server
.test_execute_command(&format!(
"sh -c 'cat < {}'",
ssh_mcp::escape_for_shell(&remote_file)
))
.await
.expect("verify remote file");
let verify_text = extract_text_from_result(&verify);
assert!(verify_text.contains("hello via transfer"));
let local_dir = local_base.join("dir");
std::fs::create_dir_all(local_dir.join("nested")).expect("create local dir tree");
std::fs::write(local_dir.join("nested").join("a.txt"), "a\n").expect("write nested file");
let local_dir_param = local_dir.to_string_lossy().to_string();
let remote_dir = format!("{remote_home}/recv-dir");
let dir_resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_dir_param,
remote_path: remote_dir.clone(),
transport: TransferTransport::Auto,
kind: Some(TransferKind::Directory),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
dir_resp.ok,
"directory transfer should succeed: {:?}",
dir_resp.error
);
let dir_verify = server
.test_execute_command(&format!(
"sh -c 'test -f {}/nested/a.txt && printf ok'",
ssh_mcp::escape_for_shell(&remote_dir)
))
.await
.expect("verify remote dir");
assert!(extract_text_from_result(&dir_verify).contains("ok"));
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}
}
#[tokio::test]
async fn test_compact_response_has_paths() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: Some("secret".to_string()),
key: None,
su_password: None,
sudo_password: Some("secret".to_string()),
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: false,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp = std::path::PathBuf::from("target/tmp").join(format!("compact-test-{unique}"));
std::fs::create_dir_all(&temp).expect("create temp dir");
let test_file = temp.join("test.txt");
tokio::fs::write(&test_file, "test content for compact response")
.await
.expect("write test file");
let local_path_param = test_file.to_string_lossy().to_string();
let remote_file = format!("{}/test_compact.txt", remote_home);
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param.clone(),
remote_path: remote_file.clone(),
transport: TransferTransport::ExecRaw,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false, ..Default::default()
})
.await;
assert!(resp.ok, "transfer should succeed: {:?}", resp.error);
let compact_json = resp
.to_json(false)
.expect("compact JSON serialization failed");
let json: serde_json::Value =
serde_json::from_str(&compact_json).expect("compact JSON parse failed");
assert_eq!(json["ok"], true, "compact response should have ok=true");
assert_eq!(
json["local_path"].as_str().unwrap_or_default(),
local_path_param,
"compact response should have local_path"
);
assert_eq!(
json["remote_path"].as_str().unwrap_or_default(),
remote_file,
"compact response should have remote_path"
);
let bytes = json["counts"]["bytes"].as_u64().unwrap_or(0);
assert!(
bytes > 0,
"compact response should have counts.bytes > 0, got {bytes}"
);
assert!(
!json["transport_used"].is_null(),
"compact response should have transport_used"
);
assert!(
json["staging"].is_null(),
"compact response should NOT have staging"
);
assert!(
json["resolved"].is_null(),
"compact response should NOT have resolved"
);
let verbose_resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param.clone(),
remote_path: remote_file.clone(),
transport: TransferTransport::ExecRaw,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: true, ..Default::default()
})
.await;
assert!(
verbose_resp.ok,
"verbose transfer should succeed: {:?}",
verbose_resp.error
);
let verbose_json = verbose_resp
.to_json(true)
.expect("verbose JSON serialization failed");
let json: serde_json::Value =
serde_json::from_str(&verbose_json).expect("verbose JSON parse failed");
assert_eq!(json["ok"], true, "verbose response should have ok=true");
assert_eq!(
json["params"]["local_path"].as_str().unwrap_or_default(),
local_path_param,
"verbose response should have local_path in params"
);
assert_eq!(
json["params"]["remote_path"].as_str().unwrap_or_default(),
remote_file,
"verbose response should have remote_path in params"
);
assert!(
!json["transport_used"].is_null(),
"verbose response should have transport_used"
);
assert!(
!json["params"].is_null(),
"verbose response should have params"
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&temp);
}