diff --git a/.github/workflows/core-build-checks.yml b/.github/workflows/core-build-checks.yml index a738e619..e02c377e 100644 --- a/.github/workflows/core-build-checks.yml +++ b/.github/workflows/core-build-checks.yml @@ -51,6 +51,21 @@ jobs: working-directory: core run: cargo test --locked -p cortexbrain-common --test public_contracts --no-default-features --features network-structs,monitoring-structs + test-cli: + name: Test CLI + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install prerequisites + run: sudo apt-get install -y protobuf-compiler + + - name: Test CLI + working-directory: cli + run: cargo test --locked + build-core-components: runs-on: ubuntu-latest diff --git a/cli/Cargo.lock b/cli/Cargo.lock index e64cbf66..62be59d5 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -377,18 +377,23 @@ name = "cortexflow-cli" version = "0.1.5" dependencies = [ "anyhow", + "bytes", "clap", "colored", "cortexflow_agent_api", "directories", + "http", + "http-body-util", "k8s-openapi", "kube", "prost", "prost-types", "serde", + "serde_json", "tokio", "tonic", "tonic-reflection", + "tower", "tracing", ] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 11041c39..abd55f56 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -26,6 +26,13 @@ cortexflow_agent_api = {version="0.1.2",features = ["client"]} kube = "2.0.1" k8s-openapi = {version = "0.26.0", features = ["v1_34"]} +[dev-dependencies] +serde_json = "1.0" +tower = { version = "0.5", features = ["util"] } +http = "1" +http-body-util = "0.1" +bytes = "1" + [[bin]] name = "cfcli" path = "src/main.rs" diff --git a/cli/src/command_runner.rs b/cli/src/command_runner.rs new file mode 100644 index 00000000..2a6489c1 --- /dev/null +++ b/cli/src/command_runner.rs @@ -0,0 +1,76 @@ +use std::process::{Command, Output}; + +// docs: +// +// Abstraction over external process execution (kubectl, cargo, ...) so callers +// can inject a stub in tests instead of shelling out to a real binary/cluster. + +pub trait CommandRunner { + fn run(&self, program: &str, args: &[String]) -> std::io::Result; +} + +pub struct RealCommandRunner; + +impl CommandRunner for RealCommandRunner { + fn run(&self, program: &str, args: &[String]) -> std::io::Result { + Command::new(program).args(args).output() + } +} + +#[cfg(test)] +pub mod test_support { + use super::CommandRunner; + use std::process::{ExitStatus, Output}; + + #[cfg(unix)] + fn exit_status(success: bool) -> ExitStatus { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(if success { 0 } else { 1 << 8 }) + } + + // Stubs process output so tests never shell out to a real binary or cluster. + pub struct StubCommandRunner { + result: std::io::Result, + } + + impl StubCommandRunner { + pub fn success(stdout: &str) -> Self { + Self { + result: Ok(Output { + status: exit_status(true), + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + }), + } + } + + pub fn failure(stderr: &str) -> Self { + Self { + result: Ok(Output { + status: exit_status(false), + stdout: Vec::new(), + stderr: stderr.as_bytes().to_vec(), + }), + } + } + + pub fn io_error() -> Self { + Self { + result: Err(std::io::Error::other("command not found")), + } + } + } + + impl CommandRunner for StubCommandRunner { + fn run(&self, _program: &str, _args: &[String]) -> std::io::Result { + match &self.result { + Ok(output) => Ok(Output { + status: output.status, + stdout: output.stdout.clone(), + stderr: output.stderr.clone(), + }), + Err(_) => Err(std::io::Error::other("command not found")), + } + } + } +} diff --git a/cli/src/errors.rs b/cli/src/errors.rs index b813e35b..fde81563 100644 --- a/cli/src/errors.rs +++ b/cli/src/errors.rs @@ -122,3 +122,18 @@ impl fmt::Display for CliError { } } } + +# [cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_base_error() { + let err = CliError::BaseError { + reason: "some reason".to_string(), + }; + let err_str = format!("{}", err); + assert!(err_str.contains("An error occured. Reason:")); + assert!(err_str.contains("some reason")); + } +} \ No newline at end of file diff --git a/cli/src/essential.rs b/cli/src/essential.rs index 5ca01b9a..a386f3a3 100644 --- a/cli/src/essential.rs +++ b/cli/src/essential.rs @@ -1,3 +1,4 @@ +use crate::command_runner::{CommandRunner, RealCommandRunner}; use crate::errors::CliError; use std::borrow::Cow; use std::thread; @@ -130,10 +131,17 @@ pub fn update_cli() -> Result<(), CliError> { // // This function returns the latest version of the CLI from the crates.io registry pub fn get_latest_cfcli_version() -> Result { - let output = Command::new("cargo") - .args(["search", "cortexflow-cli", "--limit", "1"]) - .output() - .expect("Error"); + get_latest_cfcli_version_with(&RealCommandRunner) +} + +fn get_latest_cfcli_version_with(runner: &dyn CommandRunner) -> Result { + let args = [ + "search".to_string(), + "cortexflow-cli".to_string(), + "--limit".to_string(), + "1".to_string(), + ]; + let output = runner.run("cargo", &args).expect("Error"); if !output.status.success() { return Err(CliError::InstallerError { @@ -410,10 +418,18 @@ pub async fn update_configmap(config_struct: MetadataConfigFile) -> Result<(), C #[cfg(test)] mod tests { - use crate::essential::extract_version_from_output; + use crate::command_runner::test_support::StubCommandRunner; + use crate::essential::{create_configs, extract_version_from_output, get_latest_cfcli_version_with}; + + #[test] + fn creates_an_empty_blocklist_configuration() { + let configs = create_configs(); + + assert_eq!(configs.blocklist, vec![String::new()]); + } #[test] - fn test_version_extraction() { + fn extracts_the_version_from_cargo_search_output() { let command_stdout = String::from( r#"cortexflow-cli = "0.1.4-test_123" # CortexFlow command line interface made to interact with the CortexBrain core components... @@ -423,4 +439,19 @@ mod tests { let extracted_command = extract_version_from_output(command_stdout.into()); assert_eq!(extracted_command, "0.1.4-test_123"); } + + #[test] + fn get_latest_cfcli_version_returns_parsed_version_on_success() { + let runner = StubCommandRunner::success( + r#"cortexflow-cli = "0.1.5" # CortexFlow command line interface"#, + ); + let version = get_latest_cfcli_version_with(&runner); + assert_eq!(version.unwrap(), "0.1.5"); + } + + #[test] + fn get_latest_cfcli_version_errors_on_command_failure() { + let runner = StubCommandRunner::failure("network unreachable"); + assert!(get_latest_cfcli_version_with(&runner).is_err()); + } } diff --git a/cli/src/install.rs b/cli/src/install.rs index 105853c0..1553726a 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -1,3 +1,4 @@ +use crate::command_runner::{CommandRunner, RealCommandRunner}; use crate::errors::CliError; use crate::essential::{BASE_COMMAND, connect_to_client, create_config_file, create_configs}; use clap::{Args, Subcommand}; @@ -213,54 +214,59 @@ async fn install_simple_example_component() -> Result<(), CliError> { // docs: pub async fn install_blocklist_configmap() -> Result<(), CliError> { match connect_to_client().await { - Ok(client) => { - println!( - "{} {}", - "=====>".blue().bold(), - "Checking if the Blocklist configmap exists" - ); - sleep(Duration::from_secs(1)); - let blocklist_exists = check_if_blocklist_exists(client).await?; - if !blocklist_exists { + Ok(client) => install_blocklist_configmap_with_client(client).await, + Err(e) => Err(CliError::ClientError(Error::Api(ErrorResponse { + status: "failed".to_string(), + message: "Failed to connect to kubernetes client".to_string(), + reason: e.to_string(), + code: 404, + }))), + } +} + +// docs: +// +// Split out of install_blocklist_configmap so the blocklist-checking logic can be +// exercised in tests against a fake kube Client, without requiring a real cluster. + +async fn install_blocklist_configmap_with_client(client: Client) -> Result<(), CliError> { + println!( + "{} {}", + "=====>".blue().bold(), + "Checking if the Blocklist configmap exists" + ); + sleep(Duration::from_secs(1)); + let blocklist_exists = check_if_blocklist_exists(client).await?; + if !blocklist_exists { + println!( + "{} {}", + "=====>".blue().bold(), + "Blocklist configmap does not exist".red().bold() + ); + sleep(Duration::from_secs(1)); + println!("{} {}", "=====>".bold().blue(), "Creating configmap"); + let metdata_configs = create_configs(); + sleep(Duration::from_secs(1)); + match create_config_file(metdata_configs).await { + Ok(_) => { println!( "{} {}", - "=====>".blue().bold(), - "Blocklist configmap does not exist".red().bold() - ); - sleep(Duration::from_secs(1)); - println!("{} {}", "=====>".bold().blue(), "Creating configmap"); - let metdata_configs = create_configs(); - sleep(Duration::from_secs(1)); - match create_config_file(metdata_configs).await { - Ok(_) => { - println!( - "{} {}", - "=====>".bold().blue(), - "Configmap created/repaired successfully".bold().green() - ) - } - Err(e) => { - return Err(CliError::InstallerError { - reason: e.to_string(), - }); - } - } - return Ok(()); - } else { - println!() + "=====>".bold().blue(), + "Configmap created/repaired successfully".bold().green() + ) + } + Err(e) => { + return Err(CliError::InstallerError { + reason: e.to_string(), + }); } - - Ok(()) - } - Err(e) => { - return Err(CliError::ClientError(Error::Api(ErrorResponse { - status: "failed".to_string(), - message: "Failed to connect to kubernetes client".to_string(), - reason: e.to_string(), - code: 404, - }))); } + return Ok(()); + } else { + println!() } + + Ok(()) } // docs: @@ -297,6 +303,13 @@ async fn check_if_blocklist_exists(client: Client) -> Result { // fn install_components(components_type: &str) -> Result<(), CliError> { + install_components_with(&RealCommandRunner, components_type) +} + +fn install_components_with( + runner: &dyn CommandRunner, + components_type: &str, +) -> Result<(), CliError> { if components_type == "cortexbrain" { let files_to_install = vec![ "configmap-role.yaml", @@ -327,7 +340,7 @@ fn install_components(components_type: &str) -> Result<(), CliError> { "Applying", component.to_string().green().bold() ); - apply_component(component)?; + apply_component_with(runner, component)?; i = i + 1; } } else if components_type == "simple-example" { @@ -347,7 +360,7 @@ fn install_components(components_type: &str) -> Result<(), CliError> { "Applying", component.to_string().green().bold() ); - apply_component(component)?; + apply_component_with(runner, component)?; i = i + 1; } } else { @@ -367,10 +380,10 @@ fn install_components(components_type: &str) -> Result<(), CliError> { // // Returns an CliError if something fails -fn apply_component(file: &str) -> Result<(), CliError> { - let output = Command::new(BASE_COMMAND) - .args(["apply", "-f", file]) - .output() +fn apply_component_with(runner: &dyn CommandRunner, file: &str) -> Result<(), CliError> { + let args = ["apply".to_string(), "-f".to_string(), file.to_string()]; + let output = runner + .run(BASE_COMMAND, &args) .map_err(|e| CliError::InstallerError { reason: e.to_string(), })?; @@ -453,13 +466,16 @@ fn rm_installation_files(file_to_remove: InstallationType) -> Result<(), CliErro // Returns a CliError if something fails fn download_file(src: &str) -> Result<(), CliError> { - let output = - Command::new("wget") - .args([src]) - .output() - .map_err(|e| CliError::InstallerError { - reason: e.to_string(), - })?; + download_file_with(&RealCommandRunner, src) +} + +fn download_file_with(runner: &dyn CommandRunner, src: &str) -> Result<(), CliError> { + let args = [src.to_string()]; + let output = runner + .run("wget", &args) + .map_err(|e| CliError::InstallerError { + reason: e.to_string(), + })?; if !output.status.success() { return Err(CliError::InstallerError { @@ -486,6 +502,13 @@ fn download_file(src: &str) -> Result<(), CliError> { // Returns an CliError if something fails fn rm_file(file_to_remove: &str) -> Result<(), CliError> { + // rm -f never fails for a missing file, so check existence first + if !std::path::Path::new(file_to_remove).exists() { + return Err(CliError::InstallerError { + reason: format!("File not found: {}", file_to_remove), + }); + } + let output = Command::new("rm") .args(["-f", file_to_remove]) .output() @@ -508,3 +531,158 @@ fn rm_file(file_to_remove: &str) -> Result<(), CliError> { thread::sleep(Duration::from_secs(2)); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn test_download_file_with_success() { + let runner = crate::command_runner::test_support::StubCommandRunner::success(""); + assert!(download_file_with(&runner, "https://example.com/file.yaml").is_ok()); + } + + #[test] + fn test_download_file_with_command_failure() { + let runner = crate::command_runner::test_support::StubCommandRunner::failure("404 Not Found"); + assert!(download_file_with(&runner, "https://example.com/missing.yaml").is_err()); + } + + #[test] + fn test_download_file_with_io_error() { + let runner = crate::command_runner::test_support::StubCommandRunner::io_error(); + assert!(download_file_with(&runner, "https://example.com/file.yaml").is_err()); + } + + #[test] + fn test_rm_file_failure_cleanup() { + let file_name = "test_rm_file_failure_cleanup.txt"; + // Attempt to clean up a non-existent file + assert!(rm_file(file_name).is_err()); + } + + #[test] + fn test_rm_file_success_cleanup() { + let file_name = "test_rm_file_success_cleanup.txt"; + std::fs::write(file_name, "test content").unwrap(); + // Clean up a file that exists + assert!(rm_file(file_name).is_ok()); + } + + // docs: builds a fake kube Client backed by an in-memory tower service, so the + // Kubernetes API calls never leave the process or touch a real cluster. + fn mock_client(status: http::StatusCode, body: String) -> Client { + let service = tower::service_fn(move |_req: http::Request| { + let body = body.clone(); + async move { + let response = http::Response::builder() + .status(status) + .body(http_body_util::Full::new(bytes::Bytes::from(body))) + .unwrap(); + Ok::<_, std::convert::Infallible>(response) + } + }); + Client::new(service, "cortexflow") + } + + fn configmap_found_body() -> String { + serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "cortexbrain-client-config", + "namespace": "cortexflow" + }, + "data": {} + }) + .to_string() + } + + fn configmap_not_found_body() -> String { + serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Failure", + "message": "configmaps \"cortexbrain-client-config\" not found", + "reason": "NotFound", + "code": 404 + }) + .to_string() + } + + #[tokio::test] + async fn test_check_if_blocklist_exists_true() { + let client = mock_client(http::StatusCode::OK, configmap_found_body()); + let exists = check_if_blocklist_exists(client).await.unwrap(); + assert!(exists); + } + + #[tokio::test] + async fn test_check_if_blocklist_exists_false() { + let client = mock_client(http::StatusCode::NOT_FOUND, configmap_not_found_body()); + let exists = check_if_blocklist_exists(client).await.unwrap(); + assert!(!exists); + } + + #[tokio::test] + async fn test_install_blocklist_configmap_with_client_already_exists() { + // When the configmap already exists no creation attempt is made, so this + // is safe to exercise end-to-end against the fake client. + let client = mock_client(http::StatusCode::OK, configmap_found_body()); + assert!(install_blocklist_configmap_with_client(client).await.is_ok()); + } + + #[test] + fn test_install_components_with_invalid_type() { + let runner = crate::command_runner::test_support::StubCommandRunner::success(""); + assert!(install_components_with(&runner, "not-a-real-type").is_err()); + } + + #[test] + fn test_install_components_with_simple_example_success() { + let runner = crate::command_runner::test_support::StubCommandRunner::success(""); + assert!(install_components_with(&runner, "simple-example").is_ok()); + } + + #[test] + fn test_install_components_with_cortexbrain_success() { + let runner = crate::command_runner::test_support::StubCommandRunner::success(""); + assert!(install_components_with(&runner, "cortexbrain").is_ok()); + } + + #[test] + fn test_install_components_with_apply_failure() { + let runner = crate::command_runner::test_support::StubCommandRunner::failure("kubectl error"); + assert!(install_components_with(&runner, "simple-example").is_err()); + } + + #[derive(clap::Parser)] + struct InstallCommandsHarness { + #[command(subcommand)] + cmd: InstallCommands, + } + + #[test] + fn test_install_commands_parses_cortexflow() { + let parsed = InstallCommandsHarness::try_parse_from(["cfcli", "cortexflow"]).unwrap(); + assert!(matches!(parsed.cmd, InstallCommands::All)); + } + + #[test] + fn test_install_commands_parses_simple_example() { + let parsed = InstallCommandsHarness::try_parse_from(["cfcli", "simple-example"]).unwrap(); + assert!(matches!(parsed.cmd, InstallCommands::TestPods)); + } + + #[test] + fn test_install_commands_parses_blocklist() { + let parsed = InstallCommandsHarness::try_parse_from(["cfcli", "blocklist"]).unwrap(); + assert!(matches!(parsed.cmd, InstallCommands::Blocklist)); + } + + #[test] + fn test_install_commands_rejects_unknown_subcommand() { + assert!(InstallCommandsHarness::try_parse_from(["cfcli", "not-a-command"]).is_err()); + } +} \ No newline at end of file diff --git a/cli/src/logs.rs b/cli/src/logs.rs index 102d97b6..863cd33b 100644 --- a/cli/src/logs.rs +++ b/cli/src/logs.rs @@ -1,3 +1,4 @@ +use crate::command_runner::{CommandRunner, RealCommandRunner}; use crate::errors::CliError; use crate::essential::{BASE_COMMAND, connect_to_client}; use clap::Args; @@ -5,6 +6,15 @@ use colored::Colorize; use kube::{Error, core::ErrorResponse}; use std::{process::Command, result::Result::Ok, str}; +fn parse_lines(stdout: &[u8]) -> Vec { + str::from_utf8(stdout) + .unwrap_or("") + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect() +} + #[derive(Args, Debug, Clone)] pub struct LogsArgs { #[arg(long)] @@ -182,16 +192,7 @@ pub async fn logs_command( pub async fn check_namespace_exists(namespace: &str) -> Result { match connect_to_client().await { - Ok(_) => { - let output = Command::new(BASE_COMMAND) - .args(["get", "namespace", namespace]) - .output(); - - match output { - Ok(output) => Ok(output.status.success()), - Err(_) => Ok(false), - } - } + Ok(_) => Ok(check_namespace_exists_with(&RealCommandRunner, namespace)), Err(e) => { return Err(CliError::ClientError(Error::Api(ErrorResponse { status: "failed".to_string(), @@ -203,6 +204,14 @@ pub async fn check_namespace_exists(namespace: &str) -> Result { } } +fn check_namespace_exists_with(runner: &dyn CommandRunner, namespace: &str) -> bool { + let args = ["get".to_string(), "namespace".to_string(), namespace.to_string()]; + match runner.run(BASE_COMMAND, &args) { + Ok(output) => output.status.success(), + Err(_) => false, + } +} + // docs: // // This function returns the available namespaces: @@ -215,30 +224,7 @@ pub async fn check_namespace_exists(namespace: &str) -> Result { pub async fn get_available_namespaces() -> Result, CliError> { match connect_to_client().await { - Ok(_) => { - let output = Command::new(BASE_COMMAND) - .args([ - "get", - "namespaces", - "--no-headers", - "-o", - "custom-columns=NAME:.metadata.name", - ]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let stdout = str::from_utf8(&output.stdout).unwrap_or(""); - let ns = stdout - .lines() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()) - .collect(); - Ok(ns) - } - _ => Ok(Vec::new()), - } - } + Ok(_) => Ok(get_available_namespaces_with(&RealCommandRunner)), Err(e) => { return Err(CliError::ClientError(Error::Api(ErrorResponse { status: "failed".to_string(), @@ -250,6 +236,21 @@ pub async fn get_available_namespaces() -> Result, CliError> { } } +fn get_available_namespaces_with(runner: &dyn CommandRunner) -> Vec { + let args = [ + "get".to_string(), + "namespaces".to_string(), + "--no-headers".to_string(), + "-o".to_string(), + "custom-columns=NAME:.metadata.name".to_string(), + ]; + + match runner.run(BASE_COMMAND, &args) { + Ok(output) if output.status.success() => parse_lines(&output.stdout), + _ => Vec::new(), + } +} + // docs: // // This function returns the pods: @@ -265,34 +266,11 @@ async fn get_pods_for_service( service_name: &str, ) -> Result, CliError> { match connect_to_client().await { - Ok(_) => { - let output = Command::new(BASE_COMMAND) - .args([ - "get", - "pods", - "-n", - namespace, - "-l", - &format!("app={}", service_name), - "--no-headers", - "-o", - "custom-columns=NAME:.metadata.name", - ]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let stdout = str::from_utf8(&output.stdout).unwrap_or(""); - let pods = stdout - .lines() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()) - .collect(); - Ok(pods) - } - _ => Ok(Vec::new()), - } - } + Ok(_) => Ok(get_pods_for_service_with( + &RealCommandRunner, + namespace, + service_name, + )), Err(e) => { return Err(CliError::ClientError(Error::Api(ErrorResponse { status: "failed".to_string(), @@ -304,6 +282,29 @@ async fn get_pods_for_service( } } +fn get_pods_for_service_with( + runner: &dyn CommandRunner, + namespace: &str, + service_name: &str, +) -> Vec { + let args = [ + "get".to_string(), + "pods".to_string(), + "-n".to_string(), + namespace.to_string(), + "-l".to_string(), + format!("app={}", service_name), + "--no-headers".to_string(), + "-o".to_string(), + "custom-columns=NAME:.metadata.name".to_string(), + ]; + + match runner.run(BASE_COMMAND, &args) { + Ok(output) if output.status.success() => parse_lines(&output.stdout), + _ => Vec::new(), + } +} + // docs: // // This function returns the pods: @@ -320,34 +321,11 @@ async fn get_pods_for_component( component: &Component, ) -> Result, CliError> { match connect_to_client().await { - Ok(_) => { - let output = Command::new(BASE_COMMAND) - .args([ - "get", - "pods", - "-n", - namespace, - "-l", - component.to_label_selector(), - "--no-headers", - "-o", - "custom-columns=NAME:.metadata.name", - ]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let stdout = str::from_utf8(&output.stdout).unwrap_or(""); - let pods = stdout - .lines() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()) - .collect(); - Ok(pods) - } - _ => Ok(Vec::new()), - } - } + Ok(_) => Ok(get_pods_for_component_with( + &RealCommandRunner, + namespace, + component, + )), Err(e) => { return Err(CliError::ClientError(Error::Api(ErrorResponse { status: "failed".to_string(), @@ -359,6 +337,29 @@ async fn get_pods_for_component( } } +fn get_pods_for_component_with( + runner: &dyn CommandRunner, + namespace: &str, + component: &Component, +) -> Vec { + let args = [ + "get".to_string(), + "pods".to_string(), + "-n".to_string(), + namespace.to_string(), + "-l".to_string(), + component.to_label_selector().to_string(), + "--no-headers".to_string(), + "-o".to_string(), + "custom-columns=NAME:.metadata.name".to_string(), + ]; + + match runner.run(BASE_COMMAND, &args) { + Ok(output) if output.status.success() => parse_lines(&output.stdout), + _ => Vec::new(), + } +} + // docs: // // This function returns the available namespaces: @@ -371,32 +372,7 @@ async fn get_pods_for_component( async fn get_all_pods(namespace: &str) -> Result, CliError> { match connect_to_client().await { - Ok(_) => { - let output = Command::new(BASE_COMMAND) - .args([ - "get", - "pods", - "-n", - namespace, - "--no-headers", - "-o", - "custom-columns=NAME:.metadata.name", - ]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let stdout = str::from_utf8(&output.stdout).unwrap_or(""); - let pods = stdout - .lines() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()) - .collect(); - Ok(pods) - } - _ => Ok(Vec::new()), - } - } + Ok(_) => Ok(get_all_pods_with(&RealCommandRunner, namespace)), Err(e) => { return Err(CliError::ClientError(Error::Api(ErrorResponse { status: "failed".to_string(), @@ -407,3 +383,216 @@ async fn get_all_pods(namespace: &str) -> Result, CliError> { } } } + +fn get_all_pods_with(runner: &dyn CommandRunner, namespace: &str) -> Vec { + let args = [ + "get".to_string(), + "pods".to_string(), + "-n".to_string(), + namespace.to_string(), + "--no-headers".to_string(), + "-o".to_string(), + "custom-columns=NAME:.metadata.name".to_string(), + ]; + + match runner.run(BASE_COMMAND, &args) { + Ok(output) if output.status.success() => parse_lines(&output.stdout), + _ => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command_runner::test_support::StubCommandRunner; + + // Component + + #[test] + fn test_component_from_control_plane() { + assert!(matches!( + Component::from("control-plane".to_string()), + Component::ControlPlane + )); + } + + #[test] + fn test_component_from_data_plane() { + assert!(matches!( + Component::from("data-plane".to_string()), + Component::DataPlane + )); + } + + #[test] + fn test_component_from_is_case_insensitive() { + assert!(matches!( + Component::from("Data-Plane".to_string()), + Component::DataPlane + )); + } + + #[test] + fn test_component_from_unknown_defaults_to_control_plane() { + assert!(matches!( + Component::from("unknown".to_string()), + Component::ControlPlane + )); + } + + #[test] + fn test_component_to_label_selector() { + assert_eq!( + Component::ControlPlane.to_label_selector(), + "component=control-plane" + ); + assert_eq!( + Component::DataPlane.to_label_selector(), + "component=data-plane" + ); + } + + // parse_lines + + #[test] + fn test_parse_lines_filters_empty_lines_and_trims() { + let stdout = b" pod-a \n\npod-b\n \n"; + let parsed = parse_lines(stdout); + assert_eq!(parsed, vec!["pod-a".to_string(), "pod-b".to_string()]); + } + + #[test] + fn test_parse_lines_empty_input() { + assert!(parse_lines(b"").is_empty()); + } + + // check_namespace_exists_with + + #[test] + fn test_check_namespace_exists_with_success() { + let runner = StubCommandRunner::success(""); + assert!(check_namespace_exists_with(&runner, "cortexflow")); + } + + #[test] + fn test_check_namespace_exists_with_failure() { + let runner = StubCommandRunner::failure("not found"); + assert!(!check_namespace_exists_with(&runner, "missing")); + } + + #[test] + fn test_check_namespace_exists_with_io_error() { + let runner = StubCommandRunner::io_error(); + assert!(!check_namespace_exists_with(&runner, "cortexflow")); + } + + // get_available_namespaces_with + + #[test] + fn test_get_available_namespaces_with_success() { + let runner = StubCommandRunner::success("default\ncortexflow\nkube-system\n"); + let namespaces = get_available_namespaces_with(&runner); + assert_eq!( + namespaces, + vec![ + "default".to_string(), + "cortexflow".to_string(), + "kube-system".to_string() + ] + ); + } + + #[test] + fn test_get_available_namespaces_with_empty_output() { + let runner = StubCommandRunner::success(""); + assert!(get_available_namespaces_with(&runner).is_empty()); + } + + #[test] + fn test_get_available_namespaces_with_command_failure() { + let runner = StubCommandRunner::failure("connection refused"); + assert!(get_available_namespaces_with(&runner).is_empty()); + } + + // get_pods_for_service_with + + #[test] + fn test_get_pods_for_service_with_success() { + let runner = StubCommandRunner::success("pod-a\npod-b\n"); + let pods = get_pods_for_service_with(&runner, "cortexflow", "my-service"); + assert_eq!(pods, vec!["pod-a".to_string(), "pod-b".to_string()]); + } + + #[test] + fn test_get_pods_for_service_with_no_matches() { + let runner = StubCommandRunner::success(""); + assert!(get_pods_for_service_with(&runner, "cortexflow", "unknown-service").is_empty()); + } + + #[test] + fn test_get_pods_for_service_with_command_failure() { + let runner = StubCommandRunner::failure("error"); + assert!(get_pods_for_service_with(&runner, "cortexflow", "my-service").is_empty()); + } + + // get_pods_for_component_with + + #[test] + fn test_get_pods_for_component_with_success() { + let runner = StubCommandRunner::success("agent-pod\n"); + let pods = get_pods_for_component_with(&runner, "cortexflow", &Component::DataPlane); + assert_eq!(pods, vec!["agent-pod".to_string()]); + } + + #[test] + fn test_get_pods_for_component_with_no_matches() { + let runner = StubCommandRunner::success(""); + assert!( + get_pods_for_component_with(&runner, "cortexflow", &Component::ControlPlane) + .is_empty() + ); + } + + #[test] + fn test_get_pods_for_component_with_command_failure() { + let runner = StubCommandRunner::failure("error"); + assert!( + get_pods_for_component_with(&runner, "cortexflow", &Component::ControlPlane) + .is_empty() + ); + } + + // get_all_pods_with + + #[test] + fn test_get_all_pods_with_success() { + let runner = StubCommandRunner::success("pod-a\npod-b\npod-c\n"); + let pods = get_all_pods_with(&runner, "cortexflow"); + assert_eq!( + pods, + vec![ + "pod-a".to_string(), + "pod-b".to_string(), + "pod-c".to_string() + ] + ); + } + + #[test] + fn test_get_all_pods_with_empty_namespace() { + let runner = StubCommandRunner::success(""); + assert!(get_all_pods_with(&runner, "empty_namespace").is_empty()); + } + + #[test] + fn test_get_all_pods_with_command_failure() { + let runner = StubCommandRunner::failure("namespace not found"); + assert!(get_all_pods_with(&runner, "non_existent_namespace").is_empty()); + } + + #[test] + fn test_get_all_pods_with_io_error() { + let runner = StubCommandRunner::io_error(); + assert!(get_all_pods_with(&runner, "cortexflow").is_empty()); + } +} \ No newline at end of file diff --git a/cli/src/main.rs b/cli/src/main.rs index 8d543cd1..89070ddc 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,4 @@ +mod command_runner; mod errors; mod essential; mod install; @@ -194,3 +195,84 @@ async fn args_parser() -> Result<(), CliError> { async fn main() { let _ = args_parser().await.map_err(|e| eprintln!("{}", e)); } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + // docs: args_parser() dispatches to functions that reach a real kubernetes + // cluster / gRPC agent, so only the pure clap parsing logic is unit tested here. + + #[test] + fn test_parse_no_subcommand() { + let cli = Cli::try_parse_from(["cfcli"]).unwrap(); + assert!(cli.cmd.is_none()); + } + + #[test] + fn test_parse_uninstall() { + let cli = Cli::try_parse_from(["cfcli", "uninstall"]).unwrap(); + assert!(matches!(cli.cmd, Some(Commands::Uninstall))); + } + + #[test] + fn test_parse_update() { + let cli = Cli::try_parse_from(["cfcli", "update"]).unwrap(); + assert!(matches!(cli.cmd, Some(Commands::Update))); + } + + #[test] + fn test_parse_info() { + let cli = Cli::try_parse_from(["cfcli", "info"]).unwrap(); + assert!(matches!(cli.cmd, Some(Commands::Info))); + } + + #[test] + fn test_parse_install_cortexflow() { + let cli = Cli::try_parse_from(["cfcli", "install", "cortexflow"]).unwrap(); + match cli.cmd { + Some(Commands::Install(args)) => { + assert!(matches!(args.install_cmd, InstallCommands::All)); + } + _ => panic!("expected Install command"), + } + } + + #[test] + fn test_parse_logs_with_flags() { + let cli = Cli::try_parse_from([ + "cfcli", + "logs", + "--service", + "my-service", + "--namespace", + "cortexflow", + ]) + .unwrap(); + match cli.cmd { + Some(Commands::Logs(args)) => { + assert_eq!(args.service, Some("my-service".to_string())); + assert_eq!(args.namespace, Some("cortexflow".to_string())); + assert_eq!(args.component, None); + } + _ => panic!("expected Logs command"), + } + } + + #[test] + fn test_parse_status_with_output_format() { + let cli = Cli::try_parse_from(["cfcli", "status", "--output", "json"]).unwrap(); + match cli.cmd { + Some(Commands::Status(args)) => { + assert_eq!(args.output, Some("json".to_string())); + } + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_parse_unknown_subcommand_fails() { + assert!(Cli::try_parse_from(["cfcli", "not-a-real-command"]).is_err()); + } +} \ No newline at end of file diff --git a/cli/src/mod.rs b/cli/src/mod.rs index fe7c8165..326b6c4d 100644 --- a/cli/src/mod.rs +++ b/cli/src/mod.rs @@ -6,4 +6,5 @@ pub mod status; pub mod logs; pub mod monitoring; pub mod policies; -pub mod errors; \ No newline at end of file +pub mod errors; +pub mod command_runner; \ No newline at end of file diff --git a/cli/src/monitoring.rs b/cli/src/monitoring.rs index eefae1c7..a27cc86f 100644 --- a/cli/src/monitoring.rs +++ b/cli/src/monitoring.rs @@ -10,7 +10,8 @@ use tonic_reflection::pb::v1::server_reflection_response::MessageResponse; use agent_api::client::{connect_to_client, connect_to_server_reflection}; use agent_api::requests::{ get_all_features, send_active_connection_request, send_dropped_packets_request, - send_latency_metrics_request, send_tracked_veth_request, send_veth_tracked_hashmap_req, + send_latency_metrics_request, + send_veth_tracked_hashmap_req, }; use crate::errors::CliError; @@ -345,3 +346,28 @@ fn convert_timestamp_to_date(timestamp: u64) -> String { .map(|dt| dt.to_string()) .unwrap_or_else(|| "Cannot convert timestamp to date".to_string()) } + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_timestamp_to_date_valid() { + // 1700000000000000 us = 2023-11-14T22:13:20Z + let result = convert_timestamp_to_date(1_700_000_000_000_000); + assert!(result.contains("2023")); + } + + #[test] + fn test_convert_timestamp_to_date_zero() { + let result = convert_timestamp_to_date(0); + assert!(result.contains("1970")); + } + + #[test] + fn test_convert_timestamp_to_date_invalid_returns_fallback() { + let result = convert_timestamp_to_date(i64::MAX as u64); + assert_eq!(result, "Cannot convert timestamp to date"); + } +} \ No newline at end of file diff --git a/cli/src/policies.rs b/cli/src/policies.rs index 03841e93..cdc962d8 100644 --- a/cli/src/policies.rs +++ b/cli/src/policies.rs @@ -127,3 +127,57 @@ pub async fn remove_ip(ip:&str) -> Result<(), Error> { } Ok(()) } + + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser)] + struct TestCli { + #[command(flatten)] + args: PoliciesArgs, + } + + // docs: the gRPC-calling functions in this module have no pure logic to + // unit test without mocking the agent_api client; these tests instead + // verify the clap argument parsing, which is the only externally-free logic here. + + #[test] + fn test_parse_create_blocklist_with_ip() { + let cli = TestCli::try_parse_from(["cfcli", "--flags", "1.2.3.4", "create-blocklist"]) + .unwrap(); + assert!(matches!( + cli.args.policy_cmd, + PoliciesCommands::CreateBlocklist + )); + assert_eq!(cli.args.flags, Some("1.2.3.4".to_string())); + } + + #[test] + fn test_parse_check_blocklist() { + let cli = TestCli::try_parse_from(["cfcli", "check-blocklist"]).unwrap(); + assert!(matches!( + cli.args.policy_cmd, + PoliciesCommands::CheckBlocklist + )); + assert_eq!(cli.args.flags, None); + } + + #[test] + fn test_parse_remove_ip() { + let cli = + TestCli::try_parse_from(["cfcli", "--flags", "5.6.7.8", "remove-ip"]).unwrap(); + assert!(matches!( + cli.args.policy_cmd, + PoliciesCommands::RemoveIpFromBlocklist + )); + assert_eq!(cli.args.flags, Some("5.6.7.8".to_string())); + } + + #[test] + fn test_parse_unknown_subcommand_fails() { + assert!(TestCli::try_parse_from(["cfcli", "not-a-command"]).is_err()); + } +} \ No newline at end of file diff --git a/cli/src/service.rs b/cli/src/service.rs index 8cfebf10..9885afe7 100644 --- a/cli/src/service.rs +++ b/cli/src/service.rs @@ -7,6 +7,40 @@ use crate::errors::CliError; use crate::essential::{BASE_COMMAND, connect_to_client}; use crate::logs::{check_namespace_exists, get_available_namespaces}; +// docs: +// +// Pure formatting helper extracted from list_services so the table-row +// formatting logic can be unit tested with stubbed kubectl output. + +fn format_service_rows(stdout: &str) -> Vec { + stdout + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 5 { + let name = parts[0]; + let ready = parts[1]; + let status = parts[2]; + let restarts = parts[3]; + let age = parts[4]; + + let full_status = if ready.contains('/') { + format!("{} ({})", status, ready) + } else { + status.to_string() + }; + + Some(format!( + "{:<40} {:<20} {:<10} {:<10}", + name, full_status, restarts, age + )) + } else { + None + } + }) + .collect() +} + //service subcommands #[derive(Subcommand, Debug, Clone)] pub enum ServiceCommands { @@ -104,26 +138,8 @@ pub async fn list_services(namespace: Option) -> Result<(), CliError> { println!("{}", "-".repeat(80)); // Display Each Pod. - for line in stdout.lines() { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 5 { - let name = parts[0]; - let ready = parts[1]; - let status = parts[2]; - let restarts = parts[3]; - let age = parts[4]; - - let full_status = if ready.contains('/') { - format!("{} ({})", status, ready) - } else { - status.to_string() - }; - - println!( - "{:<40} {:<20} {:<10} {:<10}", - name, full_status, restarts, age - ); - } + for row in format_service_rows(stdout) { + println!("{}", row); } Ok(()) } @@ -267,3 +283,39 @@ pub async fn describe_service( } } } + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_service_rows_formats_matching_lines() { + let stdout = "my-svc 1/1 Running 0 5d\nother-svc 2/2 Pending 1 1h\n"; + let rows = format_service_rows(stdout); + assert_eq!(rows.len(), 2); + assert!(rows[0].contains("my-svc")); + assert!(rows[0].contains("Running (1/1)")); + assert!(rows[1].contains("other-svc")); + assert!(rows[1].contains("Pending (2/2)")); + } + + #[test] + fn test_format_service_rows_skips_short_lines() { + let stdout = "incomplete line\n"; + assert!(format_service_rows(stdout).is_empty()); + } + + #[test] + fn test_format_service_rows_without_ready_slash() { + let stdout = "svc-a Active 0 5d extra\n"; + let rows = format_service_rows(stdout); + assert_eq!(rows.len(), 1); + assert!(rows[0].contains("svc-a")); + } + + #[test] + fn test_format_service_rows_empty_input() { + assert!(format_service_rows("").is_empty()); + } +} \ No newline at end of file diff --git a/cli/src/status.rs b/cli/src/status.rs index ca5d43aa..ccb58aa1 100644 --- a/cli/src/status.rs +++ b/cli/src/status.rs @@ -7,6 +7,29 @@ use crate::logs::{ get_available_namespaces, check_namespace_exists }; use crate::essential::{ BASE_COMMAND, connect_to_client }; use crate::errors::CliError; +// docs: +// +// Pure parsing helper shared by get_pods_status/get_services_status so the +// column-parsing logic can be unit tested with stubbed kubectl output. + +fn parse_status_columns(stdout: &str, min_parts: usize) -> Vec<(String, String, String)> { + stdout + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= min_parts { + Some(( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + )) + } else { + None + } + }) + .collect() +} + #[derive(Debug)] pub enum OutputFormat { Text, @@ -165,23 +188,7 @@ async fn get_pods_status(namespace: &str) -> Result { let stdout = str::from_utf8(&output.stdout).unwrap_or(""); - Ok( - stdout - .lines() - .filter_map(|line| { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 3 { - Some(( - parts[0].to_string(), // name - parts[1].to_string(), // ready - parts[2].to_string(), // status - )) - } else { - None - } - }) - .collect() - ) + Ok(parse_status_columns(stdout, 3)) } _ => Ok(Vec::new()), } @@ -220,23 +227,7 @@ async fn get_services_status(namespace: &str) -> Result { let stdout = str::from_utf8(&output.stdout).unwrap_or(""); - Ok( - stdout - .lines() - .filter_map(|line| { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 4 { - Some(( - parts[0].to_string(), // name - parts[1].to_string(), // type - parts[2].to_string(), // cluster ips - )) - } else { - None - } - }) - .collect() - ) + Ok(parse_status_columns(stdout, 4)) } _ => Ok(Vec::new()), } @@ -360,3 +351,79 @@ fn display_yaml_format( println!(" cluster_ip: {}", cluster_ip); } } + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_output_format_from_json() { + assert!(matches!(OutputFormat::from("json".to_string()), OutputFormat::Json)); + assert!(matches!(OutputFormat::from("JSON".to_string()), OutputFormat::Json)); + } + + #[test] + fn test_output_format_from_yaml() { + assert!(matches!(OutputFormat::from("yaml".to_string()), OutputFormat::Yaml)); + } + + #[test] + fn test_output_format_from_defaults_to_text() { + assert!(matches!(OutputFormat::from("unknown".to_string()), OutputFormat::Text)); + } + + #[test] + fn test_parse_status_columns_pods() { + let stdout = "pod-a 1/1 Running\npod-b 0/1 Pending\n"; + let parsed = parse_status_columns(stdout, 3); + assert_eq!( + parsed, + vec![ + ("pod-a".to_string(), "1/1".to_string(), "Running".to_string()), + ("pod-b".to_string(), "0/1".to_string(), "Pending".to_string()), + ] + ); + } + + #[test] + fn test_parse_status_columns_services() { + let stdout = "svc-a ClusterIP 10.0.0.1 443/TCP\n"; + let parsed = parse_status_columns(stdout, 4); + assert_eq!( + parsed, + vec![("svc-a".to_string(), "ClusterIP".to_string(), "10.0.0.1".to_string())] + ); + } + + #[test] + fn test_parse_status_columns_skips_short_lines() { + let stdout = "only-one-column\n"; + assert!(parse_status_columns(stdout, 3).is_empty()); + } + + #[test] + fn test_parse_status_columns_empty_input() { + assert!(parse_status_columns("", 3).is_empty()); + } + + #[test] + fn test_display_text_format_does_not_panic() { + display_text_format( + "cortexflow", + true, + vec![("pod-a".to_string(), "1/1".to_string(), "Running".to_string())], + vec![("svc-a".to_string(), "ClusterIP".to_string(), "10.0.0.1".to_string())], + ); + } + + #[test] + fn test_display_json_format_does_not_panic() { + display_json_format("cortexflow", false, Vec::new(), Vec::new()); + } + + #[test] + fn test_display_yaml_format_does_not_panic() { + display_yaml_format("cortexflow", false, Vec::new(), Vec::new()); + } +} \ No newline at end of file diff --git a/cli/src/uninstall.rs b/cli/src/uninstall.rs index b9558ddc..39949692 100644 --- a/cli/src/uninstall.rs +++ b/cli/src/uninstall.rs @@ -1,6 +1,7 @@ use colored::Colorize; -use std::{io::stdin, process::Command}; +use std::io::stdin; +use crate::command_runner::{CommandRunner, RealCommandRunner}; use crate::errors::CliError; use crate::essential::{BASE_COMMAND, connect_to_client}; use kube::{Error, core::ErrorResponse}; @@ -78,22 +79,7 @@ async fn uninstall_all() -> Result<(), CliError> { "=====>".blue().bold(), "Deleting cortexflow components".red().bold() ); - let output = Command::new(BASE_COMMAND) - .args(["delete", "namespace", "cortexflow"]) - .output() - .map_err(|e| CliError::InstallerError { - reason: format!("Failed to execute delete command: {}", e), - })?; - - if output.status.success() { - println!("✅ Removed cortexflow namespace"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(CliError::InstallerError { - reason: format!("Failed to delete cortexflow namespace. Error: {}", stderr), - }); - } + uninstall_all_with(&RealCommandRunner) } Err(e) => { return { @@ -108,6 +94,29 @@ async fn uninstall_all() -> Result<(), CliError> { } } +fn uninstall_all_with(runner: &dyn CommandRunner) -> Result<(), CliError> { + let args = [ + "delete".to_string(), + "namespace".to_string(), + "cortexflow".to_string(), + ]; + let output = runner + .run(BASE_COMMAND, &args) + .map_err(|e| CliError::InstallerError { + reason: format!("Failed to execute delete command: {}", e), + })?; + + if output.status.success() { + println!("✅ Removed cortexflow namespace"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(CliError::InstallerError { + reason: format!("Failed to delete cortexflow namespace. Error: {}", stderr), + }) + } +} + //docs: // // This function manages the uninstall of given cortexflow components @@ -127,22 +136,7 @@ async fn uninstall_component(component_type: &str, component: &str) -> Result<() component ); - let output = Command::new(BASE_COMMAND) - .args(["delete", component_type, component, "-n", "cortexflow"]) - .output() - .map_err(|e| CliError::InstallerError { - reason: format!("Failed to execute delete command: {}", e), - })?; - - if output.status.success() { - println!("✅ Removed component {}", component); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(CliError::InstallerError { - reason: format!("Failed to delete component '{}': {}", component, stderr), - }); - } + uninstall_component_with(&RealCommandRunner, component_type, component) } Err(e) => { return { @@ -155,4 +149,76 @@ async fn uninstall_component(component_type: &str, component: &str) -> Result<() }; } } +} + +fn uninstall_component_with( + runner: &dyn CommandRunner, + component_type: &str, + component: &str, +) -> Result<(), CliError> { + let args = [ + "delete".to_string(), + component_type.to_string(), + component.to_string(), + "-n".to_string(), + "cortexflow".to_string(), + ]; + let output = runner + .run(BASE_COMMAND, &args) + .map_err(|e| CliError::InstallerError { + reason: format!("Failed to execute delete command: {}", e), + })?; + + if output.status.success() { + println!("✅ Removed component {}", component); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(CliError::InstallerError { + reason: format!("Failed to delete component '{}': {}", component, stderr), + }) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::command_runner::test_support::StubCommandRunner; + + #[test] + fn test_uninstall_all_with_success() { + let runner = StubCommandRunner::success(""); + assert!(uninstall_all_with(&runner).is_ok()); + } + + #[test] + fn test_uninstall_all_with_command_failure() { + let runner = StubCommandRunner::failure("namespace not found"); + assert!(uninstall_all_with(&runner).is_err()); + } + + #[test] + fn test_uninstall_all_with_io_error() { + let runner = StubCommandRunner::io_error(); + assert!(uninstall_all_with(&runner).is_err()); + } + + #[test] + fn test_uninstall_component_with_success() { + let runner = StubCommandRunner::success(""); + assert!(uninstall_component_with(&runner, "deployment", "cortexflow-identity").is_ok()); + } + + #[test] + fn test_uninstall_component_with_command_failure() { + let runner = StubCommandRunner::failure("component not found"); + assert!(uninstall_component_with(&runner, "deployment", "unknown").is_err()); + } + + #[test] + fn test_uninstall_component_with_io_error() { + let runner = StubCommandRunner::io_error(); + assert!(uninstall_component_with(&runner, "deployment", "cortexflow-identity").is_err()); + } } \ No newline at end of file