398 lines
14 KiB
Rust
398 lines
14 KiB
Rust
//! Listing models with type-safe enum-based structure
|
|
//!
|
|
//! This module provides a type-safe representation of different listing types:
|
|
//! - `BasicAuction`: Traditional time-based auction with bidding
|
|
//! - `MultiSlotAuction`: Auction with multiple winners/slots available
|
|
//! - `FixedPriceListing`: Fixed price sale with no bidding
|
|
//! - `BlindAuction`: Blind auction where seller chooses winner
|
|
//!
|
|
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
|
|
//! Database mapping is handled through `ListingRow` with conversion traits.
|
|
|
|
use super::listing_type::ListingType;
|
|
use crate::db::{ListingId, MoneyAmount, UserRowId};
|
|
use chrono::{DateTime, Utc};
|
|
|
|
/// Main listing/auction entity
|
|
#[derive(Debug, Clone)]
|
|
#[allow(unused)]
|
|
pub struct Listing {
|
|
pub base: ListingBase,
|
|
pub fields: ListingFields,
|
|
}
|
|
|
|
/// Common fields shared by all listing types
|
|
#[derive(Debug, Clone)]
|
|
#[allow(unused)]
|
|
pub struct ListingBase {
|
|
pub id: ListingId,
|
|
pub seller_id: UserRowId,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub starts_at: DateTime<Utc>,
|
|
pub ends_at: DateTime<Utc>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[allow(unused)]
|
|
pub enum ListingFields {
|
|
BasicAuction {
|
|
starting_bid: MoneyAmount,
|
|
buy_now_price: Option<MoneyAmount>,
|
|
min_increment: MoneyAmount,
|
|
anti_snipe_minutes: Option<i32>,
|
|
},
|
|
MultiSlotAuction {
|
|
starting_bid: MoneyAmount,
|
|
buy_now_price: MoneyAmount,
|
|
min_increment: Option<MoneyAmount>,
|
|
slots_available: i32,
|
|
anti_snipe_minutes: i32,
|
|
},
|
|
FixedPriceListing {
|
|
buy_now_price: MoneyAmount,
|
|
slots_available: i32,
|
|
},
|
|
BlindAuction {
|
|
starting_bid: MoneyAmount,
|
|
},
|
|
}
|
|
|
|
#[allow(unused)]
|
|
impl Listing {
|
|
/// Get the listing type as an enum value
|
|
pub fn listing_type(&self) -> ListingType {
|
|
match &self.fields {
|
|
ListingFields::BasicAuction { .. } => ListingType::BasicAuction,
|
|
ListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
|
ListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
|
ListingFields::BlindAuction { .. } => ListingType::BlindAuction,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::db::{new_listing::NewListingBase, ListingDAO, TelegramUserId};
|
|
use crate::{assert_listing_timestamps_approx_eq, assert_timestamps_approx_eq_default};
|
|
|
|
use super::*;
|
|
use chrono::{Duration, Utc};
|
|
use rstest::rstest;
|
|
use sqlx::SqlitePool;
|
|
|
|
/// Test utilities for creating an in-memory database with migrations
|
|
async fn create_test_pool() -> SqlitePool {
|
|
// Create an in-memory SQLite database for testing
|
|
let pool = SqlitePool::connect("sqlite::memory:")
|
|
.await
|
|
.expect("Failed to create in-memory database");
|
|
|
|
// Run the migration
|
|
apply_test_migrations(&pool).await;
|
|
|
|
pool
|
|
}
|
|
|
|
/// Apply the database migrations for testing
|
|
async fn apply_test_migrations(pool: &SqlitePool) {
|
|
// Run the actual migrations from the migrations directory
|
|
sqlx::migrate!("./migrations")
|
|
.run(pool)
|
|
.await
|
|
.expect("Failed to run database migrations");
|
|
}
|
|
|
|
/// Create a test user using UserDAO and return their ID
|
|
async fn create_test_user(
|
|
pool: &SqlitePool,
|
|
telegram_id: TelegramUserId,
|
|
username: Option<&str>,
|
|
) -> UserRowId {
|
|
use crate::db::{models::user::NewUser, UserDAO};
|
|
|
|
let new_user = NewUser {
|
|
telegram_id,
|
|
username: username.map(|s| s.to_string()),
|
|
display_name: username.map(|s| s.to_string()),
|
|
};
|
|
|
|
let user = UserDAO::insert_user(pool, &new_user)
|
|
.await
|
|
.expect("Failed to create test user");
|
|
user.id
|
|
}
|
|
|
|
/// Fetch a listing using ListingDAO by ID
|
|
async fn fetch_listing_using_dao(pool: &SqlitePool, id: ListingId) -> Listing {
|
|
use crate::db::ListingDAO;
|
|
|
|
ListingDAO::find_by_id(pool, id)
|
|
.await
|
|
.expect("Failed to fetch listing using DAO")
|
|
.expect("Listing should exist")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_basic_auction_crud() {
|
|
let pool = create_test_pool().await;
|
|
let seller_id = create_test_user(&pool, 12345.into(), Some("testuser")).await;
|
|
|
|
// Create a basic auction listing
|
|
let starts_at = Utc::now();
|
|
let ends_at = starts_at + Duration::hours(24);
|
|
|
|
let new_listing = build_base_listing(
|
|
seller_id,
|
|
"Test Basic Auction",
|
|
Some("A test auction for basic functionality"),
|
|
)
|
|
.new_basic_auction(
|
|
MoneyAmount::from_str("10.00").unwrap(),
|
|
Some(MoneyAmount::from_str("100.00").unwrap()),
|
|
MoneyAmount::from_str("1.00").unwrap(),
|
|
Some(5),
|
|
);
|
|
|
|
// Insert using DAO
|
|
let actual_id = ListingDAO::insert_listing(&pool, &new_listing)
|
|
.await
|
|
.expect("Failed to insert listing")
|
|
.base
|
|
.id;
|
|
|
|
// Fetch back from database using DAO
|
|
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
|
|
// Verify the round trip worked correctly
|
|
match reconstructed_listing.fields {
|
|
ListingFields::BasicAuction {
|
|
starting_bid,
|
|
buy_now_price,
|
|
min_increment,
|
|
anti_snipe_minutes,
|
|
} => {
|
|
assert_eq!(reconstructed_listing.base.seller_id, seller_id);
|
|
assert_eq!(reconstructed_listing.base.title, "Test Basic Auction");
|
|
assert_eq!(
|
|
reconstructed_listing.base.description,
|
|
Some("A test auction for basic functionality".to_string())
|
|
);
|
|
assert_eq!(starting_bid, MoneyAmount::from_str("10.00").unwrap());
|
|
assert_eq!(
|
|
buy_now_price,
|
|
Some(MoneyAmount::from_str("100.00").unwrap())
|
|
);
|
|
assert_eq!(min_increment, MoneyAmount::from_str("1.00").unwrap());
|
|
assert_eq!(anti_snipe_minutes, Some(5));
|
|
assert_timestamps_approx_eq_default!(
|
|
reconstructed_listing.base.starts_at,
|
|
starts_at
|
|
);
|
|
assert_timestamps_approx_eq_default!(reconstructed_listing.base.ends_at, ends_at);
|
|
}
|
|
_ => panic!("Expected BasicAuction, got different variant"),
|
|
}
|
|
}
|
|
|
|
fn build_base_listing(
|
|
seller_id: UserRowId,
|
|
title: &str,
|
|
description: Option<&str>,
|
|
) -> NewListingBase {
|
|
NewListingBase {
|
|
seller_id,
|
|
title: title.to_string(),
|
|
description: description.map(|s| s.to_string()),
|
|
starts_at: Utc::now(),
|
|
ends_at: Utc::now() + Duration::hours(24),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_slot_auction_crud() {
|
|
let pool = create_test_pool().await;
|
|
let seller_id = create_test_user(&pool, 67890.into(), Some("multislotuser")).await;
|
|
let listing = build_base_listing(seller_id, "Test Multi-Slot Auction", None)
|
|
.new_multi_slot_auction(
|
|
MoneyAmount::from_str("10.00").unwrap(),
|
|
MoneyAmount::from_str("50.00").unwrap(),
|
|
Some(MoneyAmount::from_str("2.50").unwrap()),
|
|
5,
|
|
10,
|
|
);
|
|
|
|
// Insert using DAO
|
|
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
|
.await
|
|
.expect("Failed to insert listing")
|
|
.base
|
|
.id;
|
|
|
|
// Fetch back from database using DAO
|
|
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
|
|
// Verify the round trip worked correctly
|
|
match reconstructed_listing.fields {
|
|
ListingFields::MultiSlotAuction {
|
|
starting_bid,
|
|
buy_now_price,
|
|
min_increment,
|
|
slots_available,
|
|
anti_snipe_minutes,
|
|
} => {
|
|
let reconstructed_base = reconstructed_listing.base;
|
|
assert_eq!(reconstructed_base.seller_id, seller_id);
|
|
assert_eq!(reconstructed_base.title, "Test Multi-Slot Auction");
|
|
assert_eq!(reconstructed_base.description, None);
|
|
assert_eq!(starting_bid, MoneyAmount::from_str("10.00").unwrap());
|
|
assert_eq!(buy_now_price, MoneyAmount::from_str("50.00").unwrap());
|
|
assert_eq!(min_increment, Some(MoneyAmount::from_str("2.50").unwrap()));
|
|
assert_eq!(slots_available, 5);
|
|
assert_eq!(anti_snipe_minutes, 10);
|
|
assert_listing_timestamps_approx_eq!(reconstructed_base, listing.base);
|
|
}
|
|
_ => panic!("Expected MultiSlotAuction, got different variant"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_fixed_price_listing_crud() {
|
|
let pool = create_test_pool().await;
|
|
let seller_id = create_test_user(&pool, 11111.into(), Some("fixedpriceuser")).await;
|
|
|
|
let listing = build_base_listing(
|
|
seller_id,
|
|
"Test Fixed Price Item",
|
|
Some("Fixed price sale with multiple slots"),
|
|
)
|
|
.new_fixed_price_listing(MoneyAmount::from_str("25.99").unwrap(), 3);
|
|
|
|
// Insert using DAO
|
|
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
|
.await
|
|
.expect("Failed to insert listing")
|
|
.base
|
|
.id;
|
|
|
|
// Fetch back from database using DAO
|
|
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
|
|
// Verify the round trip worked correctly
|
|
match reconstructed_listing.fields {
|
|
ListingFields::FixedPriceListing {
|
|
buy_now_price,
|
|
slots_available,
|
|
} => {
|
|
assert_eq!(reconstructed_listing.base.seller_id, seller_id);
|
|
assert_eq!(reconstructed_listing.base.title, "Test Fixed Price Item");
|
|
assert_eq!(
|
|
listing.base.description,
|
|
Some("Fixed price sale with multiple slots".to_string())
|
|
);
|
|
assert_eq!(buy_now_price, MoneyAmount::from_str("25.99").unwrap());
|
|
assert_eq!(slots_available, 3);
|
|
assert_listing_timestamps_approx_eq!(reconstructed_listing.base, listing.base);
|
|
}
|
|
_ => panic!("Expected FixedPriceListing, got different variant"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_blind_auction_crud() {
|
|
let pool = create_test_pool().await;
|
|
let seller_id = create_test_user(&pool, 99999.into(), Some("blinduser")).await;
|
|
|
|
let listing = build_base_listing(
|
|
seller_id,
|
|
"Test Blind Auction",
|
|
Some("Seller chooses winner"),
|
|
)
|
|
.new_blind_auction(MoneyAmount::from_str("100.00").unwrap());
|
|
|
|
// Insert using DAO
|
|
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
|
.await
|
|
.expect("Failed to insert listing")
|
|
.base
|
|
.id;
|
|
|
|
// Fetch back from database using DAO
|
|
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
|
|
// Verify the round trip worked correctly
|
|
match reconstructed_listing.fields {
|
|
ListingFields::BlindAuction { starting_bid } => {
|
|
assert_eq!(reconstructed_listing.base.seller_id, seller_id);
|
|
assert_eq!(reconstructed_listing.base.title, "Test Blind Auction");
|
|
assert_eq!(
|
|
reconstructed_listing.base.description,
|
|
Some("Seller chooses winner".to_string())
|
|
);
|
|
assert_listing_timestamps_approx_eq!(reconstructed_listing.base, listing.base);
|
|
assert_eq!(starting_bid, MoneyAmount::from_str("100.00").unwrap());
|
|
}
|
|
_ => panic!("Expected BlindAuction, got different variant"),
|
|
}
|
|
}
|
|
|
|
#[rstest]
|
|
#[case("10.50", "100.00", "1.00")]
|
|
#[case("0.00", "50.00", "0.25")]
|
|
#[case("25.75", "999.99", "5.50")]
|
|
#[tokio::test]
|
|
async fn test_money_amount_precision_in_listings(
|
|
#[case] starting_bid_str: &str,
|
|
#[case] buy_now_price_str: &str,
|
|
#[case] min_increment_str: &str,
|
|
) {
|
|
let pool = create_test_pool().await;
|
|
let seller_id = create_test_user(&pool, 55555.into(), Some("precisionuser")).await;
|
|
|
|
let listing = build_base_listing(seller_id, "Precision Test Auction", None)
|
|
.new_basic_auction(
|
|
MoneyAmount::from_str(starting_bid_str).unwrap(),
|
|
Some(MoneyAmount::from_str(buy_now_price_str).unwrap()),
|
|
MoneyAmount::from_str(min_increment_str).unwrap(),
|
|
Some(5),
|
|
);
|
|
|
|
// Insert using DAO
|
|
let actual_id = ListingDAO::insert_listing(&pool, &listing)
|
|
.await
|
|
.expect("Failed to insert listing")
|
|
.base
|
|
.id;
|
|
|
|
// Fetch back from database using DAO
|
|
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
|
|
|
// Verify precision is maintained
|
|
match reconstructed_listing.fields {
|
|
ListingFields::BasicAuction {
|
|
starting_bid,
|
|
buy_now_price,
|
|
min_increment,
|
|
anti_snipe_minutes,
|
|
} => {
|
|
assert_eq!(
|
|
starting_bid,
|
|
MoneyAmount::from_str(starting_bid_str).unwrap()
|
|
);
|
|
assert_eq!(
|
|
buy_now_price,
|
|
Some(MoneyAmount::from_str(buy_now_price_str).unwrap())
|
|
);
|
|
assert_eq!(
|
|
min_increment,
|
|
MoneyAmount::from_str(min_increment_str).unwrap(),
|
|
);
|
|
assert_eq!(anti_snipe_minutes, Some(5));
|
|
}
|
|
_ => panic!("Expected BasicAuction"),
|
|
}
|
|
}
|
|
}
|