Files
pawctioneer-bot/src/db/models/listing.rs
Dylan Knutson d8e6c4ef3e cargo clippy
2025-08-29 10:21:39 -07:00

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"),
}
}
}