diff --git a/backend/crates/cli/Cargo.toml b/backend/crates/cli/Cargo.toml index bc2ccc4..54521ae 100644 --- a/backend/crates/cli/Cargo.toml +++ b/backend/crates/cli/Cargo.toml @@ -14,6 +14,7 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } -ratatui = { workspace = true } crossterm = { workspace = true } textwrap = "0.16" +chrono = { workspace = true } +thiserror = { workspace = true } diff --git a/backend/crates/cli/src/lib.rs b/backend/crates/cli/src/lib.rs index cea65cc..a42d6e8 100644 --- a/backend/crates/cli/src/lib.rs +++ b/backend/crates/cli/src/lib.rs @@ -174,3 +174,967 @@ impl Cli { Ok(()) } } + +#[cfg(test)] +mod tests { + //! # CLI Tests + //! + //! This module contains comprehensive unit tests for the CLI application. + //! Tests cover command parsing, execution, and error handling scenarios. + //! + //! ## Test Structure + //! - `command_parsing` - Tests for CLI argument parsing + //! - `user_commands` - Tests for user management commands + //! - `product_commands` - Tests for product management commands + //! - `integration_tests` - End-to-end command execution tests + //! - `error_handling` - Tests for error scenarios + //! + //! ## Testing Approach + //! - Uses mock services implementing the `UseCase` trait + //! - Tests verify command parsing and execution + //! - Captures stdout to verify output messages + //! - Tests error handling and edge cases + + use super::*; + use application::{UseCase, ApplicationError}; + use domain::{DomainError, User, Product, CreateUser, UpdateUser, CreateProduct, UpdateProduct}; + use std::sync::Arc; + use tokio::sync::RwLock; + use std::collections::HashMap; + use chrono::Utc; + + /// Mock user service for testing. + /// + /// Implements the `UseCase` trait using an in-memory HashMap + /// for storing test data. Provides thread-safe access via `RwLock`. + #[derive(Clone)] + struct MockUserService { + users: Arc>>, + } + + impl MockUserService { + fn new() -> Self { + Self { + users: Arc::new(RwLock::new(HashMap::new())), + } + } + } + + impl UseCase for MockUserService { + fn create(&self, data: CreateUser) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let mut guard = users.write().await; + let id = Uuid::new_v4(); + let user = User { + id, + username: data.username, + email: data.email, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + guard.insert(id, user.clone()); + Ok(user) + } + } + + fn get(&self, id: Uuid) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let guard = users.read().await; + guard.get(&id) + .cloned() + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("User not found: {}", id)))) + } + } + + fn list(&self) -> impl std::future::Future, ApplicationError>> + Send { + let users = self.users.clone(); + async move { + let guard = users.read().await; + Ok(guard.values().cloned().collect()) + } + } + + fn update(&self, id: Uuid, data: UpdateUser) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let mut guard = users.write().await; + let user = guard.get_mut(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("User not found: {}", id))))?; + + if let Some(username) = data.username { + user.username = username; + } + if let Some(email) = data.email { + user.email = email; + } + user.updated_at = Utc::now(); + Ok(user.clone()) + } + } + + fn delete(&self, id: Uuid) -> impl std::future::Future> + Send { + let users = self.users.clone(); + async move { + let mut guard = users.write().await; + guard.remove(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("User not found: {}", id))))?; + Ok(()) + } + } + } + + /// Mock product service for testing. + /// + /// Implements the `UseCase` trait using an in-memory HashMap + /// for storing test data. Provides thread-safe access via `RwLock`. + #[derive(Clone)] + struct MockProductService { + products: Arc>>, + } + + impl MockProductService { + fn new() -> Self { + Self { + products: Arc::new(RwLock::new(HashMap::new())), + } + } + } + + impl UseCase for MockProductService { + fn create(&self, data: CreateProduct) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let mut guard = products.write().await; + let id = Uuid::new_v4(); + let product = Product { + id, + name: data.name, + description: data.description, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + guard.insert(id, product.clone()); + Ok(product) + } + } + + fn get(&self, id: Uuid) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let guard = products.read().await; + guard.get(&id) + .cloned() + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("Product not found: {}", id)))) + } + } + + fn list(&self) -> impl std::future::Future, ApplicationError>> + Send { + let products = self.products.clone(); + async move { + let guard = products.read().await; + Ok(guard.values().cloned().collect()) + } + } + + fn update(&self, id: Uuid, data: UpdateProduct) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let mut guard = products.write().await; + let product = guard.get_mut(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("Product not found: {}", id))))?; + + if let Some(name) = data.name { + product.name = name; + } + if let Some(description) = data.description { + product.description = description; + } + product.updated_at = Utc::now(); + Ok(product.clone()) + } + } + + fn delete(&self, id: Uuid) -> impl std::future::Future> + Send { + let products = self.products.clone(); + async move { + let mut guard = products.write().await; + guard.remove(&id) + .ok_or_else(|| ApplicationError::Domain(DomainError::NotFound(format!("Product not found: {}", id))))?; + Ok(()) + } + } + } + + mod command_parsing { + use super::*; + + /// Tests that the CLI can parse user create command with all arguments. + #[test] + fn test_parse_user_create() { + let args = vec![ + "cli", + "user", + "create", + "--username", "testuser", + "--email", "test@example.com" + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::User { command: UserCommands::Create { username, email } }) = cli.command { + assert_eq!(username, "testuser"); + assert_eq!(email, "test@example.com"); + } else { + panic!("Expected user create command"); + } + } + + /// Tests that the CLI can parse user create command with short arguments. + #[test] + fn test_parse_user_create_short_args() { + let args = vec![ + "cli", + "user", + "create", + "-u", "testuser", + "-e", "test@example.com" + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::User { command: UserCommands::Create { username, email } }) = cli.command { + assert_eq!(username, "testuser"); + assert_eq!(email, "test@example.com"); + } else { + panic!("Expected user create command"); + } + } + + /// Tests that the CLI can parse user list command. + #[test] + fn test_parse_user_list() { + let args = vec!["cli", "user", "list"]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::User { command: UserCommands::List }) = cli.command { + // Command parsed successfully + } else { + panic!("Expected user list command"); + } + } + + /// Tests that the CLI can parse user get command. + #[test] + fn test_parse_user_get() { + let user_id = Uuid::new_v4(); + let user_id_str = user_id.to_string(); + let args = vec![ + "cli", + "user", + "get", + "--id", &user_id_str + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::User { command: UserCommands::Get { id } }) = cli.command { + assert_eq!(id, user_id); + } else { + panic!("Expected user get command"); + } + } + + /// Tests that the CLI can parse user update command with all fields. + #[test] + fn test_parse_user_update() { + let user_id = Uuid::new_v4(); + let user_id_str = user_id.to_string(); + let args = vec![ + "cli", + "user", + "update", + "--id", &user_id_str, + "--username", "newuser", + "--email", "new@example.com" + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::User { command: UserCommands::Update { id, username, email } }) = cli.command { + assert_eq!(id, user_id); + assert_eq!(username, Some("newuser".to_string())); + assert_eq!(email, Some("new@example.com".to_string())); + } else { + panic!("Expected user update command"); + } + } + + /// Tests that the CLI can parse user update command with partial fields. + #[test] + fn test_parse_user_update_partial() { + let user_id = Uuid::new_v4(); + let user_id_str = user_id.to_string(); + let args = vec![ + "cli", + "user", + "update", + "--id", &user_id_str, + "--username", "newuser" + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::User { command: UserCommands::Update { id, username, email } }) = cli.command { + assert_eq!(id, user_id); + assert_eq!(username, Some("newuser".to_string())); + assert_eq!(email, None); + } else { + panic!("Expected user update command"); + } + } + + /// Tests that the CLI can parse user delete command. + #[test] + fn test_parse_user_delete() { + let user_id = Uuid::new_v4(); + let user_id_str = user_id.to_string(); + let args = vec![ + "cli", + "user", + "delete", + "--id", &user_id_str + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::User { command: UserCommands::Delete { id } }) = cli.command { + assert_eq!(id, user_id); + } else { + panic!("Expected user delete command"); + } + } + + /// Tests that the CLI can parse product create command. + #[test] + fn test_parse_product_create() { + let args = vec![ + "cli", + "product", + "create", + "--name", "Test Product", + "--description", "Test Description" + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::Product { command: ProductCommands::Create { name, description } }) = cli.command { + assert_eq!(name, "Test Product"); + assert_eq!(description, "Test Description"); + } else { + panic!("Expected product create command"); + } + } + + /// Tests that the CLI can parse product list command. + #[test] + fn test_parse_product_list() { + let args = vec!["cli", "product", "list"]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::Product { command: ProductCommands::List }) = cli.command { + // Command parsed successfully + } else { + panic!("Expected product list command"); + } + } + + /// Tests that the CLI can parse product get command. + #[test] + fn test_parse_product_get() { + let product_id = Uuid::new_v4(); + let product_id_str = product_id.to_string(); + let args = vec![ + "cli", + "product", + "get", + "--id", &product_id_str + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::Product { command: ProductCommands::Get { id } }) = cli.command { + assert_eq!(id, product_id); + } else { + panic!("Expected product get command"); + } + } + + /// Tests that the CLI can parse product update command. + #[test] + fn test_parse_product_update() { + let product_id = Uuid::new_v4(); + let product_id_str = product_id.to_string(); + let args = vec![ + "cli", + "product", + "update", + "--id", &product_id_str, + "--name", "New Product", + "--description", "New Description" + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::Product { command: ProductCommands::Update { id, name, description } }) = cli.command { + assert_eq!(id, product_id); + assert_eq!(name, Some("New Product".to_string())); + assert_eq!(description, Some("New Description".to_string())); + } else { + panic!("Expected product update command"); + } + } + + /// Tests that the CLI can parse product delete command. + #[test] + fn test_parse_product_delete() { + let product_id = Uuid::new_v4(); + let product_id_str = product_id.to_string(); + let args = vec![ + "cli", + "product", + "delete", + "--id", &product_id_str + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Some(Commands::Product { command: ProductCommands::Delete { id } }) = cli.command { + assert_eq!(id, product_id); + } else { + panic!("Expected product delete command"); + } + } + + /// Tests that the CLI handles no command gracefully. + #[test] + fn test_parse_no_command() { + let args = vec!["cli"]; + + let cli = Cli::try_parse_from(args).unwrap(); + + assert!(cli.command.is_none()); + } + + /// Tests that the CLI rejects invalid UUID format. + #[test] + fn test_parse_invalid_uuid() { + let args = vec![ + "cli", + "user", + "get", + "--id", "invalid-uuid" + ]; + + let result = Cli::try_parse_from(args); + assert!(result.is_err()); + } + } + + mod user_commands { + use super::*; + + /// Tests user creation command execution. + #[tokio::test] + async fn test_user_create_command() { + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Create { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); + } + + /// Tests user list command execution. + #[tokio::test] + async fn test_user_list_command() { + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::List + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); + } + + /// Tests user get command execution. + #[tokio::test] + async fn test_user_get_command() { + let user_id = Uuid::new_v4(); + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Get { id: user_id } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + // Should fail because user doesn't exist + assert!(result.is_err()); + } + + /// Tests user update command execution. + #[tokio::test] + async fn test_user_update_command() { + let user_id = Uuid::new_v4(); + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Update { + id: user_id, + username: Some("newuser".to_string()), + email: Some("new@example.com".to_string()), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + // Should fail because user doesn't exist + assert!(result.is_err()); + } + + /// Tests user delete command execution. + #[tokio::test] + async fn test_user_delete_command() { + let user_id = Uuid::new_v4(); + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Delete { id: user_id } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + // Should fail because user doesn't exist + assert!(result.is_err()); + } + } + + mod product_commands { + use super::*; + + /// Tests product creation command execution. + #[tokio::test] + async fn test_product_create_command() { + let cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Create { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); + } + + /// Tests product list command execution. + #[tokio::test] + async fn test_product_list_command() { + let cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::List + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); + } + + /// Tests product get command execution. + #[tokio::test] + async fn test_product_get_command() { + let product_id = Uuid::new_v4(); + let cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Get { id: product_id } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + // Should fail because product doesn't exist + assert!(result.is_err()); + } + + /// Tests product update command execution. + #[tokio::test] + async fn test_product_update_command() { + let product_id = Uuid::new_v4(); + let cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Update { + id: product_id, + name: Some("New Product".to_string()), + description: Some("New Description".to_string()), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + // Should fail because product doesn't exist + assert!(result.is_err()); + } + + /// Tests product delete command execution. + #[tokio::test] + async fn test_product_delete_command() { + let product_id = Uuid::new_v4(); + let cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Delete { id: product_id } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + // Should fail because product doesn't exist + assert!(result.is_err()); + } + } + + mod integration_tests { + use super::*; + + /// Tests complete user lifecycle through CLI commands. + #[tokio::test] + async fn test_user_lifecycle() { + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + // Create user + let create_cli = Cli { + command: Some(Commands::User { + command: UserCommands::Create { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + } + }) + }; + + let result = create_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Get the created user ID by listing users + let users = user_service.list().await.unwrap(); + assert_eq!(users.len(), 1); + let user_id = users[0].id; + + // Get user + let get_cli = Cli { + command: Some(Commands::User { + command: UserCommands::Get { id: user_id } + }) + }; + + let result = get_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Update user + let update_cli = Cli { + command: Some(Commands::User { + command: UserCommands::Update { + id: user_id, + username: Some("updateduser".to_string()), + email: None, + } + }) + }; + + let result = update_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Delete user + let delete_cli = Cli { + command: Some(Commands::User { + command: UserCommands::Delete { id: user_id } + }) + }; + + let result = delete_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Verify user is deleted + let users = user_service.list().await.unwrap(); + assert_eq!(users.len(), 0); + } + + /// Tests complete product lifecycle through CLI commands. + #[tokio::test] + async fn test_product_lifecycle() { + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + // Create product + let create_cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Create { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + } + }) + }; + + let result = create_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Get the created product ID by listing products + let products = product_service.list().await.unwrap(); + assert_eq!(products.len(), 1); + let product_id = products[0].id; + + // Get product + let get_cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Get { id: product_id } + }) + }; + + let result = get_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Update product + let update_cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Update { + id: product_id, + name: Some("Updated Product".to_string()), + description: None, + } + }) + }; + + let result = update_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Delete product + let delete_cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Delete { id: product_id } + }) + }; + + let result = delete_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Verify product is deleted + let products = product_service.list().await.unwrap(); + assert_eq!(products.len(), 0); + } + + /// Tests mixed user and product operations. + #[tokio::test] + async fn test_mixed_operations() { + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + // Create a user + let user_cli = Cli { + command: Some(Commands::User { + command: UserCommands::Create { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + } + }) + }; + + let result = user_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Create a product + let product_cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Create { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + } + }) + }; + + let result = product_cli.run(user_service.clone(), product_service.clone()).await; + assert!(result.is_ok()); + + // Verify both exist + let users = user_service.list().await.unwrap(); + let products = product_service.list().await.unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(products.len(), 1); + } + } + + mod error_handling { + use super::*; + + /// Tests handling of service errors in user operations. + #[tokio::test] + async fn test_user_service_error() { + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Get { id: Uuid::new_v4() } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_err()); + } + + /// Tests handling of service errors in product operations. + #[tokio::test] + async fn test_product_service_error() { + let cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Get { id: Uuid::new_v4() } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_err()); + } + + /// Tests handling of no command provided. + #[tokio::test] + async fn test_no_command() { + let cli = Cli { command: None }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); + } + } + + mod edge_cases { + use super::*; + + /// Tests CLI with empty strings in user creation. + #[tokio::test] + async fn test_user_create_empty_strings() { + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Create { + username: "".to_string(), + email: "".to_string(), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); // Should succeed even with empty strings + } + + /// Tests CLI with empty strings in product creation. + #[tokio::test] + async fn test_product_create_empty_strings() { + let cli = Cli { + command: Some(Commands::Product { + command: ProductCommands::Create { + name: "".to_string(), + description: "".to_string(), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); // Should succeed even with empty strings + } + + /// Tests CLI with very long strings. + #[tokio::test] + async fn test_user_create_long_strings() { + let long_username = "a".repeat(1000); + let long_email = format!("{}@example.com", "a".repeat(1000)); + + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Create { + username: long_username.clone(), + email: long_email.clone(), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); + } + + /// Tests CLI with special characters in strings. + #[tokio::test] + async fn test_user_create_special_characters() { + let cli = Cli { + command: Some(Commands::User { + command: UserCommands::Create { + username: "user@#$%^&*()".to_string(), + email: "test+tag@example.com".to_string(), + } + }) + }; + + let user_service = MockUserService::new(); + let product_service = MockProductService::new(); + + let result = cli.run(user_service, product_service).await; + assert!(result.is_ok()); + } + } +}