diff --git a/src/db/money.rs b/src/db/money.rs deleted file mode 100644 index 9c9c083..0000000 --- a/src/db/money.rs +++ /dev/null @@ -1,730 +0,0 @@ -use rust_decimal::Decimal; -use serde::{Deserialize, Serialize}; -use sqlx::{ - encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, - TypeInfo, -}; -use std::ops::{Add, Sub}; -use std::str::FromStr; - -use super::currency_type::CurrencyType; - -/// Newtype wrapper around Decimal for SQLx integration -/// Stores as NUMERIC (TEXT) in SQLite to maintain exact precision -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub struct MoneyAmount(Decimal); - -impl MoneyAmount { - pub fn new(decimal: Decimal) -> Self { - Self(decimal) - } - - pub fn from_str(s: &str) -> Result { - Ok(Self(Decimal::from_str(s)?)) - } - - pub fn zero() -> Self { - Self(Decimal::ZERO) - } - - pub fn inner(&self) -> Decimal { - self.0 - } -} - -// Allow easy conversion from Decimal -impl From for MoneyAmount { - fn from(decimal: Decimal) -> Self { - Self(decimal) - } -} - -// Allow easy conversion to Decimal -impl From for Decimal { - fn from(money: MoneyAmount) -> Self { - money.0 - } -} - -// Implement Display for easy printing -impl std::fmt::Display for MoneyAmount { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -// Implement arithmetic operations -impl Add for MoneyAmount { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - Self(self.0 + rhs.0) - } -} - -impl Sub for MoneyAmount { - type Output = Self; - - fn sub(self, rhs: Self) -> Self::Output { - Self(self.0 - rhs.0) - } -} - -// Implement SQLx Type trait for MoneyAmount with SQLite (store as TEXT with NUMERIC affinity) -impl Type for MoneyAmount { - fn type_info() -> SqliteTypeInfo { - // Use TEXT type but this will work with NUMERIC affinity columns - <&str as Type>::type_info() - } - - fn compatible(ty: &SqliteTypeInfo) -> bool { - // Accept both TEXT and NUMERIC/REAL types - <&str as Type>::compatible(ty) - || >::compatible(ty) - || ty.name() == "NUMERIC" - || ty.name() == "DECIMAL" - } -} - -// Implement SQLx Encode trait for MoneyAmount with SQLite -impl<'q> Encode<'q, Sqlite> for MoneyAmount { - fn encode_by_ref( - &self, - args: &mut Vec>, - ) -> Result { - // Store decimal as string - SQLite NUMERIC affinity will handle the conversion - let decimal_str = self.0.to_string(); - args.push(sqlx::sqlite::SqliteArgumentValue::Text(decimal_str.into())); - Ok(IsNull::No) - } -} - -// Implement SQLx Decode trait for MoneyAmount with SQLite -impl<'r> Decode<'r, Sqlite> for MoneyAmount { - fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { - // Decode as string from NUMERIC affinity column (stored as TEXT for precision) - let decimal_str = <&str as Decode>::decode(value)?; - let decimal = Decimal::from_str(decimal_str)?; - Ok(MoneyAmount(decimal)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - use sqlx::{Row, SqlitePool}; - - #[test] - fn test_money_amount_creation() { - let money1 = MoneyAmount::from(Decimal::new(12345, 2)); // 123.45 - assert_eq!(money1.to_string(), "123.45"); - - let money2 = MoneyAmount::from_str("456.789123").unwrap(); - assert_eq!(money2.to_string(), "456.789123"); - - let zero_money = MoneyAmount::zero(); - assert_eq!(zero_money.to_string(), "0"); - } - - #[test] - fn test_money_amount_precision() { - let precise_money = MoneyAmount::from_str("0.000001").unwrap(); // 1 micro-unit - assert_eq!(precise_money.to_string(), "0.000001"); - - let large_money = MoneyAmount::from_str("999999.999999").unwrap(); // Near max precision - assert_eq!(large_money.to_string(), "999999.999999"); - } - - #[test] - fn test_money_amount_conversions() { - let decimal = Decimal::new(12345, 2); - let money: MoneyAmount = decimal.into(); - let back_to_decimal: Decimal = money.into(); - assert_eq!(decimal, back_to_decimal); - } - - // Database integration test utilities - async fn create_test_pool() -> SqlitePool { - // Create an in-memory SQLite database for testing - SqlitePool::connect("sqlite::memory:") - .await - .expect("Failed to create in-memory database") - } - - async fn setup_test_table(pool: &SqlitePool) { - // Create a test table with MoneyAmount and CurrencyType fields - // Use TEXT for MoneyAmount fields in STRICT mode (stores as TEXT, preserves precision) - sqlx::query( - r#" - CREATE TABLE test_money ( - id INTEGER PRIMARY KEY, - amount TEXT NOT NULL, - currency TEXT NOT NULL, - optional_amount TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) STRICT - "#, - ) - .execute(pool) - .await - .expect("Failed to create test table"); - } - - #[rstest] - #[case("0")] - #[case("123.45")] - #[case("0.000001")] // Micro precision - #[case("999999.999999")] // Large with precision - #[case("1234567890.123456")] // Very large - #[case("0.1")] - #[case("0.01")] - #[case("0.001")] - #[case("1.0")] - #[case("1.00")] - #[case("1.10")] - #[case("1.01")] - #[tokio::test] - async fn test_money_amount_database_encode_decode(#[case] amount_str: &str) { - let pool = create_test_pool().await; - setup_test_table(&pool).await; - - let amount = MoneyAmount::from_str(amount_str).unwrap(); - - // Insert test data - sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)") - .bind(&amount) - .bind(CurrencyType::USD) - .execute(&pool) - .await - .expect("Failed to insert test data"); - - // Retrieve and verify data - let row = sqlx::query("SELECT amount, currency FROM test_money LIMIT 1") - .fetch_one(&pool) - .await - .expect("Failed to fetch test data"); - - let retrieved_amount: MoneyAmount = row.get("amount"); - let retrieved_currency: CurrencyType = row.get("currency"); - - assert_eq!(retrieved_amount, amount); - assert_eq!(retrieved_currency, CurrencyType::USD); - - // Verify string representation matches exactly - assert_eq!(retrieved_amount.to_string(), amount.to_string()); - assert_eq!(retrieved_amount.to_string(), amount_str); - } - - #[tokio::test] - async fn test_optional_money_amount_database() { - let pool = create_test_pool().await; - setup_test_table(&pool).await; - - // Test with NULL (None) value - sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)") - .bind(MoneyAmount::from_str("50.00").unwrap()) - .bind(CurrencyType::USD) - .bind(None::) - .execute(&pool) - .await - .expect("Failed to insert null optional amount"); - - // Test with Some value - let optional_amount = Some(MoneyAmount::from_str("25.75").unwrap()); - sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)") - .bind(MoneyAmount::from_str("100.00").unwrap()) - .bind(CurrencyType::USD) - .bind(&optional_amount) - .execute(&pool) - .await - .expect("Failed to insert some optional amount"); - - // Retrieve and verify - let rows = - sqlx::query("SELECT amount, currency, optional_amount FROM test_money ORDER BY id") - .fetch_all(&pool) - .await - .expect("Failed to fetch optional amount test data"); - - assert_eq!(rows.len(), 2); - - // First row - NULL optional_amount - let first_optional: Option = rows[0].get("optional_amount"); - assert!(first_optional.is_none()); - - // Second row - Some optional_amount - let second_optional: Option = rows[1].get("optional_amount"); - assert_eq!(second_optional, optional_amount); - } - - #[rstest] - #[case("0", "0")] - #[case("1", "1")] - #[case("1.0", "1.0")] - #[case("1.00", "1.00")] - #[case("1.10", "1.10")] - #[case("1.01", "1.01")] - #[case("0.1", "0.1")] - #[case("0.01", "0.01")] - #[case("0.001", "0.001")] - #[case("0.0001", "0.0001")] - #[case("0.00001", "0.00001")] - #[case("0.000001", "0.000001")] - #[case("123.456789", "123.456789")] - #[tokio::test] - async fn test_money_precision_preservation( - #[case] input_str: &str, - #[case] expected_str: &str, - ) { - let pool = create_test_pool().await; - setup_test_table(&pool).await; - - let amount = MoneyAmount::from_str(input_str).unwrap(); - - // Insert into database - sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)") - .bind(&amount) - .bind(CurrencyType::USD) - .execute(&pool) - .await - .expect("Failed to insert precision test data"); - - // Retrieve from database - let row = sqlx::query("SELECT amount FROM test_money LIMIT 1") - .fetch_one(&pool) - .await - .expect("Failed to fetch precision test data"); - - let retrieved_amount: MoneyAmount = row.get("amount"); - - // Verify exact string representation is preserved - assert_eq!( - retrieved_amount.to_string(), - expected_str, - "Precision not preserved for input: {}", - input_str - ); - } - - // ========== ARITHMETIC OPERATIONS TESTS ========== - - #[rstest] - #[case("0", "0", "0")] - #[case("1", "2", "3")] - #[case("10.50", "5.25", "15.75")] - #[case("0.000001", "0.000002", "0.000003")] - #[case("999999.999999", "0.000001", "1000000")] - #[case("123.45", "876.55", "1000")] - #[case("0.1", "0.2", "0.3")] - #[case("0.33", "0.67", "1.00")] - fn test_money_addition(#[case] a_str: &str, #[case] b_str: &str, #[case] expected: &str) { - let a = MoneyAmount::from_str(a_str).unwrap(); - let b = MoneyAmount::from_str(b_str).unwrap(); - let result = a + b; - let expected_amount = MoneyAmount::from_str(expected).unwrap(); - assert_eq!(result, expected_amount); - } - - #[rstest] - #[case("10", "5", "5")] - #[case("100.50", "0.50", "100")] - #[case("1", "1", "0")] - #[case("0.000003", "0.000001", "0.000002")] - #[case("1000000", "0.000001", "999999.999999")] - #[case("1000", "123.45", "876.55")] - #[case("0.3", "0.1", "0.2")] - fn test_money_subtraction(#[case] a_str: &str, #[case] b_str: &str, #[case] expected: &str) { - let a = MoneyAmount::from_str(a_str).unwrap(); - let b = MoneyAmount::from_str(b_str).unwrap(); - let result = a - b; - let expected_amount = MoneyAmount::from_str(expected).unwrap(); - assert_eq!(result, expected_amount); - } - - #[test] - fn test_money_arithmetic_precision() { - let a = MoneyAmount::from_str("0.1").unwrap(); - let b = MoneyAmount::from_str("0.2").unwrap(); - let sum = a + b; - - // This should be exactly 0.3, not 0.30000000000000004 like with f64 - assert_eq!(sum.to_string(), "0.3"); - - let c = MoneyAmount::from_str("1.0").unwrap(); - let d = MoneyAmount::from_str("0.9").unwrap(); - let diff = c - d; - - // This should be exactly 0.1 - assert_eq!(diff.to_string(), "0.1"); - } - - #[test] - fn test_money_chained_operations() { - let a = MoneyAmount::from_str("100").unwrap(); - let b = MoneyAmount::from_str("25.50").unwrap(); - let c = MoneyAmount::from_str("10.25").unwrap(); - - let result = a + b - c; - let expected = MoneyAmount::from_str("115.25").unwrap(); - assert_eq!(result, expected); - - let result2 = a - b + c; - let expected2 = MoneyAmount::from_str("84.75").unwrap(); - assert_eq!(result2, expected2); - } - - #[test] - fn test_money_zero_operations() { - let zero = MoneyAmount::zero(); - let amount = MoneyAmount::from_str("42.50").unwrap(); - - assert_eq!(zero + amount, amount); - assert_eq!(amount + zero, amount); - assert_eq!(amount - zero, amount); - assert_eq!(amount - amount, zero); - } - - #[test] - fn test_money_negative_results() { - let a = MoneyAmount::from_str("10").unwrap(); - let b = MoneyAmount::from_str("15").unwrap(); - let result = a - b; - let expected = MoneyAmount::from_str("-5").unwrap(); - assert_eq!(result, expected); - } - - // ========== DATABASE QUERY OPERATIONS TESTS ========== - - async fn setup_test_data_for_queries(pool: &SqlitePool) { - // Create test table for query operations - sqlx::query( - r#" - CREATE TABLE test_bids ( - id INTEGER PRIMARY KEY, - listing_id INTEGER NOT NULL, - buyer_id INTEGER NOT NULL, - bid_amount TEXT NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) STRICT - "#, - ) - .execute(pool) - .await - .expect("Failed to create test_bids table"); - - // Insert test data with various amounts - let test_amounts = [ - "10.00", "25.50", "100.00", "15.75", "200.25", "0.01", "999.99", "50.00", "75.25", - "125.50", - ]; - - for (i, amount_str) in test_amounts.iter().enumerate() { - let amount = MoneyAmount::from_str(amount_str).unwrap(); - sqlx::query( - "INSERT INTO test_bids (listing_id, buyer_id, bid_amount) VALUES (?, ?, ?)", - ) - .bind(1) // listing_id - .bind(i as i64 + 1) // buyer_id - .bind(amount) - .execute(pool) - .await - .expect("Failed to insert test bid data"); - } - } - - #[tokio::test] - async fn test_money_database_max_min() { - let pool = create_test_pool().await; - setup_test_data_for_queries(&pool).await; - - // Test MAX - let max_row = sqlx::query("SELECT MAX(bid_amount) as max_bid FROM test_bids") - .fetch_one(&pool) - .await - .expect("Failed to fetch max bid"); - - let max_amount: MoneyAmount = max_row.get("max_bid"); - let expected_max = MoneyAmount::from_str("999.99").unwrap(); - assert_eq!(max_amount, expected_max); - - // Test MIN - let min_row = sqlx::query("SELECT MIN(bid_amount) as min_bid FROM test_bids") - .fetch_one(&pool) - .await - .expect("Failed to fetch min bid"); - - let min_amount: MoneyAmount = min_row.get("min_bid"); - let expected_min = MoneyAmount::from_str("0.01").unwrap(); - assert_eq!(min_amount, expected_min); - } - - #[rstest] - #[case(">", "50.00", 5)] // amounts > 50.00: 100.00, 200.25, 999.99, 75.25, 125.50 - #[case(">=", "50.00", 6)] // amounts >= 50.00: includes 50.00 - #[case("<", "50.00", 4)] // amounts < 50.00: 10.00, 25.50, 15.75, 0.01 - #[case("<=", "50.00", 5)] // amounts <= 50.00: includes 50.00 - #[tokio::test] - async fn test_money_database_comparison_operators( - #[case] operator: &str, - #[case] threshold: &str, - #[case] expected_count: usize, - ) { - let pool = create_test_pool().await; - setup_test_data_for_queries(&pool).await; - - let threshold_amount = MoneyAmount::from_str(threshold).unwrap(); - let query = format!( - "SELECT COUNT(*) as count FROM test_bids WHERE CAST(bid_amount AS REAL) {} CAST(? AS REAL)", - operator - ); - - let count_row = sqlx::query(&query) - .bind(threshold_amount) - .fetch_one(&pool) - .await - .expect("Failed to fetch count with comparison"); - - let count: i64 = count_row.get("count"); - assert_eq!( - count as usize, expected_count, - "Comparison {} {} failed", - operator, threshold - ); - } - - #[rstest] - #[case("25.00", "100.00", 4)] // amounts between 25.00 and 100.00: 25.50, 100.00, 50.00, 75.25 - #[case("0.00", "20.00", 3)] // amounts between 0.00 and 20.00: 10.00, 15.75, 0.01 - #[case("200.00", "1000.00", 2)] // amounts between 200.00 and 1000.00: 200.25, 999.99 - #[tokio::test] - async fn test_money_database_between_operator( - #[case] min_amount: &str, - #[case] max_amount: &str, - #[case] expected_count: usize, - ) { - let pool = create_test_pool().await; - setup_test_data_for_queries(&pool).await; - - let min = MoneyAmount::from_str(min_amount).unwrap(); - let max = MoneyAmount::from_str(max_amount).unwrap(); - - let count_row = - sqlx::query("SELECT COUNT(*) as count FROM test_bids WHERE CAST(bid_amount AS REAL) BETWEEN CAST(? AS REAL) AND CAST(? AS REAL)") - .bind(min) - .bind(max) - .fetch_one(&pool) - .await - .expect("Failed to fetch count with BETWEEN"); - - let count: i64 = count_row.get("count"); - assert_eq!( - count as usize, expected_count, - "BETWEEN {} AND {} failed", - min_amount, max_amount - ); - } - - #[tokio::test] - async fn test_money_database_ordering() { - let pool = create_test_pool().await; - setup_test_data_for_queries(&pool).await; - - // Test ORDER BY ASC - let rows_asc = - sqlx::query("SELECT bid_amount FROM test_bids ORDER BY CAST(bid_amount AS REAL) ASC") - .fetch_all(&pool) - .await - .expect("Failed to fetch ordered data ASC"); - - let amounts_asc: Vec = rows_asc - .iter() - .map(|row| row.get::("bid_amount")) - .collect(); - - // Verify ascending order - for i in 1..amounts_asc.len() { - assert!( - amounts_asc[i - 1] <= amounts_asc[i], - "ASC order violated: {} should be <= {}", - amounts_asc[i - 1], - amounts_asc[i] - ); - } - - // Test ORDER BY DESC - let rows_desc = - sqlx::query("SELECT bid_amount FROM test_bids ORDER BY CAST(bid_amount AS REAL) DESC") - .fetch_all(&pool) - .await - .expect("Failed to fetch ordered data DESC"); - - let amounts_desc: Vec = rows_desc - .iter() - .map(|row| row.get::("bid_amount")) - .collect(); - - // Verify descending order - for i in 1..amounts_desc.len() { - assert!( - amounts_desc[i - 1] >= amounts_desc[i], - "DESC order violated: {} should be >= {}", - amounts_desc[i - 1], - amounts_desc[i] - ); - } - - // Verify first and last elements - let expected_min = MoneyAmount::from_str("0.01").unwrap(); - let expected_max = MoneyAmount::from_str("999.99").unwrap(); - - assert_eq!(amounts_asc[0], expected_min); - assert_eq!(amounts_asc[amounts_asc.len() - 1], expected_max); - assert_eq!(amounts_desc[0], expected_max); - assert_eq!(amounts_desc[amounts_desc.len() - 1], expected_min); - } - - #[tokio::test] - async fn test_money_database_aggregation_functions() { - let pool = create_test_pool().await; - setup_test_data_for_queries(&pool).await; - - // Test SUM, AVG, COUNT - let agg_row = sqlx::query( - "SELECT - SUM(bid_amount) as total_sum, - AVG(bid_amount) as average, - COUNT(bid_amount) as count - FROM test_bids", - ) - .fetch_one(&pool) - .await - .expect("Failed to fetch aggregation data"); - - let total_sum: MoneyAmount = agg_row.get("total_sum"); - let average: MoneyAmount = agg_row.get("average"); - let count: i64 = agg_row.get("count"); - - // Verify count - assert_eq!(count, 10); - - // Calculate expected sum manually - let expected_sum = MoneyAmount::from_str("10.00").unwrap() - + MoneyAmount::from_str("25.50").unwrap() - + MoneyAmount::from_str("100.00").unwrap() - + MoneyAmount::from_str("15.75").unwrap() - + MoneyAmount::from_str("200.25").unwrap() - + MoneyAmount::from_str("0.01").unwrap() - + MoneyAmount::from_str("999.99").unwrap() - + MoneyAmount::from_str("50.00").unwrap() - + MoneyAmount::from_str("75.25").unwrap() - + MoneyAmount::from_str("125.50").unwrap(); - - assert_eq!(total_sum, expected_sum); - - // Verify average is approximately correct (may have rounding differences) - let expected_avg_decimal = expected_sum.inner() / Decimal::new(10, 0); - let avg_diff = (average.inner() - expected_avg_decimal).abs(); - assert!( - avg_diff < Decimal::new(1, 2), - "Average calculation too far off: expected ~{}, got {}", - expected_avg_decimal, - average - ); - } - - #[tokio::test] - async fn test_money_database_group_by_with_aggregation() { - let pool = create_test_pool().await; - - // Create a more complex test table - sqlx::query( - r#" - CREATE TABLE test_listing_bids ( - id INTEGER PRIMARY KEY, - listing_id INTEGER NOT NULL, - bid_amount TEXT NOT NULL - ) STRICT - "#, - ) - .execute(&pool) - .await - .expect("Failed to create test_listing_bids table"); - - // Insert test data for multiple listings - let test_data = [ - (1, "10.00"), - (1, "20.00"), - (1, "30.00"), // Listing 1: total 60.00, max 30.00 - (2, "50.00"), - (2, "75.00"), // Listing 2: total 125.00, max 75.00 - (3, "100.00"), // Listing 3: total 100.00, max 100.00 - ]; - - for (listing_id, amount_str) in test_data { - let amount = MoneyAmount::from_str(amount_str).unwrap(); - sqlx::query("INSERT INTO test_listing_bids (listing_id, bid_amount) VALUES (?, ?)") - .bind(listing_id) - .bind(amount) - .execute(&pool) - .await - .expect("Failed to insert listing bid data"); - } - - // Test GROUP BY with aggregation - let grouped_rows = sqlx::query( - "SELECT - listing_id, - SUM(bid_amount) as total_bids, - MAX(bid_amount) as highest_bid, - COUNT(*) as bid_count - FROM test_listing_bids - GROUP BY listing_id - ORDER BY listing_id", - ) - .fetch_all(&pool) - .await - .expect("Failed to fetch grouped data"); - - assert_eq!(grouped_rows.len(), 3); - - // Verify listing 1 - let listing1 = &grouped_rows[0]; - assert_eq!(listing1.get::("listing_id"), 1); - assert_eq!( - listing1.get::("total_bids"), - MoneyAmount::from_str("60.00").unwrap() - ); - assert_eq!( - listing1.get::("highest_bid"), - MoneyAmount::from_str("30.00").unwrap() - ); - assert_eq!(listing1.get::("bid_count"), 3); - - // Verify listing 2 - let listing2 = &grouped_rows[1]; - assert_eq!(listing2.get::("listing_id"), 2); - assert_eq!( - listing2.get::("total_bids"), - MoneyAmount::from_str("125.00").unwrap() - ); - assert_eq!( - listing2.get::("highest_bid"), - MoneyAmount::from_str("75.00").unwrap() - ); - assert_eq!(listing2.get::("bid_count"), 2); - - // Verify listing 3 - let listing3 = &grouped_rows[2]; - assert_eq!(listing3.get::("listing_id"), 3); - assert_eq!( - listing3.get::("total_bids"), - MoneyAmount::from_str("100.00").unwrap() - ); - assert_eq!( - listing3.get::("highest_bid"), - MoneyAmount::from_str("100.00").unwrap() - ); - assert_eq!(listing3.get::("bid_count"), 1); - } -}