610 lines
22 KiB
Rust
610 lines
22 KiB
Rust
//! # memory
|
|
//!
|
|
//! This crate provides in-memory implementations of the `Repository` trait for any entity type.
|
|
//! It is primarily intended for testing, development, and CLI/TUI modes where persistence is not required.
|
|
//!
|
|
//! ## Features
|
|
//! - Thread-safe, async in-memory storage for any entity type
|
|
//! - 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, Entity,
|
|
};
|
|
use application::{Repository, Service};
|
|
|
|
/// Generic trait for entities that can be stored in memory
|
|
pub trait MemoryEntity: Entity + Clone + Send + Sync {
|
|
/// Get the entity's ID
|
|
fn id(&self) -> Uuid;
|
|
|
|
/// Create a new entity from create data
|
|
fn from_create_data(data: Self::Create) -> Result<Self>;
|
|
|
|
/// Update the entity with update data
|
|
fn update(&mut self, data: Self::Update) -> Result<()>;
|
|
|
|
/// Get the entity name for error messages
|
|
fn entity_name() -> &'static str;
|
|
}
|
|
|
|
/// Generic, thread-safe, async in-memory repository for any entity type.
|
|
///
|
|
/// Implements all CRUD operations. Intended for testing and non-persistent use cases.
|
|
#[derive(Default, Clone)]
|
|
pub struct InMemoryRepository<T> {
|
|
entities: Arc<RwLock<HashMap<Uuid, T>>>,
|
|
}
|
|
|
|
impl<T: MemoryEntity> InMemoryRepository<T> {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
entities: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: MemoryEntity> Repository<T> for InMemoryRepository<T> {
|
|
async fn create(&self, data: T::Create) -> Result<T> {
|
|
let entity = T::from_create_data(data)?;
|
|
let id = entity.id();
|
|
|
|
self.entities.write().await.insert(id, entity.clone());
|
|
Ok(entity)
|
|
}
|
|
|
|
async fn find_by_id(&self, id: Uuid) -> Result<T> {
|
|
self.entities
|
|
.read()
|
|
.await
|
|
.get(&id)
|
|
.cloned()
|
|
.ok_or_else(|| domain::DomainError::NotFound(format!("{} not found: {}", T::entity_name(), id)))
|
|
}
|
|
|
|
async fn find_all(&self) -> Result<Vec<T>> {
|
|
Ok(self.entities.read().await.values().cloned().collect())
|
|
}
|
|
|
|
async fn update(&self, id: Uuid, data: T::Update) -> Result<T> {
|
|
let mut entities = self.entities.write().await;
|
|
let entity = entities
|
|
.get_mut(&id)
|
|
.ok_or_else(|| domain::DomainError::NotFound(format!("{} not found: {}", T::entity_name(), id)))?;
|
|
|
|
entity.update(data)?;
|
|
Ok(entity.clone())
|
|
}
|
|
|
|
async fn delete(&self, id: Uuid) -> Result<()> {
|
|
self.entities
|
|
.write()
|
|
.await
|
|
.remove(&id)
|
|
.map(|_| ())
|
|
.ok_or_else(|| domain::DomainError::NotFound(format!("{} not found: {}", T::entity_name(), id)))
|
|
}
|
|
}
|
|
|
|
// Implement MemoryEntity for User
|
|
impl MemoryEntity for User {
|
|
/// Get the entity's ID
|
|
fn id(&self) -> Uuid {
|
|
self.id()
|
|
}
|
|
|
|
/// Create a new entity from create data
|
|
fn from_create_data(data: CreateUser) -> Result<Self> {
|
|
User::new(
|
|
Uuid::new_v4(),
|
|
data.username().to_string(),
|
|
data.email().to_string()
|
|
)
|
|
}
|
|
|
|
/// Update the entity with update data
|
|
fn update(&mut self, data: UpdateUser) -> Result<()> {
|
|
if let Some(username) = data.username() {
|
|
self.set_username(username.to_string())?;
|
|
}
|
|
if let Some(email) = data.email() {
|
|
self.set_email(email.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the entity name for error messages
|
|
fn entity_name() -> &'static str {
|
|
"User"
|
|
}
|
|
}
|
|
|
|
// Implement MemoryEntity for Product
|
|
impl MemoryEntity for Product {
|
|
/// Get the entity's ID
|
|
fn id(&self) -> Uuid {
|
|
self.id()
|
|
}
|
|
|
|
/// Create a new entity from create data
|
|
fn from_create_data(data: CreateProduct) -> Result<Self> {
|
|
Product::new(
|
|
Uuid::new_v4(),
|
|
data.name().to_string(),
|
|
data.description().to_string()
|
|
)
|
|
}
|
|
|
|
/// Update the entity with update data
|
|
fn update(&mut self, data: UpdateProduct) -> Result<()> {
|
|
if let Some(name) = data.name() {
|
|
self.set_name(name.to_string())?;
|
|
}
|
|
if let Some(description) = data.description() {
|
|
self.set_description(description.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the entity name for error messages
|
|
fn entity_name() -> &'static str {
|
|
"Product"
|
|
}
|
|
}
|
|
|
|
/// Thread-safe, async in-memory repository for `User` entities.
|
|
///
|
|
/// Implements all CRUD operations. Intended for testing and non-persistent use cases.
|
|
pub type InMemoryUserRepository = InMemoryRepository<User>;
|
|
|
|
/// Thread-safe, async in-memory repository for `Product` entities.
|
|
///
|
|
/// Implements all CRUD operations. Intended for testing and non-persistent use cases.
|
|
pub type InMemoryProductRepository = InMemoryRepository<Product>;
|
|
|
|
/// 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};
|
|
|
|
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());
|
|
}
|
|
|
|
#[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());
|
|
assert!(matches!(result.unwrap_err(), DomainError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_find_all_empty() {
|
|
let repo = InMemoryUserRepository::new();
|
|
|
|
let users = repo.find_all().await.unwrap();
|
|
|
|
assert!(users.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_find_all_with_users() {
|
|
let repo = InMemoryUserRepository::new();
|
|
let create_data1 = CreateUser::new("user1".to_string(), "user1@example.com".to_string()).unwrap();
|
|
let create_data2 = CreateUser::new("user2".to_string(), "user2@example.com".to_string()).unwrap();
|
|
|
|
repo.create(create_data1).await.unwrap();
|
|
repo.create(create_data2).await.unwrap();
|
|
|
|
let users = repo.find_all().await.unwrap();
|
|
|
|
assert_eq!(users.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_update_user_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 update_data = UpdateUser::new(Some("updateduser".to_string()), Some("updated@example.com".to_string())).unwrap();
|
|
let updated_user = repo.update(created_user.id(), update_data).await.unwrap();
|
|
|
|
assert_eq!(updated_user.username(), "updateduser");
|
|
assert_eq!(updated_user.email(), "updated@example.com");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_update_user_partial() {
|
|
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 update_data = UpdateUser::new(Some("updateduser".to_string()), None).unwrap();
|
|
let updated_user = repo.update(created_user.id(), update_data).await.unwrap();
|
|
|
|
assert_eq!(updated_user.username(), "updateduser");
|
|
assert_eq!(updated_user.email(), "test@example.com"); // 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("updateduser".to_string()), None).unwrap();
|
|
|
|
let result = repo.update(non_existent_id, update_data).await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), DomainError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_delete_user_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 result = repo.delete(created_user.id()).await;
|
|
|
|
assert!(result.is_ok());
|
|
assert!(repo.find_all().await.unwrap().is_empty());
|
|
}
|
|
|
|
#[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());
|
|
assert!(matches!(result.unwrap_err(), DomainError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_concurrent_access() {
|
|
let repo = InMemoryUserRepository::new();
|
|
let repo_clone = repo.clone();
|
|
|
|
// Spawn multiple tasks that create users concurrently
|
|
let handles: Vec<_> = (0..10)
|
|
.map(|i| {
|
|
let repo = repo_clone.clone();
|
|
tokio::spawn(async move {
|
|
let create_data = CreateUser::new(
|
|
format!("user{}", i),
|
|
format!("user{}@example.com", i)
|
|
).unwrap();
|
|
repo.create(create_data).await
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Wait for all tasks to complete
|
|
for handle in handles {
|
|
handle.await.unwrap().unwrap();
|
|
}
|
|
|
|
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("testproduct".to_string(), "Test description".to_string()).unwrap();
|
|
|
|
let product = repo.create(create_data).await.unwrap();
|
|
|
|
assert_eq!(product.name(), "testproduct");
|
|
assert_eq!(product.description(), "Test description");
|
|
assert!(!product.id().is_nil());
|
|
assert!(product.created_at() <= chrono::Utc::now());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_find_by_id_existing() {
|
|
let repo = InMemoryProductRepository::new();
|
|
let create_data = CreateProduct::new("testproduct".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());
|
|
assert!(matches!(result.unwrap_err(), DomainError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_find_all_empty() {
|
|
let repo = InMemoryProductRepository::new();
|
|
|
|
let products = repo.find_all().await.unwrap();
|
|
|
|
assert!(products.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_find_all_with_products() {
|
|
let repo = InMemoryProductRepository::new();
|
|
let create_data1 = CreateProduct::new("product1".to_string(), "Description 1".to_string()).unwrap();
|
|
let create_data2 = CreateProduct::new("product2".to_string(), "Description 2".to_string()).unwrap();
|
|
|
|
repo.create(create_data1).await.unwrap();
|
|
repo.create(create_data2).await.unwrap();
|
|
|
|
let products = repo.find_all().await.unwrap();
|
|
|
|
assert_eq!(products.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_update_product_existing() {
|
|
let repo = InMemoryProductRepository::new();
|
|
let create_data = CreateProduct::new("testproduct".to_string(), "Test description".to_string()).unwrap();
|
|
let created_product = repo.create(create_data).await.unwrap();
|
|
|
|
let update_data = UpdateProduct::new(Some("updatedproduct".to_string()), Some("Updated description".to_string())).unwrap();
|
|
let updated_product = repo.update(created_product.id(), update_data).await.unwrap();
|
|
|
|
assert_eq!(updated_product.name(), "updatedproduct");
|
|
assert_eq!(updated_product.description(), "Updated description");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_update_product_partial() {
|
|
let repo = InMemoryProductRepository::new();
|
|
let create_data = CreateProduct::new("testproduct".to_string(), "Test description".to_string()).unwrap();
|
|
let created_product = repo.create(create_data).await.unwrap();
|
|
|
|
let update_data = UpdateProduct::new(Some("updatedproduct".to_string()), None).unwrap();
|
|
let updated_product = repo.update(created_product.id(), update_data).await.unwrap();
|
|
|
|
assert_eq!(updated_product.name(), "updatedproduct");
|
|
assert_eq!(updated_product.description(), "Test description"); // 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("updatedproduct".to_string()), None).unwrap();
|
|
|
|
let result = repo.update(non_existent_id, update_data).await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), DomainError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_delete_product_existing() {
|
|
let repo = InMemoryProductRepository::new();
|
|
let create_data = CreateProduct::new("testproduct".to_string(), "Test description".to_string()).unwrap();
|
|
let created_product = repo.create(create_data).await.unwrap();
|
|
|
|
let result = repo.delete(created_product.id()).await;
|
|
|
|
assert!(result.is_ok());
|
|
assert!(repo.find_all().await.unwrap().is_empty());
|
|
}
|
|
|
|
#[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());
|
|
assert!(matches!(result.unwrap_err(), DomainError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_concurrent_access() {
|
|
let repo = InMemoryProductRepository::new();
|
|
let repo_clone = repo.clone();
|
|
|
|
// Spawn multiple tasks that create products concurrently
|
|
let handles: Vec<_> = (0..10)
|
|
.map(|i| {
|
|
let repo = repo_clone.clone();
|
|
tokio::spawn(async move {
|
|
let create_data = CreateProduct::new(
|
|
format!("product{}", i),
|
|
format!("Description {}", i)
|
|
).unwrap();
|
|
repo.create(create_data).await
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Wait for all tasks to complete
|
|
for handle in handles {
|
|
handle.await.unwrap().unwrap();
|
|
}
|
|
|
|
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 service = MemoryUserService::new(InMemoryUserRepository::new());
|
|
let create_data = CreateUser::new("testuser".to_string(), "test@example.com".to_string()).unwrap();
|
|
|
|
let user = service.create(create_data).await.unwrap();
|
|
assert_eq!(user.username(), "testuser");
|
|
|
|
let found_user = service.get(user.id()).await.unwrap();
|
|
assert_eq!(found_user.id(), user.id());
|
|
|
|
let users = service.list().await.unwrap();
|
|
assert_eq!(users.len(), 1);
|
|
|
|
let update_data = UpdateUser::new(Some("updateduser".to_string()), None).unwrap();
|
|
let updated_user = service.update(user.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_user.username(), "updateduser");
|
|
|
|
service.delete(user.id()).await.unwrap();
|
|
let users_after_delete = service.list().await.unwrap();
|
|
assert!(users_after_delete.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_memory_product_service() {
|
|
let service = MemoryProductService::new(InMemoryProductRepository::new());
|
|
let create_data = CreateProduct::new("testproduct".to_string(), "Test description".to_string()).unwrap();
|
|
|
|
let product = service.create(create_data).await.unwrap();
|
|
assert_eq!(product.name(), "testproduct");
|
|
|
|
let found_product = service.get(product.id()).await.unwrap();
|
|
assert_eq!(found_product.id(), product.id());
|
|
|
|
let products = service.list().await.unwrap();
|
|
assert_eq!(products.len(), 1);
|
|
|
|
let update_data = UpdateProduct::new(Some("updatedproduct".to_string()), None).unwrap();
|
|
let updated_product = service.update(product.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_product.name(), "updatedproduct");
|
|
|
|
service.delete(product.id()).await.unwrap();
|
|
let products_after_delete = service.list().await.unwrap();
|
|
assert!(products_after_delete.is_empty());
|
|
}
|
|
}
|
|
|
|
mod integration_tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_user_lifecycle() {
|
|
let repo = InMemoryUserRepository::new();
|
|
|
|
// Create
|
|
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");
|
|
|
|
// Read
|
|
let found_user = repo.find_by_id(user.id()).await.unwrap();
|
|
assert_eq!(found_user.id(), user.id());
|
|
|
|
// Update
|
|
let update_data = UpdateUser::new(Some("updateduser".to_string()), Some("updated@example.com".to_string())).unwrap();
|
|
let updated_user = repo.update(user.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_user.username(), "updateduser");
|
|
assert_eq!(updated_user.email(), "updated@example.com");
|
|
|
|
// Delete
|
|
repo.delete(user.id()).await.unwrap();
|
|
let result = repo.find_by_id(user.id()).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_product_lifecycle() {
|
|
let repo = InMemoryProductRepository::new();
|
|
|
|
// Create
|
|
let create_data = CreateProduct::new("testproduct".to_string(), "Test description".to_string()).unwrap();
|
|
let product = repo.create(create_data).await.unwrap();
|
|
assert_eq!(product.name(), "testproduct");
|
|
|
|
// Read
|
|
let found_product = repo.find_by_id(product.id()).await.unwrap();
|
|
assert_eq!(found_product.id(), product.id());
|
|
|
|
// Update
|
|
let update_data = UpdateProduct::new(Some("updatedproduct".to_string()), Some("Updated description".to_string())).unwrap();
|
|
let updated_product = repo.update(product.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_product.name(), "updatedproduct");
|
|
assert_eq!(updated_product.description(), "Updated description");
|
|
|
|
// Delete
|
|
repo.delete(product.id()).await.unwrap();
|
|
let result = repo.find_by_id(product.id()).await;
|
|
assert!(result.is_err());
|
|
}
|
|
}
|
|
}
|