diff --git a/tests/e2e_create_command.rs b/tests/e2e_create_command.rs new file mode 100644 index 00000000..39ea634d --- /dev/null +++ b/tests/e2e_create_command.rs @@ -0,0 +1,169 @@ +//! End-to-End Black Box Tests for Create Command +//! +//! This test suite provides true black-box testing of the create command +//! by running the production application as an external process. Unlike +//! other E2E tests that mock infrastructure components, these tests exercise +//! the complete application workflow from configuration file to persisted +//! environment state. +//! +//! ## Test Approach +//! +//! - **Black Box**: Runs production binary as external process +//! - **Isolation**: Uses temporary directories for complete test isolation +//! - **Coverage**: Tests complete workflow from config file to persistence +//! - **Verification**: Validates environment state in data directory +//! +//! ## Test Scenarios +//! +//! 1. Happy path: Create environment from valid config file +//! 2. Invalid config: Graceful failure with validation errors +//! 3. Missing config file: Appropriate error when file not found +//! 4. Duplicate detection: Error when environment already exists + +mod support; + +use support::{EnvironmentStateAssertions, ProcessRunner, TempWorkspace}; + +#[test] +fn it_should_create_environment_from_config_file_black_box() { + // 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-environment"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Act: Run production application as external process + let result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + // Assert: Verify command succeeded + assert!( + result.success(), + "Create command failed with exit code: {:?}\nstderr: {}", + result.exit_code(), + result.stderr() + ); + + // Assert: Verify environment state was persisted + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-environment"); + env_assertions.assert_environment_state_is("test-environment", "Created"); + env_assertions.assert_data_directory_structure("test-environment"); + // Note: traces directory is created on-demand, not during environment creation +} + +#[test] +fn it_should_fail_gracefully_with_invalid_config() { + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create invalid configuration (missing required fields) + let invalid_config = r#"{"invalid": "config"}"#; + temp_workspace + .write_file("invalid.json", invalid_config) + .expect("Failed to write invalid config"); + + // Run command and expect failure + let result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./invalid.json") + .expect("Failed to run create command"); + + // Assert command failed with helpful error message + assert!( + !result.success(), + "Command should have failed with invalid config" + ); + + // Verify error message mentions configuration validation + let stderr = result.stderr(); + assert!( + stderr.contains("missing field") || stderr.contains("Configuration"), + "Error message should mention configuration issues, got: {stderr}" + ); +} + +#[test] +fn it_should_fail_when_config_file_not_found() { + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Run command with non-existent config file + let result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./nonexistent.json") + .expect("Failed to run create command"); + + // Assert command failed with file not found error + assert!( + !result.success(), + "Command should have failed with missing file" + ); + + // Verify error message mentions file not found + let stderr = result.stderr(); + assert!( + stderr.contains("not found") || stderr.contains("No such file"), + "Error message should mention file not found, got: {stderr}" + ); +} + +#[test] +fn it_should_fail_when_environment_already_exists() { + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + let config = create_test_environment_config("duplicate-env"); + temp_workspace + .write_config_file("config.json", &config) + .expect("Failed to write config"); + + // Create environment first time + let result1 = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./config.json") + .expect("Failed to run create command"); + + assert!( + result1.success(), + "First create should succeed, stderr: {}", + result1.stderr() + ); + + // Try to create same environment again + let result2 = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./config.json") + .expect("Failed to run create command"); + + // Assert second create failed + assert!( + !result2.success(), + "Second create should fail with duplicate environment" + ); + + // Verify error message mentions duplicate or already exists + let stderr = result2.stderr(); + assert!( + stderr.contains("Already Exists") + || stderr.contains("already exists") + || stderr.contains("AlreadyExists"), + "Error message should mention environment already exists, got: {stderr}" + ); +} + +/// Helper function to create a test environment configuration +fn create_test_environment_config(env_name: &str) -> String { + serde_json::json!({ + "environment": { + "name": env_name + }, + "ssh_credentials": { + "private_key_path": "fixtures/testing_rsa", + "public_key_path": "fixtures/testing_rsa.pub" + } + }) + .to_string() +} diff --git a/tests/support/assertions.rs b/tests/support/assertions.rs new file mode 100644 index 00000000..08863409 --- /dev/null +++ b/tests/support/assertions.rs @@ -0,0 +1,129 @@ +//! Environment State Assertions +//! +//! Provides assertion utilities for verifying environment state after +//! command execution in black-box tests. + +use anyhow::{Context, Result}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Assertions for verifying environment state after command execution +/// +/// This struct provides methods to verify that the environment was created +/// correctly and that all expected files and directories exist with the +/// correct structure and content. +pub struct EnvironmentStateAssertions { + workspace_path: PathBuf, +} + +impl EnvironmentStateAssertions { + /// Create a new assertions helper for the given workspace + #[must_use] + pub fn new>(workspace_path: P) -> Self { + Self { + workspace_path: workspace_path.as_ref().to_path_buf(), + } + } + + /// Assert that the environment exists + /// + /// Verifies that the environment state file exists at the expected location. + /// + /// # Panics + /// + /// Panics if the environment file does not exist. + pub fn assert_environment_exists(&self, env_name: &str) { + let env_file_path = self.environment_json_path(env_name); + assert!( + env_file_path.exists(), + "Environment file should exist at: {}", + env_file_path.display() + ); + } + + /// Assert that the environment is in the expected state + /// + /// Verifies that the environment state matches the expected state string. + /// The state is determined by the top-level key in the environment JSON. + /// + /// # Panics + /// + /// Panics if the environment JSON cannot be read or if the state doesn't match. + pub fn assert_environment_state_is(&self, env_name: &str, expected_state: &str) { + let env_data = self + .read_environment_json(env_name) + .expect("Failed to read environment JSON"); + + // Parse the environment state structure + let state_key = env_data + .as_object() + .expect("Environment JSON should be an object") + .keys() + .next() + .expect("Environment should have a state key"); + + assert_eq!( + state_key, expected_state, + "Environment state should be '{expected_state}', but was '{state_key}'" + ); + } + + /// Assert that the data directory structure exists + /// + /// Verifies that the environment's data directory and required files exist. + /// + /// # Panics + /// + /// Panics if the data directory or environment JSON file doesn't exist. + pub fn assert_data_directory_structure(&self, env_name: &str) { + let data_dir = self.workspace_path.join(env_name); + assert!( + data_dir.exists(), + "Data directory should exist at: {}", + data_dir.display() + ); + + let env_json = data_dir.join("environment.json"); + assert!( + env_json.exists(), + "Environment JSON should exist at: {}", + env_json.display() + ); + } + + /// Assert that the trace directory exists + /// + /// Verifies that the environment's traces directory exists for observability. + /// + /// # Panics + /// + /// Panics if the traces directory doesn't exist. + #[allow(dead_code)] + pub fn assert_trace_directory_exists(&self, env_name: &str) { + let traces_dir = self.workspace_path.join(env_name).join("traces"); + + assert!( + traces_dir.exists(), + "Traces directory should exist at: {}", + traces_dir.display() + ); + } + + fn environment_json_path(&self, env_name: &str) -> PathBuf { + self.workspace_path.join(env_name).join("environment.json") + } + + fn read_environment_json(&self, env_name: &str) -> Result { + let env_file_path = self.environment_json_path(env_name); + let content = fs::read_to_string(&env_file_path).context(format!( + "Failed to read environment file: {}", + env_file_path.display() + ))?; + + let json: Value = + serde_json::from_str(&content).context("Failed to parse environment JSON")?; + + Ok(json) + } +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 00000000..946122d8 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,13 @@ +//! Test Support Infrastructure +//! +//! This module provides reusable test utilities for black-box E2E testing. +//! It includes temporary workspace management, external process execution, +//! and environment state assertions. + +mod assertions; +mod process_runner; +mod temp_workspace; + +pub use assertions::EnvironmentStateAssertions; +pub use process_runner::ProcessRunner; +pub use temp_workspace::TempWorkspace; diff --git a/tests/support/process_runner.rs b/tests/support/process_runner.rs new file mode 100644 index 00000000..217a2bf6 --- /dev/null +++ b/tests/support/process_runner.rs @@ -0,0 +1,120 @@ +//! External Process Execution +//! +//! Provides utilities for running the production application as an external +//! process for black-box testing. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +/// Runs the production application as an external process +/// +/// This struct provides methods for executing the application binary +/// with different command-line arguments for black-box testing. +pub struct ProcessRunner { + working_dir: Option, +} + +impl ProcessRunner { + /// Create a new process runner + #[must_use] + pub fn new() -> Self { + Self { working_dir: None } + } + + /// Set the working directory for the test process (not the app working dir) + /// + /// This is the directory where the test command will be executed from, + /// typically a temporary directory for test isolation. + #[must_use] + pub fn working_dir>(mut self, dir: P) -> Self { + self.working_dir = Some(dir.as_ref().to_path_buf()); + self + } + + /// Run the create command with the production binary + /// + /// This method runs `cargo run -- create --env-file ` with + /// optional working directory for the application itself via `--working-dir`. + /// + /// # Errors + /// + /// Returns an error if the command fails to execute. + pub fn run_create_command(&self, config_file: &str) -> Result { + let mut cmd = Command::new("cargo"); + // If working directory is specified, we need to: + // 1. Make the config file path absolute (cargo runs from project root) + // 2. Pass --working-dir to tell the app where to store data + if let Some(working_dir) = &self.working_dir { + // Convert config file to absolute path + let absolute_config = if config_file.starts_with("./") { + working_dir.join(config_file.trim_start_matches("./")) + } else { + working_dir.join(config_file) + }; + + // Build command with absolute paths + cmd.args([ + "run", + "--", + "create", + "--env-file", + absolute_config.to_str().unwrap(), + "--working-dir", + working_dir.to_str().unwrap(), + ]); + } else { + // No working directory, use relative paths + cmd.args(["run", "--", "create", "--env-file", config_file]); + } + + let output = cmd.output().context("Failed to execute create command")?; + + Ok(ProcessResult::new(output)) + } +} + +impl Default for ProcessRunner { + fn default() -> Self { + Self::new() + } +} + +/// Wrapper around process execution results +/// +/// Provides convenient access to process output, exit status, and other +/// execution results. +pub struct ProcessResult { + output: Output, +} + +impl ProcessResult { + fn new(output: Output) -> Self { + Self { output } + } + + /// Check if the process completed successfully + #[must_use] + pub fn success(&self) -> bool { + self.output.status.success() + } + + /// Get the process stdout as a string + #[must_use] + #[allow(dead_code)] + pub fn stdout(&self) -> String { + String::from_utf8_lossy(&self.output.stdout).to_string() + } + + /// Get the process stderr as a string + #[must_use] + pub fn stderr(&self) -> String { + String::from_utf8_lossy(&self.output.stderr).to_string() + } + + /// Get the process exit code + #[must_use] + pub fn exit_code(&self) -> Option { + self.output.status.code() + } +} diff --git a/tests/support/temp_workspace.rs b/tests/support/temp_workspace.rs new file mode 100644 index 00000000..d7e34c17 --- /dev/null +++ b/tests/support/temp_workspace.rs @@ -0,0 +1,72 @@ +//! Temporary Workspace Management +//! +//! Provides utilities for creating and managing temporary workspaces +//! for black-box testing with automatic cleanup. + +use anyhow::Result; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +/// Manages a temporary workspace for black-box testing +/// +/// This struct provides a temporary directory with helper methods +/// for creating test configurations and accessing workspace paths. +/// The temporary directory is automatically cleaned up when dropped. +pub struct TempWorkspace { + temp_dir: TempDir, +} + +impl TempWorkspace { + /// Create a new temporary workspace + /// + /// # Errors + /// + /// Returns an error if the temporary directory cannot be created. + pub fn new() -> Result { + let temp_dir = TempDir::new()?; + Ok(Self { temp_dir }) + } + + /// Get the path to the temporary workspace + #[must_use] + pub fn path(&self) -> &Path { + self.temp_dir.path() + } + + /// Write a configuration file to the workspace + /// + /// # Errors + /// + /// Returns an error if the file cannot be written. + pub fn write_config_file(&self, filename: &str, config: &str) -> Result<()> { + let file_path = self.temp_dir.path().join(filename); + fs::write(file_path, config)?; + Ok(()) + } + + /// Write a file to the workspace + /// + /// # Errors + /// + /// Returns an error if the file cannot be written. + pub fn write_file(&self, filename: &str, content: &str) -> Result<()> { + let file_path = self.temp_dir.path().join(filename); + fs::write(file_path, content)?; + Ok(()) + } + + /// Get the data directory path + #[must_use] + #[allow(dead_code)] + pub fn data_dir(&self) -> PathBuf { + self.temp_dir.path().join("data") + } + + /// Get the build directory path + #[must_use] + #[allow(dead_code)] + pub fn build_dir(&self) -> PathBuf { + self.temp_dir.path().join("build") + } +}