From 35a95fbc3d8612285ae697cc98a6758dbf47535c Mon Sep 17 00:00:00 2001 From: continuist Date: Mon, 23 Jun 2025 20:00:45 -0400 Subject: [PATCH] Add memory crate unit tests --- backend/crates/memory/Cargo.toml | 3 + backend/crates/memory/src/lib.rs | 610 +++++++++++++++++++++++++++++++ 2 files changed, 613 insertions(+) diff --git a/backend/crates/memory/Cargo.toml b/backend/crates/memory/Cargo.toml index bcc0f9c..ec94304 100644 --- a/backend/crates/memory/Cargo.toml +++ b/backend/crates/memory/Cargo.toml @@ -11,3 +11,6 @@ application = { path = "../application" } tokio = { workspace = true, features = ["sync"] } uuid = { workspace = true } chrono = { workspace = true } + +[dev-dependencies] +futures = "0.3" diff --git a/backend/crates/memory/src/lib.rs b/backend/crates/memory/src/lib.rs index 4df1982..9152559 100644 --- a/backend/crates/memory/src/lib.rs +++ b/backend/crates/memory/src/lib.rs @@ -1,3 +1,17 @@ +//! # memory +//! +//! This crate provides in-memory implementations of the `Repository` trait for users and products. +//! It is primarily intended for testing, development, and CLI/TUI modes where persistence is not required. +//! +//! ## Features +//! - Thread-safe, async in-memory storage for `User` and `Product` entities +//! - Implements all CRUD operations +//! - Used as a backend for memory-based API, CLI, and TUI binaries +//! - Comprehensive unit tests for all repository and service operations +//! +//! ## Usage +//! Use `InMemoryUserRepository` and `InMemoryProductRepository` directly, or via the `MemoryUserService` and `MemoryProductService` type aliases. + /* * This file is part of Sharenet. * @@ -19,6 +33,9 @@ use domain::{ }; use application::{Repository, Service}; +/// Thread-safe, async in-memory repository for `User` entities. +/// +/// Implements all CRUD operations. Intended for testing and non-persistent use cases. #[derive(Default, Clone)] pub struct InMemoryUserRepository { users: Arc>>, @@ -86,6 +103,9 @@ impl Repository for InMemoryUserRepository { } } +/// Thread-safe, async in-memory repository for `Product` entities. +/// +/// Implements all CRUD operations. Intended for testing and non-persistent use cases. #[derive(Default, Clone)] pub struct InMemoryProductRepository { products: Arc>>, @@ -153,5 +173,595 @@ impl Repository for InMemoryProductRepository { } } +/// Type alias for a user service backed by the in-memory repository. +/// +/// Provides a high-level API for user operations using `InMemoryUserRepository`. pub type MemoryUserService = Service; + +/// Type alias for a product service backed by the in-memory repository. +/// +/// Provides a high-level API for product operations using `InMemoryProductRepository`. pub type MemoryProductService = Service; + +#[cfg(test)] +mod tests { + use super::*; + use domain::{CreateUser, UpdateUser, CreateProduct, UpdateProduct, DomainError}; + use std::time::Duration; + use tokio::time::sleep; + + mod user_repository_tests { + use super::*; + + #[tokio::test] + async fn test_create_user() { + let repo = InMemoryUserRepository::new(); + let create_data = CreateUser { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + }; + + let user = repo.create(create_data).await.unwrap(); + + assert_eq!(user.username, "testuser"); + assert_eq!(user.email, "test@example.com"); + assert!(!user.id.is_nil()); + assert!(user.created_at <= chrono::Utc::now()); + assert!(user.updated_at <= chrono::Utc::now()); + } + + #[tokio::test] + async fn test_find_by_id_existing() { + let repo = InMemoryUserRepository::new(); + let create_data = CreateUser { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + }; + + let created_user = repo.create(create_data).await.unwrap(); + let found_user = repo.find_by_id(created_user.id).await.unwrap(); + + assert_eq!(found_user.id, created_user.id); + assert_eq!(found_user.username, created_user.username); + assert_eq!(found_user.email, created_user.email); + } + + #[tokio::test] + async fn test_find_by_id_not_found() { + let repo = InMemoryUserRepository::new(); + let non_existent_id = Uuid::new_v4(); + + let result = repo.find_by_id(non_existent_id).await; + assert!(result.is_err()); + + match result.unwrap_err() { + DomainError::NotFound(msg) => { + assert!(msg.contains("User not found")); + assert!(msg.contains(&non_existent_id.to_string())); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_find_all_empty() { + let repo = InMemoryUserRepository::new(); + let users = repo.find_all().await.unwrap(); + + assert_eq!(users.len(), 0); + } + + #[tokio::test] + async fn test_find_all_with_users() { + let repo = InMemoryUserRepository::new(); + + let user1 = repo.create(CreateUser { + username: "user1".to_string(), + email: "user1@example.com".to_string(), + }).await.unwrap(); + + let user2 = repo.create(CreateUser { + username: "user2".to_string(), + email: "user2@example.com".to_string(), + }).await.unwrap(); + + let users = repo.find_all().await.unwrap(); + + assert_eq!(users.len(), 2); + assert!(users.iter().any(|u| u.id == user1.id)); + assert!(users.iter().any(|u| u.id == user2.id)); + } + + #[tokio::test] + async fn test_update_user_existing() { + let repo = InMemoryUserRepository::new(); + let user = repo.create(CreateUser { + username: "olduser".to_string(), + email: "old@example.com".to_string(), + }).await.unwrap(); + + let original_updated_at = user.updated_at; + sleep(Duration::from_millis(1)).await; // Ensure timestamp difference + + let update_data = UpdateUser { + username: Some("newuser".to_string()), + email: Some("new@example.com".to_string()), + }; + + let updated_user = repo.update(user.id, update_data).await.unwrap(); + + assert_eq!(updated_user.username, "newuser"); + assert_eq!(updated_user.email, "new@example.com"); + assert!(updated_user.updated_at > original_updated_at); + } + + #[tokio::test] + async fn test_update_user_partial() { + let repo = InMemoryUserRepository::new(); + let user = repo.create(CreateUser { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + }).await.unwrap(); + + let update_data = UpdateUser { + username: Some("newuser".to_string()), + email: None, + }; + + let updated_user = repo.update(user.id, update_data).await.unwrap(); + + assert_eq!(updated_user.username, "newuser"); + assert_eq!(updated_user.email, "test@example.com"); // Should remain unchanged + } + + #[tokio::test] + async fn test_update_user_not_found() { + let repo = InMemoryUserRepository::new(); + let non_existent_id = Uuid::new_v4(); + let update_data = UpdateUser { + username: Some("newuser".to_string()), + email: None, + }; + + let result = repo.update(non_existent_id, update_data).await; + assert!(result.is_err()); + + match result.unwrap_err() { + DomainError::NotFound(msg) => { + assert!(msg.contains("User not found")); + assert!(msg.contains(&non_existent_id.to_string())); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_delete_user_existing() { + let repo = InMemoryUserRepository::new(); + let user = repo.create(CreateUser { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + }).await.unwrap(); + + let result = repo.delete(user.id).await; + assert!(result.is_ok()); + + // Verify user is actually deleted + let find_result = repo.find_by_id(user.id).await; + assert!(find_result.is_err()); + } + + #[tokio::test] + async fn test_delete_user_not_found() { + let repo = InMemoryUserRepository::new(); + let non_existent_id = Uuid::new_v4(); + + let result = repo.delete(non_existent_id).await; + assert!(result.is_err()); + + match result.unwrap_err() { + DomainError::NotFound(msg) => { + assert!(msg.contains("User not found")); + assert!(msg.contains(&non_existent_id.to_string())); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_concurrent_access() { + let repo = InMemoryUserRepository::new(); + let repo_clone = repo.clone(); + + // Spawn multiple tasks to create users concurrently + let handles: Vec<_> = (0..10).map(|i| { + let repo = repo_clone.clone(); + tokio::spawn(async move { + repo.create(CreateUser { + username: format!("user{}", i), + email: format!("user{}@example.com", i), + }).await + }) + }).collect(); + + // Wait for all tasks to complete + let results = futures::future::join_all(handles).await; + for result in results { + assert!(result.unwrap().is_ok()); + } + + // Verify all users were created + let users = repo.find_all().await.unwrap(); + assert_eq!(users.len(), 10); + } + } + + mod product_repository_tests { + use super::*; + + #[tokio::test] + async fn test_create_product() { + let repo = InMemoryProductRepository::new(); + let create_data = CreateProduct { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + }; + + let product = repo.create(create_data).await.unwrap(); + + assert_eq!(product.name, "Test Product"); + assert_eq!(product.description, "Test Description"); + assert!(!product.id.is_nil()); + assert!(product.created_at <= chrono::Utc::now()); + assert!(product.updated_at <= chrono::Utc::now()); + } + + #[tokio::test] + async fn test_find_by_id_existing() { + let repo = InMemoryProductRepository::new(); + let create_data = CreateProduct { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + }; + + let created_product = repo.create(create_data).await.unwrap(); + let found_product = repo.find_by_id(created_product.id).await.unwrap(); + + assert_eq!(found_product.id, created_product.id); + assert_eq!(found_product.name, created_product.name); + assert_eq!(found_product.description, created_product.description); + } + + #[tokio::test] + async fn test_find_by_id_not_found() { + let repo = InMemoryProductRepository::new(); + let non_existent_id = Uuid::new_v4(); + + let result = repo.find_by_id(non_existent_id).await; + assert!(result.is_err()); + + match result.unwrap_err() { + DomainError::NotFound(msg) => { + assert!(msg.contains("Product not found")); + assert!(msg.contains(&non_existent_id.to_string())); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_find_all_empty() { + let repo = InMemoryProductRepository::new(); + let products = repo.find_all().await.unwrap(); + + assert_eq!(products.len(), 0); + } + + #[tokio::test] + async fn test_find_all_with_products() { + let repo = InMemoryProductRepository::new(); + + let product1 = repo.create(CreateProduct { + name: "Product 1".to_string(), + description: "Description 1".to_string(), + }).await.unwrap(); + + let product2 = repo.create(CreateProduct { + name: "Product 2".to_string(), + description: "Description 2".to_string(), + }).await.unwrap(); + + let products = repo.find_all().await.unwrap(); + + assert_eq!(products.len(), 2); + assert!(products.iter().any(|p| p.id == product1.id)); + assert!(products.iter().any(|p| p.id == product2.id)); + } + + #[tokio::test] + async fn test_update_product_existing() { + let repo = InMemoryProductRepository::new(); + let product = repo.create(CreateProduct { + name: "Old Product".to_string(), + description: "Old Description".to_string(), + }).await.unwrap(); + + let original_updated_at = product.updated_at; + sleep(Duration::from_millis(1)).await; // Ensure timestamp difference + + let update_data = UpdateProduct { + name: Some("New Product".to_string()), + description: Some("New Description".to_string()), + }; + + let updated_product = repo.update(product.id, update_data).await.unwrap(); + + assert_eq!(updated_product.name, "New Product"); + assert_eq!(updated_product.description, "New Description"); + assert!(updated_product.updated_at > original_updated_at); + } + + #[tokio::test] + async fn test_update_product_partial() { + let repo = InMemoryProductRepository::new(); + let product = repo.create(CreateProduct { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + }).await.unwrap(); + + let update_data = UpdateProduct { + name: Some("New Product".to_string()), + description: None, + }; + + let updated_product = repo.update(product.id, update_data).await.unwrap(); + + assert_eq!(updated_product.name, "New Product"); + assert_eq!(updated_product.description, "Test Description"); // Should remain unchanged + } + + #[tokio::test] + async fn test_update_product_not_found() { + let repo = InMemoryProductRepository::new(); + let non_existent_id = Uuid::new_v4(); + let update_data = UpdateProduct { + name: Some("New Product".to_string()), + description: None, + }; + + let result = repo.update(non_existent_id, update_data).await; + assert!(result.is_err()); + + match result.unwrap_err() { + DomainError::NotFound(msg) => { + assert!(msg.contains("Product not found")); + assert!(msg.contains(&non_existent_id.to_string())); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_delete_product_existing() { + let repo = InMemoryProductRepository::new(); + let product = repo.create(CreateProduct { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + }).await.unwrap(); + + let result = repo.delete(product.id).await; + assert!(result.is_ok()); + + // Verify product is actually deleted + let find_result = repo.find_by_id(product.id).await; + assert!(find_result.is_err()); + } + + #[tokio::test] + async fn test_delete_product_not_found() { + let repo = InMemoryProductRepository::new(); + let non_existent_id = Uuid::new_v4(); + + let result = repo.delete(non_existent_id).await; + assert!(result.is_err()); + + match result.unwrap_err() { + DomainError::NotFound(msg) => { + assert!(msg.contains("Product not found")); + assert!(msg.contains(&non_existent_id.to_string())); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_concurrent_access() { + let repo = InMemoryProductRepository::new(); + let repo_clone = repo.clone(); + + // Spawn multiple tasks to create products concurrently + let handles: Vec<_> = (0..10).map(|i| { + let repo = repo_clone.clone(); + tokio::spawn(async move { + repo.create(CreateProduct { + name: format!("Product {}", i), + description: format!("Description {}", i), + }).await + }) + }).collect(); + + // Wait for all tasks to complete + let results = futures::future::join_all(handles).await; + for result in results { + assert!(result.unwrap().is_ok()); + } + + // Verify all products were created + let products = repo.find_all().await.unwrap(); + assert_eq!(products.len(), 10); + } + } + + mod service_tests { + use super::*; + use application::UseCase; + + #[tokio::test] + async fn test_memory_user_service() { + let repo = InMemoryUserRepository::new(); + let service = MemoryUserService::new(repo); + + // Test create + let user = service.create(CreateUser { + username: "testuser".to_string(), + email: "test@example.com".to_string(), + }).await.unwrap(); + + assert_eq!(user.username, "testuser"); + assert_eq!(user.email, "test@example.com"); + + // Test get + let found_user = service.get(user.id).await.unwrap(); + assert_eq!(found_user.id, user.id); + + // Test update + let updated_user = service.update(user.id, UpdateUser { + username: Some("newuser".to_string()), + email: None, + }).await.unwrap(); + + assert_eq!(updated_user.username, "newuser"); + assert_eq!(updated_user.email, "test@example.com"); + + // Test list + let users = service.list().await.unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(users[0].id, user.id); + + // Test delete + service.delete(user.id).await.unwrap(); + + // Verify deletion + let result = service.get(user.id).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_memory_product_service() { + let repo = InMemoryProductRepository::new(); + let service = MemoryProductService::new(repo); + + // Test create + let product = service.create(CreateProduct { + name: "Test Product".to_string(), + description: "Test Description".to_string(), + }).await.unwrap(); + + assert_eq!(product.name, "Test Product"); + assert_eq!(product.description, "Test Description"); + + // Test get + let found_product = service.get(product.id).await.unwrap(); + assert_eq!(found_product.id, product.id); + + // Test update + let updated_product = service.update(product.id, UpdateProduct { + name: Some("New Product".to_string()), + description: None, + }).await.unwrap(); + + assert_eq!(updated_product.name, "New Product"); + assert_eq!(updated_product.description, "Test Description"); + + // Test list + let products = service.list().await.unwrap(); + assert_eq!(products.len(), 1); + assert_eq!(products[0].id, product.id); + + // Test delete + service.delete(product.id).await.unwrap(); + + // Verify deletion + let result = service.get(product.id).await; + assert!(result.is_err()); + } + } + + mod integration_tests { + use super::*; + + #[tokio::test] + async fn test_user_lifecycle() { + let repo = InMemoryUserRepository::new(); + + // Create multiple users + let user1 = repo.create(CreateUser { + username: "user1".to_string(), + email: "user1@example.com".to_string(), + }).await.unwrap(); + + let user2 = repo.create(CreateUser { + username: "user2".to_string(), + email: "user2@example.com".to_string(), + }).await.unwrap(); + + // Verify both users exist + let users = repo.find_all().await.unwrap(); + assert_eq!(users.len(), 2); + + // Update one user + let updated_user1 = repo.update(user1.id, UpdateUser { + username: Some("updated_user1".to_string()), + email: None, + }).await.unwrap(); + + assert_eq!(updated_user1.username, "updated_user1"); + assert_eq!(updated_user1.email, "user1@example.com"); + + // Delete one user + repo.delete(user2.id).await.unwrap(); + + // Verify only one user remains + let remaining_users = repo.find_all().await.unwrap(); + assert_eq!(remaining_users.len(), 1); + assert_eq!(remaining_users[0].id, user1.id); + } + + #[tokio::test] + async fn test_product_lifecycle() { + let repo = InMemoryProductRepository::new(); + + // Create multiple products + let product1 = repo.create(CreateProduct { + name: "Product 1".to_string(), + description: "Description 1".to_string(), + }).await.unwrap(); + + let product2 = repo.create(CreateProduct { + name: "Product 2".to_string(), + description: "Description 2".to_string(), + }).await.unwrap(); + + // Verify both products exist + let products = repo.find_all().await.unwrap(); + assert_eq!(products.len(), 2); + + // Update one product + let updated_product1 = repo.update(product1.id, UpdateProduct { + name: Some("Updated Product 1".to_string()), + description: None, + }).await.unwrap(); + + assert_eq!(updated_product1.name, "Updated Product 1"); + assert_eq!(updated_product1.description, "Description 1"); + + // Delete one product + repo.delete(product2.id).await.unwrap(); + + // Verify only one product remains + let remaining_products = repo.find_all().await.unwrap(); + assert_eq!(remaining_products.len(), 1); + assert_eq!(remaining_products[0].id, product1.id); + } + } +}