Add cli crate unit tests

This commit is contained in:
continuist 2025-06-23 20:39:48 -04:00
parent fffa9f35c0
commit 246acd7eba
2 changed files with 966 additions and 1 deletions

View file

@ -14,6 +14,7 @@ tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true } crossterm = { workspace = true }
textwrap = "0.16" textwrap = "0.16"
chrono = { workspace = true }
thiserror = { workspace = true }

View file

@ -174,3 +174,967 @@ impl Cli {
Ok(()) 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<User>` trait using an in-memory HashMap
/// for storing test data. Provides thread-safe access via `RwLock`.
#[derive(Clone)]
struct MockUserService {
users: Arc<RwLock<HashMap<Uuid, User>>>,
}
impl MockUserService {
fn new() -> Self {
Self {
users: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl UseCase<User> for MockUserService {
fn create(&self, data: CreateUser) -> impl std::future::Future<Output = Result<User, ApplicationError>> + 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<Output = Result<User, ApplicationError>> + 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<Output = Result<Vec<User>, 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<Output = Result<User, ApplicationError>> + 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<Output = Result<(), ApplicationError>> + 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<Product>` trait using an in-memory HashMap
/// for storing test data. Provides thread-safe access via `RwLock`.
#[derive(Clone)]
struct MockProductService {
products: Arc<RwLock<HashMap<Uuid, Product>>>,
}
impl MockProductService {
fn new() -> Self {
Self {
products: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl UseCase<Product> for MockProductService {
fn create(&self, data: CreateProduct) -> impl std::future::Future<Output = Result<Product, ApplicationError>> + 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<Output = Result<Product, ApplicationError>> + 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<Output = Result<Vec<Product>, 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<Output = Result<Product, ApplicationError>> + 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<Output = Result<(), ApplicationError>> + 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());
}
}
}