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

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());
}
}
}