sharenet/backend/crates/memory/src/lib.rs

671 lines
24 KiB
Rust

//! # 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.
*
* Sharenet is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
*
* You may obtain a copy of the license at:
* https://creativecommons.org/licenses/by-nc-sa/4.0/
*
* Copyright (c) 2024 Continuist <continuist02@gmail.com>
*/
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use domain::{
CreateProduct, CreateUser, Product, Result, UpdateProduct, UpdateUser, User,
};
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<RwLock<HashMap<Uuid, User>>>,
}
impl InMemoryUserRepository {
pub fn new() -> Self {
Self {
users: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl Repository<User> for InMemoryUserRepository {
async fn create(&self, data: CreateUser) -> Result<User> {
let user = User {
id: Uuid::new_v4(),
username: data.username,
email: data.email,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
self.users.write().await.insert(user.id, user.clone());
Ok(user)
}
async fn find_by_id(&self, id: Uuid) -> Result<User> {
self.users
.read()
.await
.get(&id)
.cloned()
.ok_or_else(|| domain::DomainError::NotFound(format!("User not found: {}", id)))
}
async fn find_all(&self) -> Result<Vec<User>> {
Ok(self.users.read().await.values().cloned().collect())
}
async fn update(&self, id: Uuid, data: UpdateUser) -> Result<User> {
let mut users = self.users.write().await;
let user = users
.get_mut(&id)
.ok_or_else(|| 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 = chrono::Utc::now();
Ok(user.clone())
}
async fn delete(&self, id: Uuid) -> Result<()> {
self.users
.write()
.await
.remove(&id)
.map(|_| ())
.ok_or_else(|| domain::DomainError::NotFound(format!("User not found: {}", id)))
}
}
/// 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<RwLock<HashMap<Uuid, Product>>>,
}
impl InMemoryProductRepository {
pub fn new() -> Self {
Self {
products: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl Repository<Product> for InMemoryProductRepository {
async fn create(&self, data: CreateProduct) -> Result<Product> {
let product = Product {
id: Uuid::new_v4(),
name: data.name,
description: data.description,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
self.products.write().await.insert(product.id, product.clone());
Ok(product)
}
async fn find_by_id(&self, id: Uuid) -> Result<Product> {
self.products
.read()
.await
.get(&id)
.cloned()
.ok_or_else(|| domain::DomainError::NotFound(format!("Product not found: {}", id)))
}
async fn find_all(&self) -> Result<Vec<Product>> {
Ok(self.products.read().await.values().cloned().collect())
}
async fn update(&self, id: Uuid, data: UpdateProduct) -> Result<Product> {
let mut products = self.products.write().await;
let product = products
.get_mut(&id)
.ok_or_else(|| 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 = chrono::Utc::now();
Ok(product.clone())
}
async fn delete(&self, id: Uuid) -> Result<()> {
self.products
.write()
.await
.remove(&id)
.map(|_| ())
.ok_or_else(|| domain::DomainError::NotFound(format!("Product not found: {}", id)))
}
}
/// 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<User, InMemoryUserRepository>;
/// 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<Product, InMemoryProductRepository>;
#[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::new("testuser".to_string(), "test@example.com".to_string()).unwrap();
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::new("testuser".to_string(), "test@example.com".to_string()).unwrap();
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::new("user1".to_string(), "user1@example.com".to_string()).unwrap()).await.unwrap();
let user2 = repo.create(CreateUser::new("user2".to_string(), "user2@example.com".to_string()).unwrap()).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::new("olduser".to_string(), "old@example.com".to_string()).unwrap()).await.unwrap();
let original_updated_at = user.updated_at;
sleep(Duration::from_millis(1)).await; // Ensure timestamp difference
let update_data = UpdateUser::new(Some("newuser".to_string()), Some("new@example.com".to_string())).unwrap();
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::new("testuser".to_string(), "test@example.com".to_string()).unwrap()).await.unwrap();
let update_data = UpdateUser::new(Some("newuser".to_string()), None).unwrap();
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::new(Some("newuser".to_string()), None).unwrap();
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::new("testuser".to_string(), "test@example.com".to_string()).unwrap()).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::new(format!("user{}", i), format!("user{}@example.com", i)).unwrap()).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::new("Test Product".to_string(), "Test Description".to_string()).unwrap();
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::new("Test Product".to_string(), "Test Description".to_string()).unwrap();
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::new("Product 1".to_string(), "Description 1".to_string()).unwrap()).await.unwrap();
let product2 = repo.create(CreateProduct::new("Product 2".to_string(), "Description 2".to_string()).unwrap()).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::new("Old Product".to_string(), "Old Description".to_string()).unwrap()).await.unwrap();
let original_updated_at = product.updated_at;
sleep(Duration::from_millis(1)).await; // Ensure timestamp difference
let update_data = UpdateProduct::new(Some("New Product".to_string()), Some("New Description".to_string())).unwrap();
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::new("Test Product".to_string(), "Test Description".to_string()).unwrap()).await.unwrap();
let update_data = UpdateProduct::new(Some("New Product".to_string()), None).unwrap();
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::new(Some("New Product".to_string()), None).unwrap();
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::new("Test Product".to_string(), "Test Description".to_string()).unwrap()).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::new(format!("Product {}", i), format!("Description {}", i)).unwrap()).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::new("testuser".to_string(), "test@example.com".to_string()).unwrap()).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::new(Some("newuser".to_string()), None).unwrap()).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::new("Test Product".to_string(), "Test Description".to_string()).unwrap()).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::new(Some("New Product".to_string()), None).unwrap()).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::new("user1".to_string(), "user1@example.com".to_string()).unwrap()).await.unwrap();
let user2 = repo.create(CreateUser::new("user2".to_string(), "user2@example.com".to_string()).unwrap()).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::new(Some("updated_user1".to_string()), None).unwrap()).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::new("Product 1".to_string(), "Description 1".to_string()).unwrap()).await.unwrap();
let product2 = repo.create(CreateProduct::new("Product 2".to_string(), "Description 2".to_string()).unwrap()).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::new(Some("Updated Product 1".to_string()), None).unwrap()).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);
}
}
}