diff --git a/src/presentation/commands/destroy.rs b/src/presentation/commands/destroy.rs index 486bb97c..cb130e4f 100644 --- a/src/presentation/commands/destroy.rs +++ b/src/presentation/commands/destroy.rs @@ -26,6 +26,7 @@ use crate::presentation::user_output::{UserOutput, VerbosityLevel}; /// # Arguments /// /// * `environment_name` - The name of the environment to destroy +/// * `working_dir` - Root directory for environment data storage /// /// # Returns /// @@ -44,14 +45,15 @@ use crate::presentation::user_output::{UserOutput, VerbosityLevel}; /// /// ```rust /// use torrust_tracker_deployer_lib::presentation::commands::destroy; +/// use std::path::Path; /// -/// if let Err(e) = destroy::handle("test-env") { +/// if let Err(e) = destroy::handle("test-env", Path::new(".")) { /// eprintln!("Destroy failed: {e}"); /// eprintln!("Help: {}", e.help()); /// } /// ``` #[allow(clippy::result_large_err)] // Error contains detailed context for user guidance -pub fn handle(environment_name: &str) -> Result<(), DestroyError> { +pub fn handle(environment_name: &str, working_dir: &std::path::Path) -> Result<(), DestroyError> { // Create user output with default stdout/stderr channels let mut output = UserOutput::new(VerbosityLevel::Normal); @@ -70,7 +72,7 @@ pub fn handle(environment_name: &str) -> Result<(), DestroyError> { // Create repository for loading environment state let repository_factory = RepositoryFactory::new(Duration::from_secs(30)); - let repository = repository_factory.create(std::path::PathBuf::from("data")); + let repository = repository_factory.create(working_dir.to_path_buf()); // Create clock for timing information let clock = std::sync::Arc::new(crate::shared::SystemClock); @@ -166,9 +168,10 @@ impl DestroyError { /// /// ```rust /// use torrust_tracker_deployer_lib::presentation::commands::destroy; + /// use std::path::Path; /// /// # fn main() -> Result<(), Box> { - /// if let Err(e) = destroy::handle("test-env") { + /// if let Err(e) = destroy::handle("test-env", Path::new(".")) { /// eprintln!("Error: {e}"); /// eprintln!("\nTroubleshooting:\n{}", e.help()); /// } diff --git a/src/presentation/commands/mod.rs b/src/presentation/commands/mod.rs index f2489c9c..0849cdd8 100644 --- a/src/presentation/commands/mod.rs +++ b/src/presentation/commands/mod.rs @@ -60,7 +60,7 @@ pub fn execute(command: Commands, working_dir: &std::path::Path) -> Result<(), C Ok(()) } Commands::Destroy { environment } => { - destroy::handle(&environment)?; + destroy::handle(&environment, working_dir)?; Ok(()) } // Future commands will be added here: // diff --git a/tests/e2e_destroy_command.rs b/tests/e2e_destroy_command.rs new file mode 100644 index 00000000..74365b05 --- /dev/null +++ b/tests/e2e_destroy_command.rs @@ -0,0 +1,210 @@ +//! End-to-End Black Box Tests for Destroy Command +//! +//! This test suite provides true black-box testing of the destroy command +//! by running the production application as an external process. These tests +//! verify that the destroy command correctly handles the working directory +//! parameter, ensuring environments can be destroyed from custom locations. +//! +//! ## Test Approach +//! +//! - **Black Box**: Runs production binary as external process +//! - **Isolation**: Uses temporary directories for complete test isolation +//! - **Coverage**: Tests complete workflow from environment creation to destruction +//! - **Verification**: Validates environment is properly removed +//! +//! ## Test Scenarios +//! +//! 1. Default working directory: Destroy environment from current directory +//! 2. Custom working directory: Destroy environment from temporary directory +//! 3. Full lifecycle: Create → Destroy with custom working directory + +mod support; + +use support::{EnvironmentStateAssertions, ProcessRunner, TempWorkspace}; + +/// Helper function to create a test environment configuration +fn create_test_environment_config(env_name: &str) -> String { + // Use absolute paths to SSH keys to ensure they work regardless of current directory + let project_root = env!("CARGO_MANIFEST_DIR"); + let private_key_path = format!("{project_root}/fixtures/testing_rsa"); + let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub"); + + serde_json::json!({ + "environment": { + "name": env_name + }, + "ssh_credentials": { + "private_key_path": private_key_path, + "public_key_path": public_key_path, + "username": "torrust", + "port": 22 + } + }) + .to_string() +} + +#[test] +fn it_should_destroy_environment_with_default_working_directory() { + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create environment configuration file + let config = create_test_environment_config("test-destroy-default"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Create environment in default location + let create_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + assert!( + create_result.success(), + "Create command failed: {}", + create_result.stderr() + ); + + // Verify environment was created + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-destroy-default"); + + // Act: Destroy environment using destroy command + let destroy_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_destroy_command("test-destroy-default") + .expect("Failed to run destroy command"); + + // Assert: Verify command succeeded + assert!( + destroy_result.success(), + "Destroy command failed with exit code: {:?}\nstderr: {}", + destroy_result.exit_code(), + destroy_result.stderr() + ); + + // Assert: Verify environment was transitioned to Destroyed state + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-destroy-default"); + env_assertions.assert_environment_state_is("test-destroy-default", "Destroyed"); +} + +#[test] +fn it_should_destroy_environment_with_custom_working_directory() { + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create environment configuration file + let config = create_test_environment_config("test-destroy-custom"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Create environment in custom location + let create_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + assert!( + create_result.success(), + "Create command failed: {}", + create_result.stderr() + ); + + // Verify environment was created in custom location + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-destroy-custom"); + + // Act: Destroy environment using same working directory + let destroy_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_destroy_command("test-destroy-custom") + .expect("Failed to run destroy command"); + + // Assert: Verify command succeeded + assert!( + destroy_result.success(), + "Destroy command failed with exit code: {:?}\nstderr: {}", + destroy_result.exit_code(), + destroy_result.stderr() + ); + + // Assert: Verify environment was transitioned to Destroyed state in custom location + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-destroy-custom"); + env_assertions.assert_environment_state_is("test-destroy-custom", "Destroyed"); +} + +#[test] +fn it_should_fail_when_environment_not_found_in_working_directory() { + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Act: Try to destroy non-existent environment + let destroy_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_destroy_command("nonexistent-environment") + .expect("Failed to run destroy command"); + + // Assert: Command should fail + assert!( + !destroy_result.success(), + "Command should have failed when environment doesn't exist" + ); + + // Verify error message mentions environment not found + let stderr = destroy_result.stderr(); + assert!( + stderr.contains("not found") || stderr.contains("does not exist"), + "Error message should mention environment not found, got: {stderr}" + ); +} + +#[test] +fn it_should_complete_full_lifecycle_with_custom_working_directory() { + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create environment configuration file + let config = create_test_environment_config("test-lifecycle"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Act: Create environment in custom location + let create_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + assert!( + create_result.success(), + "Create command failed: {}", + create_result.stderr() + ); + + // Verify environment exists + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-lifecycle"); + env_assertions.assert_environment_state_is("test-lifecycle", "Created"); + + // Act: Destroy environment + let destroy_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_destroy_command("test-lifecycle") + .expect("Failed to run destroy command"); + + // Assert: Both commands succeed + assert!( + destroy_result.success(), + "Destroy command failed: {}", + destroy_result.stderr() + ); + + // Assert: Environment is transitioned to Destroyed state + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-lifecycle"); + env_assertions.assert_environment_state_is("test-lifecycle", "Destroyed"); +} diff --git a/tests/support/assertions.rs b/tests/support/assertions.rs index 08863409..462b38f5 100644 --- a/tests/support/assertions.rs +++ b/tests/support/assertions.rs @@ -76,6 +76,7 @@ impl EnvironmentStateAssertions { /// # Panics /// /// Panics if the data directory or environment JSON file doesn't exist. + #[allow(dead_code)] pub fn assert_data_directory_structure(&self, env_name: &str) { let data_dir = self.workspace_path.join(env_name); assert!( diff --git a/tests/support/process_runner.rs b/tests/support/process_runner.rs index f602b322..fab64228 100644 --- a/tests/support/process_runner.rs +++ b/tests/support/process_runner.rs @@ -80,6 +80,38 @@ impl ProcessRunner { Ok(ProcessResult::new(output)) } + + /// Run the destroy command with the production binary + /// + /// This method runs `cargo run -- destroy ` with + /// optional working directory for the application itself via `--working-dir`. + /// + /// # Errors + /// + /// Returns an error if the command fails to execute. + #[allow(dead_code)] + pub fn run_destroy_command(&self, environment_name: &str) -> Result { + let mut cmd = Command::new("cargo"); + + if let Some(working_dir) = &self.working_dir { + // Build command with working directory + cmd.args([ + "run", + "--", + "destroy", + environment_name, + "--working-dir", + working_dir.to_str().unwrap(), + ]); + } else { + // No working directory, use relative paths + cmd.args(["run", "--", "destroy", environment_name]); + } + + let output = cmd.output().context("Failed to execute destroy command")?; + + Ok(ProcessResult::new(output)) + } } impl Default for ProcessRunner { diff --git a/tests/support/temp_workspace.rs b/tests/support/temp_workspace.rs index d7e34c17..c5c4c554 100644 --- a/tests/support/temp_workspace.rs +++ b/tests/support/temp_workspace.rs @@ -50,6 +50,7 @@ impl TempWorkspace { /// # Errors /// /// Returns an error if the file cannot be written. + #[allow(dead_code)] pub fn write_file(&self, filename: &str, content: &str) -> Result<()> { let file_path = self.temp_dir.path().join(filename); fs::write(file_path, content)?;