/* * 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 */ 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; /// Extract values from update data for UPDATE fn extract_update_values(data: &Self::Update) -> Vec>; /// 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 { pool: PgPool, _phantom: std::marker::PhantomData, } impl PostgresRepository { pub fn new(pool: PgPool) -> Self { Self { pool, _phantom: std::marker::PhantomData, } } } impl Repository for PostgresRepository { async fn create(&self, data: T::Create) -> Result { 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 { 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> { 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 { 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, pub updated_at: chrono::DateTime, } #[derive(sqlx::FromRow)] pub struct ProductRecord { pub id: Uuid, pub name: String, pub description: String, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } // 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 { vec![ data.username().to_string(), data.email().to_string(), ] } fn extract_update_values(data: &UpdateUser) -> Vec> { 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 { vec![ data.name().to_string(), data.description().to_string(), ] } fn extract_update_values(data: &UpdateProduct) -> Vec> { 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; pub type PostgresProductRepository = PostgresRepository; /// Type aliases for services pub type PostgresUserService = Service; pub type PostgresProductService = Service; /* * 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, * pub updated_at: chrono::DateTime, * } * * 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 { * vec![data.customer_id().to_string(), data.total_amount().to_string(), data.status().to_string()] * } * fn extract_update_values(data: &UpdateOrder) -> Vec> { * 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; * pub type PostgresOrderService = Service; * * That's it! The generic PostgresRepository 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 = 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 = 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()); } } }