1123 lines
39 KiB
Rust
1123 lines
39 KiB
Rust
/*
|
|
* 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 sqlx::PgPool;
|
|
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 PostgreSQL
|
|
pub trait PostgresEntity: Entity + Send + Sync {
|
|
/// Get the entity's ID
|
|
fn id(&self) -> Uuid;
|
|
|
|
/// Get the table name for SQL queries
|
|
fn table_name() -> &'static str;
|
|
|
|
/// Get the entity name for error messages
|
|
fn entity_name() -> &'static str;
|
|
|
|
/// Create a new entity from database record
|
|
fn from_db_record(record: Self::DbRecord) -> Self;
|
|
|
|
/// Get the column names for SELECT queries (excluding id)
|
|
fn select_columns() -> &'static str;
|
|
|
|
/// Get the column names for INSERT queries (excluding id, created_at, updated_at)
|
|
fn insert_columns() -> &'static str;
|
|
|
|
/// Get the placeholder values for INSERT queries
|
|
fn insert_placeholders() -> &'static str;
|
|
|
|
/// Get the SET clause for UPDATE queries
|
|
fn update_set_clause() -> &'static str;
|
|
|
|
/// Extract values from create data for INSERT
|
|
fn extract_create_values(data: &Self::Create) -> Vec<String>;
|
|
|
|
/// Extract values from update data for UPDATE
|
|
fn extract_update_values(data: &Self::Update) -> Vec<Option<String>>;
|
|
|
|
/// The database record type returned by SQLx queries
|
|
type DbRecord: Send + Sync + Unpin + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>;
|
|
}
|
|
|
|
/// Generic, thread-safe, async PostgreSQL repository for any entity type.
|
|
///
|
|
/// Implements all CRUD operations using SQLx. Intended for production use with PostgreSQL.
|
|
#[derive(Clone)]
|
|
pub struct PostgresRepository<T> {
|
|
pool: PgPool,
|
|
_phantom: std::marker::PhantomData<T>,
|
|
}
|
|
|
|
impl<T: PostgresEntity> PostgresRepository<T> {
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self {
|
|
pool,
|
|
_phantom: std::marker::PhantomData,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: PostgresEntity> Repository<T> for PostgresRepository<T> {
|
|
async fn create(&self, data: T::Create) -> Result<T> {
|
|
let columns = T::insert_columns();
|
|
let placeholders = T::insert_placeholders();
|
|
let values = T::extract_create_values(&data);
|
|
|
|
let query = format!(
|
|
r#"
|
|
INSERT INTO {} ({})
|
|
VALUES ({})
|
|
RETURNING id, {}
|
|
"#,
|
|
T::table_name(),
|
|
columns,
|
|
placeholders,
|
|
T::select_columns()
|
|
);
|
|
|
|
// Build the query dynamically
|
|
let mut query_builder = sqlx::query_as::<_, T::DbRecord>(&query);
|
|
|
|
// Add the values as parameters
|
|
for value in values {
|
|
query_builder = query_builder.bind(value);
|
|
}
|
|
|
|
let rec = query_builder
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| domain::DomainError::Internal(e.to_string()))?;
|
|
|
|
Ok(T::from_db_record(rec))
|
|
}
|
|
|
|
async fn find_by_id(&self, id: Uuid) -> Result<T> {
|
|
let query = format!(
|
|
r#"
|
|
SELECT id, {}
|
|
FROM {}
|
|
WHERE id = $1
|
|
"#,
|
|
T::select_columns(),
|
|
T::table_name()
|
|
);
|
|
|
|
let rec = sqlx::query_as::<_, T::DbRecord>(&query)
|
|
.bind(id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| domain::DomainError::Internal(e.to_string()))?
|
|
.ok_or_else(|| domain::DomainError::NotFound(format!("{} not found: {}", T::entity_name(), id)))?;
|
|
|
|
Ok(T::from_db_record(rec))
|
|
}
|
|
|
|
async fn find_all(&self) -> Result<Vec<T>> {
|
|
let query = format!(
|
|
r#"
|
|
SELECT id, {}
|
|
FROM {}
|
|
"#,
|
|
T::select_columns(),
|
|
T::table_name()
|
|
);
|
|
|
|
let recs = sqlx::query_as::<_, T::DbRecord>(&query)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| domain::DomainError::Internal(e.to_string()))?;
|
|
|
|
Ok(recs.into_iter().map(T::from_db_record).collect())
|
|
}
|
|
|
|
async fn update(&self, id: Uuid, data: T::Update) -> Result<T> {
|
|
let set_clause = T::update_set_clause();
|
|
let values = T::extract_update_values(&data);
|
|
|
|
let query = format!(
|
|
r#"
|
|
UPDATE {}
|
|
SET {}, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ${}
|
|
RETURNING id, {}
|
|
"#,
|
|
T::table_name(),
|
|
set_clause,
|
|
values.len() + 1, // +1 for the id parameter
|
|
T::select_columns()
|
|
);
|
|
|
|
// Build the query dynamically
|
|
let mut query_builder = sqlx::query_as::<_, T::DbRecord>(&query);
|
|
|
|
// Add the update values as parameters
|
|
for value in values {
|
|
query_builder = query_builder.bind(value);
|
|
}
|
|
|
|
// Add the id parameter
|
|
query_builder = query_builder.bind(id);
|
|
|
|
let rec = query_builder
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| domain::DomainError::Internal(e.to_string()))?
|
|
.ok_or_else(|| domain::DomainError::NotFound(format!("{} not found: {}", T::entity_name(), id)))?;
|
|
|
|
Ok(T::from_db_record(rec))
|
|
}
|
|
|
|
async fn delete(&self, id: Uuid) -> Result<()> {
|
|
let query = format!(
|
|
r#"
|
|
DELETE FROM {}
|
|
WHERE id = $1
|
|
"#,
|
|
T::table_name()
|
|
);
|
|
|
|
let result = sqlx::query(&query)
|
|
.bind(id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| domain::DomainError::Internal(e.to_string()))?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(domain::DomainError::NotFound(format!("{} not found: {}", T::entity_name(), id)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// Database record types for SQLx
|
|
#[derive(sqlx::FromRow)]
|
|
pub struct UserRecord {
|
|
pub id: Uuid,
|
|
pub username: String,
|
|
pub email: String,
|
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
pub struct ProductRecord {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
// Implement PostgresEntity for User
|
|
impl PostgresEntity for User {
|
|
fn id(&self) -> Uuid {
|
|
self.id()
|
|
}
|
|
|
|
fn table_name() -> &'static str {
|
|
"users"
|
|
}
|
|
|
|
fn entity_name() -> &'static str {
|
|
"User"
|
|
}
|
|
|
|
fn from_db_record(record: UserRecord) -> Self {
|
|
User::from_db(
|
|
record.id,
|
|
record.username,
|
|
record.email,
|
|
record.created_at,
|
|
record.updated_at,
|
|
)
|
|
}
|
|
|
|
fn select_columns() -> &'static str {
|
|
"username, email, created_at, updated_at"
|
|
}
|
|
|
|
fn insert_columns() -> &'static str {
|
|
"username, email"
|
|
}
|
|
|
|
fn insert_placeholders() -> &'static str {
|
|
"$1, $2"
|
|
}
|
|
|
|
fn update_set_clause() -> &'static str {
|
|
"username = COALESCE($1, username), email = COALESCE($2, email)"
|
|
}
|
|
|
|
fn extract_create_values(data: &CreateUser) -> Vec<String> {
|
|
vec![
|
|
data.username().to_string(),
|
|
data.email().to_string(),
|
|
]
|
|
}
|
|
|
|
fn extract_update_values(data: &UpdateUser) -> Vec<Option<String>> {
|
|
vec![
|
|
data.username().map(|s| s.to_string()),
|
|
data.email().map(|s| s.to_string()),
|
|
]
|
|
}
|
|
|
|
type DbRecord = UserRecord;
|
|
}
|
|
|
|
// Implement PostgresEntity for Product
|
|
impl PostgresEntity for Product {
|
|
fn id(&self) -> Uuid {
|
|
self.id()
|
|
}
|
|
|
|
fn table_name() -> &'static str {
|
|
"products"
|
|
}
|
|
|
|
fn entity_name() -> &'static str {
|
|
"Product"
|
|
}
|
|
|
|
fn from_db_record(record: ProductRecord) -> Self {
|
|
Product::from_db(
|
|
record.id,
|
|
record.name,
|
|
record.description,
|
|
record.created_at,
|
|
record.updated_at,
|
|
)
|
|
}
|
|
|
|
fn select_columns() -> &'static str {
|
|
"name, description, created_at, updated_at"
|
|
}
|
|
|
|
fn insert_columns() -> &'static str {
|
|
"name, description"
|
|
}
|
|
|
|
fn insert_placeholders() -> &'static str {
|
|
"$1, $2"
|
|
}
|
|
|
|
fn update_set_clause() -> &'static str {
|
|
"name = COALESCE($1, name), description = COALESCE($2, description)"
|
|
}
|
|
|
|
fn extract_create_values(data: &CreateProduct) -> Vec<String> {
|
|
vec![
|
|
data.name().to_string(),
|
|
data.description().to_string(),
|
|
]
|
|
}
|
|
|
|
fn extract_update_values(data: &UpdateProduct) -> Vec<Option<String>> {
|
|
vec![
|
|
data.name().map(|s| s.to_string()),
|
|
data.description().map(|s| s.to_string()),
|
|
]
|
|
}
|
|
|
|
type DbRecord = ProductRecord;
|
|
}
|
|
|
|
/// Type aliases for backward compatibility and convenience
|
|
pub type PostgresUserRepository = PostgresRepository<User>;
|
|
pub type PostgresProductRepository = PostgresRepository<Product>;
|
|
|
|
/// Type aliases for services
|
|
pub type PostgresUserService = Service<User, PostgresUserRepository>;
|
|
pub type PostgresProductService = Service<Product, PostgresProductRepository>;
|
|
|
|
/*
|
|
* Example: How to add a new entity type (e.g., Order) with the generic approach:
|
|
*
|
|
* 1. Define the database record type:
|
|
* #[derive(sqlx::FromRow)]
|
|
* pub struct OrderRecord {
|
|
* pub id: Uuid,
|
|
* pub customer_id: Uuid,
|
|
* pub total_amount: Decimal,
|
|
* pub status: String,
|
|
* pub created_at: chrono::DateTime<chrono::Utc>,
|
|
* pub updated_at: chrono::DateTime<chrono::Utc>,
|
|
* }
|
|
*
|
|
* 2. Implement PostgresEntity for Order:
|
|
* impl PostgresEntity for Order {
|
|
* fn id(&self) -> Uuid { self.id() }
|
|
* fn table_name() -> &'static str { "orders" }
|
|
* fn entity_name() -> &'static str { "Order" }
|
|
* fn from_db_record(record: OrderRecord) -> Self {
|
|
* Order::from_db(record.id, record.customer_id, record.total_amount,
|
|
* record.status, record.created_at, record.updated_at)
|
|
* }
|
|
* fn select_columns() -> &'static str { "customer_id, total_amount, status, created_at, updated_at" }
|
|
* fn insert_columns() -> &'static str { "customer_id, total_amount, status" }
|
|
* fn insert_placeholders() -> &'static str { "$1, $2, $3" }
|
|
* fn update_set_clause() -> &'static str {
|
|
* "customer_id = COALESCE($1, customer_id), total_amount = COALESCE($2, total_amount), status = COALESCE($3, status)"
|
|
* }
|
|
* fn extract_create_values(data: &CreateOrder) -> Vec<String> {
|
|
* vec![data.customer_id().to_string(), data.total_amount().to_string(), data.status().to_string()]
|
|
* }
|
|
* fn extract_update_values(data: &UpdateOrder) -> Vec<Option<String>> {
|
|
* vec![data.customer_id().map(|id| id.to_string()),
|
|
* data.total_amount().map(|amt| amt.to_string()),
|
|
* data.status().map(|s| s.to_string())]
|
|
* }
|
|
* type DbRecord = OrderRecord;
|
|
* }
|
|
*
|
|
* 3. Create type aliases:
|
|
* pub type PostgresOrderRepository = PostgresRepository<Order>;
|
|
* pub type PostgresOrderService = Service<Order, PostgresOrderRepository>;
|
|
*
|
|
* That's it! The generic PostgresRepository<T> handles all CRUD operations automatically.
|
|
* The only entity-specific code needed is the trait implementation and record type.
|
|
*/
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use sqlx::PgPool;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use std::env;
|
|
use chrono::Utc;
|
|
use serial_test::serial;
|
|
|
|
// Test database setup
|
|
async fn setup_test_db() -> PgPool {
|
|
let database_url = env::var("DATABASE_URL")
|
|
.unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/sharenet_test".to_string());
|
|
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(5)
|
|
.connect(&database_url)
|
|
.await
|
|
.expect("Failed to connect to test database");
|
|
|
|
// Run migrations
|
|
sqlx::migrate!("../../migrations")
|
|
.run(&pool)
|
|
.await
|
|
.expect("Failed to run migrations");
|
|
|
|
// Clean up any existing test data
|
|
cleanup_test_data(&pool).await;
|
|
|
|
pool
|
|
}
|
|
|
|
// Clean up test data
|
|
async fn cleanup_test_data(pool: &PgPool) {
|
|
let mut tx = pool.begin().await.expect("Failed to begin transaction");
|
|
|
|
// Delete in reverse order of foreign key dependencies
|
|
sqlx::query("DELETE FROM products").execute(&mut *tx).await.expect("Failed to delete products");
|
|
sqlx::query("DELETE FROM users").execute(&mut *tx).await.expect("Failed to delete users");
|
|
|
|
tx.commit().await.expect("Failed to commit cleanup transaction");
|
|
}
|
|
|
|
// Generate unique test data to avoid conflicts between concurrent tests
|
|
fn unique_test_data(prefix: &str) -> (String, String) {
|
|
let id = Uuid::new_v4().to_string()[..8].to_string();
|
|
(format!("{}_{}", prefix, id), format!("{}_test@example.com", prefix))
|
|
}
|
|
|
|
mod user_repository_tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_create_user() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateUser::new("testuser".to_string(), "test@example.com".to_string()).unwrap();
|
|
|
|
let result = repo.create(create_data).await;
|
|
assert!(result.is_ok());
|
|
|
|
let user = result.unwrap();
|
|
assert_eq!(user.username(), "testuser");
|
|
assert_eq!(user.email(), "test@example.com");
|
|
assert!(user.id() != Uuid::nil());
|
|
assert!(user.created_at() <= Utc::now());
|
|
assert!(user.updated_at() <= Utc::now());
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_create_user_with_duplicate_username() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateUser::new("duplicate_user".to_string(), "test1@example.com".to_string()).unwrap();
|
|
|
|
// Create first user
|
|
let result1 = repo.create(create_data.clone()).await;
|
|
assert!(result1.is_ok());
|
|
|
|
// Try to create second user with same username
|
|
let create_data2 = CreateUser::new("duplicate_user".to_string(), "test2@example.com".to_string()).unwrap();
|
|
let result2 = repo.create(create_data2).await;
|
|
assert!(result2.is_err());
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_user_by_id() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateUser::new("finduser".to_string(), "find@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;
|
|
|
|
assert!(found_user.is_ok());
|
|
let user = found_user.unwrap();
|
|
assert_eq!(user.id(), created_user.id());
|
|
assert_eq!(user.username(), "finduser");
|
|
assert_eq!(user.email(), "find@example.com");
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_user_by_nonexistent_id() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let nonexistent_id = Uuid::new_v4();
|
|
let result = repo.find_by_id(nonexistent_id).await;
|
|
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
domain::DomainError::NotFound(msg) => {
|
|
assert!(msg.contains("User not found"));
|
|
}
|
|
_ => panic!("Expected NotFound error"),
|
|
}
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_all_users() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
// Create multiple users with unique usernames
|
|
let (username1, email1) = unique_test_data("user1");
|
|
let (username2, email2) = unique_test_data("user2");
|
|
|
|
let _user1 = repo.create(CreateUser::new(username1.clone(), email1).unwrap()).await.unwrap();
|
|
|
|
let _user2 = repo.create(CreateUser::new(username2.clone(), email2).unwrap()).await.unwrap();
|
|
|
|
let users = repo.find_all().await.unwrap();
|
|
assert_eq!(users.len(), 2);
|
|
|
|
let usernames: Vec<String> = users.iter().map(|u| u.username().to_string()).collect();
|
|
assert!(usernames.contains(&username1));
|
|
assert!(usernames.contains(&username2));
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_all_users_empty() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let users = repo.find_all().await.unwrap();
|
|
assert_eq!(users.len(), 0);
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_user() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateUser::new("updateuser".to_string(), "update@example.com".to_string()).unwrap();
|
|
|
|
let user = repo.create(create_data).await.unwrap();
|
|
let original_updated_at = user.updated_at();
|
|
|
|
// Update username only
|
|
let update_data = UpdateUser::new(Some("updateduser".to_string()), None).unwrap();
|
|
|
|
let updated_user = repo.update(user.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_user.username(), "updateduser");
|
|
assert_eq!(updated_user.email(), "update@example.com"); // Should remain unchanged
|
|
assert!(updated_user.updated_at() > original_updated_at);
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_user_email_only() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateUser::new("emailuser".to_string(), "old@example.com".to_string()).unwrap();
|
|
|
|
let user = repo.create(create_data).await.unwrap();
|
|
|
|
let update_data = UpdateUser::new(None, Some("new@example.com".to_string())).unwrap();
|
|
|
|
let updated_user = repo.update(user.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_user.username(), "emailuser"); // Should remain unchanged
|
|
assert_eq!(updated_user.email(), "new@example.com");
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_user_both_fields() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateUser::new("bothuser".to_string(), "both@example.com".to_string()).unwrap();
|
|
|
|
let user = repo.create(create_data).await.unwrap();
|
|
|
|
let update_data = UpdateUser::new(Some("newbothuser".to_string()), Some("newboth@example.com".to_string())).unwrap();
|
|
|
|
let updated_user = repo.update(user.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_user.username(), "newbothuser");
|
|
assert_eq!(updated_user.email(), "newboth@example.com");
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_nonexistent_user() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let nonexistent_id = Uuid::new_v4();
|
|
let update_data = UpdateUser::new(Some("nonexistent".to_string()), None).unwrap();
|
|
|
|
let result = repo.update(nonexistent_id, update_data).await;
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
domain::DomainError::NotFound(msg) => {
|
|
assert!(msg.contains("User not found"));
|
|
}
|
|
_ => panic!("Expected NotFound error"),
|
|
}
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_delete_user() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let (username, email) = unique_test_data("delete_user");
|
|
let create_data = CreateUser::new(username.clone(), email.clone()).unwrap();
|
|
|
|
let user = repo.create(create_data).await.unwrap();
|
|
let user_id = user.id();
|
|
|
|
// Verify user exists
|
|
let found_user = repo.find_by_id(user_id).await;
|
|
assert!(found_user.is_ok());
|
|
|
|
// Delete user
|
|
let delete_result = repo.delete(user_id).await;
|
|
assert!(delete_result.is_ok());
|
|
|
|
// Verify user no longer exists
|
|
let found_user_after_delete = repo.find_by_id(user_id).await;
|
|
assert!(found_user_after_delete.is_err());
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_delete_nonexistent_user() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let nonexistent_id = Uuid::new_v4();
|
|
let result = repo.delete(nonexistent_id).await;
|
|
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
domain::DomainError::NotFound(msg) => {
|
|
assert!(msg.contains("User not found"));
|
|
}
|
|
_ => panic!("Expected NotFound error"),
|
|
}
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_concurrent_access() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let (username, email) = unique_test_data("concurrent_user");
|
|
let create_data = CreateUser::new(username.clone(), email.clone()).unwrap();
|
|
|
|
// Create a user
|
|
let user = repo.create(create_data).await.unwrap();
|
|
|
|
// Test concurrent access with a simpler approach
|
|
let repo_clone = repo.clone();
|
|
let user_id = user.id();
|
|
|
|
// Spawn a single concurrent task
|
|
let handle = tokio::spawn(async move {
|
|
repo_clone.find_by_id(user_id).await
|
|
});
|
|
|
|
// Also do a direct access
|
|
let direct_result = repo.find_by_id(user_id).await;
|
|
|
|
// Wait for the spawned task
|
|
let spawned_result = handle.await.unwrap();
|
|
|
|
// Both should succeed
|
|
assert!(direct_result.is_ok());
|
|
assert!(spawned_result.is_ok());
|
|
|
|
// Both should return the same user
|
|
assert_eq!(direct_result.unwrap().id(), user_id);
|
|
assert_eq!(spawned_result.unwrap().id(), user_id);
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
}
|
|
|
|
mod product_repository_tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_create_product() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateProduct::new("testproduct".to_string(), "test description".to_string()).unwrap();
|
|
|
|
let result = repo.create(create_data).await;
|
|
assert!(result.is_ok());
|
|
|
|
let product = result.unwrap();
|
|
assert_eq!(product.name(), "testproduct");
|
|
assert_eq!(product.description(), "test description");
|
|
assert!(product.id() != Uuid::nil());
|
|
assert!(product.created_at() <= Utc::now());
|
|
assert!(product.updated_at() <= Utc::now());
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_product_by_id() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateProduct::new("findproduct".to_string(), "find description".to_string()).unwrap();
|
|
|
|
let created_product = repo.create(create_data).await.unwrap();
|
|
let found_product = repo.find_by_id(created_product.id()).await;
|
|
|
|
assert!(found_product.is_ok());
|
|
let product = found_product.unwrap();
|
|
assert_eq!(product.id(), created_product.id());
|
|
assert_eq!(product.name(), "findproduct");
|
|
assert_eq!(product.description(), "find description");
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_product_by_nonexistent_id() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let nonexistent_id = Uuid::new_v4();
|
|
let result = repo.find_by_id(nonexistent_id).await;
|
|
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
domain::DomainError::NotFound(msg) => {
|
|
assert!(msg.contains("Product not found"));
|
|
}
|
|
_ => panic!("Expected NotFound error"),
|
|
}
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_all_products() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
// Create multiple products with unique names
|
|
let (name1, description1) = unique_test_data("product1");
|
|
let (name2, description2) = unique_test_data("product2");
|
|
|
|
let _product1 = repo.create(CreateProduct::new(name1.clone(), description1).unwrap()).await.unwrap();
|
|
|
|
let _product2 = repo.create(CreateProduct::new(name2.clone(), description2).unwrap()).await.unwrap();
|
|
|
|
let products = repo.find_all().await.unwrap();
|
|
assert_eq!(products.len(), 2);
|
|
|
|
let names: Vec<String> = products.iter().map(|p| p.name().to_string()).collect();
|
|
assert!(names.contains(&name1));
|
|
assert!(names.contains(&name2));
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_find_all_products_empty() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let products = repo.find_all().await.unwrap();
|
|
assert_eq!(products.len(), 0);
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_product() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateProduct::new("updateproduct".to_string(), "update description".to_string()).unwrap();
|
|
|
|
let product = repo.create(create_data).await.unwrap();
|
|
let original_updated_at = product.updated_at();
|
|
|
|
// Update name only
|
|
let update_data = UpdateProduct::new(Some("updatedproduct".to_string()), None).unwrap();
|
|
|
|
let updated_product = repo.update(product.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_product.name(), "updatedproduct");
|
|
assert_eq!(updated_product.description(), "update description"); // Should remain unchanged
|
|
assert!(updated_product.updated_at() > original_updated_at);
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_product_description_only() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateProduct::new("descriptionproduct".to_string(), "old description".to_string()).unwrap();
|
|
|
|
let product = repo.create(create_data).await.unwrap();
|
|
|
|
let update_data = UpdateProduct::new(None, Some("new description".to_string())).unwrap();
|
|
|
|
let updated_product = repo.update(product.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_product.name(), "descriptionproduct"); // Should remain unchanged
|
|
assert_eq!(updated_product.description(), "new description");
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_product_both_fields() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let create_data = CreateProduct::new("bothproduct".to_string(), "both description".to_string()).unwrap();
|
|
|
|
let product = repo.create(create_data).await.unwrap();
|
|
|
|
let update_data = UpdateProduct::new(Some("newbothproduct".to_string()), Some("new both description".to_string())).unwrap();
|
|
|
|
let updated_product = repo.update(product.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_product.name(), "newbothproduct");
|
|
assert_eq!(updated_product.description(), "new both description");
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_update_nonexistent_product() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let nonexistent_id = Uuid::new_v4();
|
|
let update_data = UpdateProduct::new(Some("nonexistent".to_string()), None).unwrap();
|
|
|
|
let result = repo.update(nonexistent_id, update_data).await;
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
domain::DomainError::NotFound(msg) => {
|
|
assert!(msg.contains("Product not found"));
|
|
}
|
|
_ => panic!("Expected NotFound error"),
|
|
}
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_delete_product() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let (name, description) = unique_test_data("delete_product");
|
|
let create_data = CreateProduct::new(name.clone(), description.clone()).unwrap();
|
|
|
|
let product = repo.create(create_data).await.unwrap();
|
|
let product_id = product.id();
|
|
|
|
// Verify product exists
|
|
let found_product = repo.find_by_id(product_id).await;
|
|
assert!(found_product.is_ok());
|
|
|
|
// Delete product
|
|
let delete_result = repo.delete(product_id).await;
|
|
assert!(delete_result.is_ok());
|
|
|
|
// Verify product no longer exists
|
|
let found_product_after_delete = repo.find_by_id(product_id).await;
|
|
assert!(found_product_after_delete.is_err());
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_delete_nonexistent_product() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let nonexistent_id = Uuid::new_v4();
|
|
let result = repo.delete(nonexistent_id).await;
|
|
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
domain::DomainError::NotFound(msg) => {
|
|
assert!(msg.contains("Product not found"));
|
|
}
|
|
_ => panic!("Expected NotFound error"),
|
|
}
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
}
|
|
|
|
mod service_tests {
|
|
use super::*;
|
|
use application::UseCase;
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_postgres_user_service() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresUserRepository::new(pool.clone());
|
|
let service = PostgresUserService::new(repo);
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let (username, email) = unique_test_data("service_user");
|
|
let create_data = CreateUser::new(username.clone(), email.clone()).unwrap();
|
|
|
|
// Test create
|
|
let user = service.create(create_data).await.unwrap();
|
|
assert_eq!(user.username(), username);
|
|
|
|
// Test get
|
|
let found_user = service.get(user.id()).await.unwrap();
|
|
assert_eq!(found_user.id(), user.id());
|
|
|
|
// Test list - should have exactly 1 user
|
|
let users = service.list().await.unwrap();
|
|
assert_eq!(users.len(), 1);
|
|
assert_eq!(users[0].username(), username);
|
|
|
|
// Test update
|
|
let (new_username, _) = unique_test_data("updated_service_user");
|
|
let update_data = UpdateUser::new(Some(new_username.clone()), None).unwrap();
|
|
let updated_user = service.update(user.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_user.username(), new_username);
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_postgres_product_service() {
|
|
let pool = setup_test_db().await;
|
|
let repo = PostgresProductRepository::new(pool.clone());
|
|
let service = PostgresProductService::new(repo);
|
|
|
|
// Clean up at the beginning to ensure isolation
|
|
cleanup_test_data(&pool).await;
|
|
|
|
let (name, description) = unique_test_data("service_product");
|
|
let create_data = CreateProduct::new(name.clone(), description.clone()).unwrap();
|
|
|
|
// Test create
|
|
let product = service.create(create_data).await.unwrap();
|
|
assert_eq!(product.name(), name);
|
|
|
|
// Test get
|
|
let found_product = service.get(product.id()).await.unwrap();
|
|
assert_eq!(found_product.id(), product.id());
|
|
assert_eq!(found_product.name(), name);
|
|
|
|
// Test list - should have exactly 1 product
|
|
let products = service.list().await.unwrap();
|
|
assert_eq!(products.len(), 1);
|
|
assert_eq!(products[0].name(), name);
|
|
|
|
// Test update
|
|
let (new_name, _) = unique_test_data("updated_service_product");
|
|
let update_data = UpdateProduct::new(Some(new_name.clone()), None).unwrap();
|
|
let updated_product = service.update(product.id(), update_data).await.unwrap();
|
|
assert_eq!(updated_product.name(), new_name);
|
|
|
|
cleanup_test_data(&pool).await;
|
|
}
|
|
}
|
|
|
|
mod error_handling_tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_database_connection_error() {
|
|
// Test with invalid database URL
|
|
let invalid_pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("postgres://invalid:invalid@localhost:5432/nonexistent")
|
|
.await;
|
|
|
|
assert!(invalid_pool.is_err());
|
|
}
|
|
}
|
|
}
|