feat: add epsilon-based timestamp comparison macros for tests
This commit is contained in:
@@ -9,15 +9,12 @@
|
||||
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
|
||||
//! Database mapping is handled through `ListingRow` with conversion traits.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use super::listing_type::ListingType;
|
||||
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Common fields shared by all listing types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListingBase {
|
||||
pub id: ListingId,
|
||||
pub seller_id: UserId,
|
||||
@@ -29,394 +26,368 @@ pub struct ListingBase {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Traditional time-based auction with bidding
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BasicAuction {
|
||||
#[serde(flatten)]
|
||||
pub base: ListingBase,
|
||||
pub starting_bid: Option<MoneyAmount>,
|
||||
pub buy_now_price: Option<MoneyAmount>,
|
||||
pub min_increment: MoneyAmount,
|
||||
pub anti_snipe_minutes: i32,
|
||||
}
|
||||
|
||||
/// Auction with multiple winners/slots available
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiSlotAuction {
|
||||
#[serde(flatten)]
|
||||
pub base: ListingBase,
|
||||
pub starting_bid: Option<MoneyAmount>,
|
||||
pub buy_now_price: Option<MoneyAmount>,
|
||||
pub min_increment: MoneyAmount,
|
||||
pub slots_available: i32,
|
||||
pub anti_snipe_minutes: i32,
|
||||
}
|
||||
|
||||
/// Fixed price sale with no bidding
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FixedPriceListing {
|
||||
#[serde(flatten)]
|
||||
pub base: ListingBase,
|
||||
pub buy_now_price: MoneyAmount,
|
||||
pub slots_available: i32,
|
||||
}
|
||||
|
||||
/// Blind auction where seller chooses winner
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlindAuction {
|
||||
#[serde(flatten)]
|
||||
pub base: ListingBase,
|
||||
}
|
||||
|
||||
/// Main listing/auction entity enum
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "listing_type")]
|
||||
pub enum Listing {
|
||||
#[serde(rename = "basic_auction")]
|
||||
BasicAuction(BasicAuction),
|
||||
#[serde(rename = "multi_slot_auction")]
|
||||
MultiSlotAuction(MultiSlotAuction),
|
||||
#[serde(rename = "fixed_price_listing")]
|
||||
FixedPriceListing(FixedPriceListing),
|
||||
#[serde(rename = "blind_auction")]
|
||||
BlindAuction(BlindAuction),
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Listing {
|
||||
pub base: ListingBase,
|
||||
pub fields: ListingFields,
|
||||
}
|
||||
|
||||
/// New listing data for insertion
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NewListing {
|
||||
pub enum ListingFields {
|
||||
BasicAuction {
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starting_bid: Option<MoneyAmount>,
|
||||
starting_bid: MoneyAmount,
|
||||
buy_now_price: Option<MoneyAmount>,
|
||||
min_increment: MoneyAmount,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
anti_snipe_minutes: i32,
|
||||
anti_snipe_minutes: Option<i32>,
|
||||
},
|
||||
MultiSlotAuction {
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starting_bid: Option<MoneyAmount>,
|
||||
buy_now_price: Option<MoneyAmount>,
|
||||
min_increment: MoneyAmount,
|
||||
starting_bid: MoneyAmount,
|
||||
buy_now_price: MoneyAmount,
|
||||
min_increment: Option<MoneyAmount>,
|
||||
slots_available: i32,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
anti_snipe_minutes: i32,
|
||||
},
|
||||
FixedPriceListing {
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
buy_now_price: MoneyAmount,
|
||||
slots_available: i32,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
},
|
||||
BlindAuction {
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
starting_bid: MoneyAmount,
|
||||
},
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
/// Get the base information common to all listing types
|
||||
fn base(&self) -> &ListingBase {
|
||||
match self {
|
||||
Listing::BasicAuction(auction) => &auction.base,
|
||||
Listing::MultiSlotAuction(auction) => &auction.base,
|
||||
Listing::FixedPriceListing(listing) => &listing.base,
|
||||
Listing::BlindAuction(auction) => &auction.base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the listing type as an enum value
|
||||
pub fn listing_type(&self) -> ListingType {
|
||||
match self {
|
||||
Listing::BasicAuction(_) => ListingType::BasicAuction,
|
||||
Listing::MultiSlotAuction(_) => ListingType::MultiSlotAuction,
|
||||
Listing::FixedPriceListing(_) => ListingType::FixedPriceListing,
|
||||
Listing::BlindAuction(_) => ListingType::BlindAuction,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the seller ID
|
||||
pub fn seller_id(&self) -> UserId {
|
||||
self.base().seller_id
|
||||
}
|
||||
|
||||
/// Get the listing title
|
||||
pub fn title(&self) -> &str {
|
||||
&self.base().title
|
||||
}
|
||||
|
||||
/// Get the listing description
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.base().description.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl NewListing {
|
||||
/// Get the listing type for this new listing
|
||||
pub fn listing_type(&self) -> ListingType {
|
||||
match self {
|
||||
NewListing::BasicAuction { .. } => ListingType::BasicAuction,
|
||||
NewListing::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
||||
NewListing::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
||||
NewListing::BlindAuction { .. } => ListingType::BlindAuction,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the seller ID for this new listing
|
||||
pub fn seller_id(&self) -> UserId {
|
||||
match self {
|
||||
NewListing::BasicAuction { seller_id, .. } => *seller_id,
|
||||
NewListing::MultiSlotAuction { seller_id, .. } => *seller_id,
|
||||
NewListing::FixedPriceListing { seller_id, .. } => *seller_id,
|
||||
NewListing::BlindAuction { seller_id, .. } => *seller_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the title for this new listing
|
||||
pub fn title(&self) -> &str {
|
||||
match self {
|
||||
NewListing::BasicAuction { title, .. } => title,
|
||||
NewListing::MultiSlotAuction { title, .. } => title,
|
||||
NewListing::FixedPriceListing { title, .. } => title,
|
||||
NewListing::BlindAuction { title, .. } => title,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new basic auction listing
|
||||
pub fn new_basic_auction(
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starting_bid: Option<MoneyAmount>,
|
||||
buy_now_price: Option<MoneyAmount>,
|
||||
min_increment: MoneyAmount,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
anti_snipe_minutes: i32,
|
||||
) -> Self {
|
||||
NewListing::BasicAuction {
|
||||
seller_id,
|
||||
title,
|
||||
description,
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
starts_at,
|
||||
ends_at,
|
||||
anti_snipe_minutes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new multi-slot auction listing
|
||||
pub fn new_multi_slot_auction(
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starting_bid: Option<MoneyAmount>,
|
||||
buy_now_price: Option<MoneyAmount>,
|
||||
min_increment: MoneyAmount,
|
||||
slots_available: i32,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
anti_snipe_minutes: i32,
|
||||
) -> Self {
|
||||
NewListing::MultiSlotAuction {
|
||||
seller_id,
|
||||
title,
|
||||
description,
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
slots_available,
|
||||
starts_at,
|
||||
ends_at,
|
||||
anti_snipe_minutes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new fixed price listing
|
||||
pub fn new_fixed_price_listing(
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
buy_now_price: MoneyAmount,
|
||||
slots_available: i32,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
NewListing::FixedPriceListing {
|
||||
seller_id,
|
||||
title,
|
||||
description,
|
||||
buy_now_price,
|
||||
slots_available,
|
||||
starts_at,
|
||||
ends_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new blind auction listing
|
||||
pub fn new_blind_auction(
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
NewListing::BlindAuction {
|
||||
seller_id,
|
||||
title,
|
||||
description,
|
||||
starts_at,
|
||||
ends_at,
|
||||
match &self.fields {
|
||||
ListingFields::BasicAuction { .. } => ListingType::BasicAuction,
|
||||
ListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
||||
ListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
||||
ListingFields::BlindAuction { .. } => ListingType::BlindAuction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat database row structure for SQLx FromRow compatibility
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct ListingRow {
|
||||
pub id: ListingId,
|
||||
pub seller_id: UserId,
|
||||
pub listing_type: ListingType,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::{new_listing::NewListingBase, ListingDAO, TelegramUserId};
|
||||
use crate::{assert_listing_timestamps_approx_eq, assert_timestamps_approx_eq_default};
|
||||
|
||||
// Pricing fields - nullable for types that don't use them
|
||||
pub starting_bid: Option<MoneyAmount>,
|
||||
pub buy_now_price: Option<MoneyAmount>,
|
||||
pub min_increment: Option<MoneyAmount>,
|
||||
use super::*;
|
||||
use chrono::{Duration, Utc};
|
||||
use rstest::rstest;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// Multi-slot/fixed price
|
||||
pub slots_available: Option<i32>,
|
||||
/// 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");
|
||||
|
||||
// Timing
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
pub anti_snipe_minutes: Option<i32>,
|
||||
// Run the migration
|
||||
apply_test_migrations(&pool).await;
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
impl From<ListingRow> for Listing {
|
||||
fn from(row: ListingRow) -> Self {
|
||||
let base = ListingBase {
|
||||
id: row.id,
|
||||
seller_id: row.seller_id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
starts_at: row.starts_at,
|
||||
ends_at: row.ends_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
/// 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>,
|
||||
) -> UserId {
|
||||
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()),
|
||||
};
|
||||
|
||||
match row.listing_type {
|
||||
ListingType::BasicAuction => Listing::BasicAuction(BasicAuction {
|
||||
base,
|
||||
starting_bid: row.starting_bid,
|
||||
buy_now_price: row.buy_now_price,
|
||||
min_increment: row.min_increment.unwrap_or_else(MoneyAmount::zero),
|
||||
anti_snipe_minutes: row.anti_snipe_minutes.unwrap_or(5),
|
||||
}),
|
||||
ListingType::MultiSlotAuction => Listing::MultiSlotAuction(MultiSlotAuction {
|
||||
base,
|
||||
starting_bid: row.starting_bid,
|
||||
buy_now_price: row.buy_now_price,
|
||||
min_increment: row.min_increment.unwrap_or_else(MoneyAmount::zero),
|
||||
slots_available: row.slots_available.unwrap_or(1),
|
||||
anti_snipe_minutes: row.anti_snipe_minutes.unwrap_or(5),
|
||||
}),
|
||||
ListingType::FixedPriceListing => Listing::FixedPriceListing(FixedPriceListing {
|
||||
base,
|
||||
buy_now_price: row.buy_now_price.unwrap_or_else(MoneyAmount::zero),
|
||||
slots_available: row.slots_available.unwrap_or(1),
|
||||
}),
|
||||
ListingType::BlindAuction => Listing::BlindAuction(BlindAuction { base }),
|
||||
}
|
||||
let user = UserDAO::insert_user(pool, &new_user)
|
||||
.await
|
||||
.expect("Failed to create test user");
|
||||
user.id
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Listing> for ListingRow {
|
||||
fn from(listing: Listing) -> Self {
|
||||
match listing {
|
||||
Listing::BasicAuction(auction) => ListingRow {
|
||||
id: auction.base.id,
|
||||
seller_id: auction.base.seller_id,
|
||||
listing_type: ListingType::BasicAuction,
|
||||
title: auction.base.title,
|
||||
description: auction.base.description,
|
||||
starting_bid: auction.starting_bid,
|
||||
buy_now_price: auction.buy_now_price,
|
||||
min_increment: Some(auction.min_increment),
|
||||
slots_available: Some(1),
|
||||
starts_at: auction.base.starts_at,
|
||||
ends_at: auction.base.ends_at,
|
||||
anti_snipe_minutes: Some(auction.anti_snipe_minutes),
|
||||
created_at: auction.base.created_at,
|
||||
updated_at: auction.base.updated_at,
|
||||
},
|
||||
Listing::MultiSlotAuction(auction) => ListingRow {
|
||||
id: auction.base.id,
|
||||
seller_id: auction.base.seller_id,
|
||||
listing_type: ListingType::MultiSlotAuction,
|
||||
title: auction.base.title,
|
||||
description: auction.base.description,
|
||||
starting_bid: auction.starting_bid,
|
||||
buy_now_price: auction.buy_now_price,
|
||||
min_increment: Some(auction.min_increment),
|
||||
slots_available: Some(auction.slots_available),
|
||||
starts_at: auction.base.starts_at,
|
||||
ends_at: auction.base.ends_at,
|
||||
anti_snipe_minutes: Some(auction.anti_snipe_minutes),
|
||||
created_at: auction.base.created_at,
|
||||
updated_at: auction.base.updated_at,
|
||||
},
|
||||
Listing::FixedPriceListing(listing) => ListingRow {
|
||||
id: listing.base.id,
|
||||
seller_id: listing.base.seller_id,
|
||||
listing_type: ListingType::FixedPriceListing,
|
||||
title: listing.base.title,
|
||||
description: listing.base.description,
|
||||
starting_bid: None,
|
||||
buy_now_price: Some(listing.buy_now_price),
|
||||
min_increment: None,
|
||||
slots_available: Some(listing.slots_available),
|
||||
starts_at: listing.base.starts_at,
|
||||
ends_at: listing.base.ends_at,
|
||||
anti_snipe_minutes: None,
|
||||
created_at: listing.base.created_at,
|
||||
updated_at: listing.base.updated_at,
|
||||
},
|
||||
Listing::BlindAuction(auction) => ListingRow {
|
||||
id: auction.base.id,
|
||||
seller_id: auction.base.seller_id,
|
||||
listing_type: ListingType::BlindAuction,
|
||||
title: auction.base.title,
|
||||
description: auction.base.description,
|
||||
starting_bid: None,
|
||||
buy_now_price: None,
|
||||
min_increment: None,
|
||||
slots_available: Some(1),
|
||||
starts_at: auction.base.starts_at,
|
||||
ends_at: auction.base.ends_at,
|
||||
anti_snipe_minutes: None,
|
||||
created_at: auction.base.created_at,
|
||||
updated_at: auction.base.updated_at,
|
||||
},
|
||||
/// 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: UserId,
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ use teloxide::{prelude::*, utils::command::BotCommands};
|
||||
mod commands;
|
||||
mod config;
|
||||
mod db;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
|
||||
use commands::*;
|
||||
use config::Config;
|
||||
|
||||
216
src/test_utils.rs
Normal file
216
src/test_utils.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! Test utilities including timestamp comparison macros
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
/// Assert that two timestamps are approximately equal within a given epsilon tolerance.
|
||||
///
|
||||
/// This macro is useful for testing timestamps that may have small variations due to
|
||||
/// execution timing. The epsilon is specified as a `Duration`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use chrono::{Utc, Duration};
|
||||
/// use crate::test_utils::assert_timestamps_approx_eq;
|
||||
///
|
||||
/// let now = Utc::now();
|
||||
/// let nearly_now = now + Duration::milliseconds(50);
|
||||
///
|
||||
/// // This will pass - 50ms difference is within 100ms epsilon
|
||||
/// assert_timestamps_approx_eq!(now, nearly_now, Duration::milliseconds(100));
|
||||
///
|
||||
/// // This will fail - 150ms difference exceeds 100ms epsilon
|
||||
/// // assert_timestamps_approx_eq!(now, nearly_now, Duration::milliseconds(100));
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! assert_timestamps_approx_eq {
|
||||
($left:expr, $right:expr, $epsilon:expr) => {{
|
||||
let left_val = $left;
|
||||
let right_val = $right;
|
||||
let epsilon_val = $epsilon;
|
||||
|
||||
let diff = if left_val > right_val {
|
||||
left_val - right_val
|
||||
} else {
|
||||
right_val - left_val
|
||||
};
|
||||
|
||||
if diff > epsilon_val {
|
||||
panic!(
|
||||
"Timestamp assertion failed: timestamps differ by {:?}, which exceeds epsilon {:?}\n left: {:?}\n right: {:?}",
|
||||
diff, epsilon_val, left_val, right_val
|
||||
);
|
||||
}
|
||||
}};
|
||||
($left:expr, $right:expr, $epsilon:expr, $($arg:tt)+) => {{
|
||||
let left_val = $left;
|
||||
let right_val = $right;
|
||||
let epsilon_val = $epsilon;
|
||||
|
||||
let diff = if left_val > right_val {
|
||||
left_val - right_val
|
||||
} else {
|
||||
right_val - left_val
|
||||
};
|
||||
|
||||
if diff > epsilon_val {
|
||||
panic!(
|
||||
"Timestamp assertion failed: timestamps differ by {:?}, which exceeds epsilon {:?}\n left: {:?}\n right: {:?}\n{}",
|
||||
diff, epsilon_val, left_val, right_val, format_args!($($arg)+)
|
||||
);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// Assert that two timestamps are approximately equal within a default epsilon of 1 second.
|
||||
///
|
||||
/// This is a convenience macro for common cases where a 1-second tolerance is sufficient.
|
||||
/// For more control over the epsilon, use `assert_timestamps_approx_eq!`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use chrono::Utc;
|
||||
/// use crate::test_utils::assert_timestamps_approx_eq_default;
|
||||
///
|
||||
/// let now = Utc::now();
|
||||
/// let nearly_now = now + chrono::Duration::milliseconds(500);
|
||||
///
|
||||
/// // This will pass - 500ms difference is within default 1s epsilon
|
||||
/// assert_timestamps_approx_eq_default!(now, nearly_now);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! assert_timestamps_approx_eq_default {
|
||||
($left:expr, $right:expr) => {
|
||||
$crate::assert_timestamps_approx_eq!($left, $right, chrono::Duration::seconds(1))
|
||||
};
|
||||
($left:expr, $right:expr, $($arg:tt)+) => {
|
||||
$crate::assert_timestamps_approx_eq!($left, $right, chrono::Duration::seconds(1), $($arg)+)
|
||||
};
|
||||
}
|
||||
|
||||
/// Assert that the `starts_at` and `ends_at` fields of two structs are approximately equal.
|
||||
///
|
||||
/// This macro is specifically designed for comparing listing timestamps where small
|
||||
/// variations in timing are expected. Uses a default epsilon of 1 second.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use chrono::Utc;
|
||||
/// use crate::test_utils::assert_listing_timestamps_approx_eq;
|
||||
///
|
||||
/// let original_listing = /* some listing */;
|
||||
/// let reconstructed_listing = /* reconstructed from DB */;
|
||||
///
|
||||
/// // Compare both starts_at and ends_at with default 1s epsilon
|
||||
/// assert_listing_timestamps_approx_eq!(
|
||||
/// original_listing.base,
|
||||
/// reconstructed_listing.base
|
||||
/// );
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! assert_listing_timestamps_approx_eq {
|
||||
($left:expr, $right:expr) => {
|
||||
$crate::assert_timestamps_approx_eq_default!(
|
||||
$left.starts_at,
|
||||
$right.starts_at,
|
||||
"starts_at timestamps don't match"
|
||||
);
|
||||
$crate::assert_timestamps_approx_eq_default!(
|
||||
$left.ends_at,
|
||||
$right.ends_at,
|
||||
"ends_at timestamps don't match"
|
||||
);
|
||||
};
|
||||
($left:expr, $right:expr, $epsilon:expr) => {
|
||||
$crate::assert_timestamps_approx_eq!(
|
||||
$left.starts_at,
|
||||
$right.starts_at,
|
||||
$epsilon,
|
||||
"starts_at timestamps don't match"
|
||||
);
|
||||
$crate::assert_timestamps_approx_eq!(
|
||||
$left.ends_at,
|
||||
$right.ends_at,
|
||||
$epsilon,
|
||||
"ends_at timestamps don't match"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
#[test]
|
||||
fn test_assert_timestamps_approx_eq_success() {
|
||||
let now = Utc::now();
|
||||
let nearly_now = now + Duration::milliseconds(50);
|
||||
|
||||
// Should not panic - within epsilon
|
||||
assert_timestamps_approx_eq!(now, nearly_now, Duration::milliseconds(100));
|
||||
assert_timestamps_approx_eq!(nearly_now, now, Duration::milliseconds(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assert_timestamps_approx_eq_exact() {
|
||||
let now = Utc::now();
|
||||
|
||||
// Should not panic - exact match
|
||||
assert_timestamps_approx_eq!(now, now, Duration::milliseconds(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Timestamp assertion failed")]
|
||||
fn test_assert_timestamps_approx_eq_failure() {
|
||||
let now = Utc::now();
|
||||
let much_later = now + Duration::seconds(2);
|
||||
|
||||
// Should panic - exceeds epsilon
|
||||
assert_timestamps_approx_eq!(now, much_later, Duration::milliseconds(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assert_timestamps_approx_eq_default_success() {
|
||||
let now = Utc::now();
|
||||
let nearly_now = now + Duration::milliseconds(500);
|
||||
|
||||
// Should not panic - within default 1s epsilon
|
||||
assert_timestamps_approx_eq_default!(now, nearly_now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Timestamp assertion failed")]
|
||||
fn test_assert_timestamps_approx_eq_default_failure() {
|
||||
let now = Utc::now();
|
||||
let much_later = now + Duration::seconds(2);
|
||||
|
||||
// Should panic - exceeds default 1s epsilon
|
||||
assert_timestamps_approx_eq_default!(now, much_later);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_error_message() {
|
||||
let now = Utc::now();
|
||||
let nearly_now = now + Duration::milliseconds(50);
|
||||
|
||||
// Should not panic - within epsilon, but test custom message format
|
||||
assert_timestamps_approx_eq!(
|
||||
now,
|
||||
nearly_now,
|
||||
Duration::milliseconds(100),
|
||||
"Custom error message"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_duration() {
|
||||
let now = Utc::now();
|
||||
let earlier = now - Duration::milliseconds(50);
|
||||
|
||||
// Should not panic - absolute difference is within epsilon
|
||||
assert_timestamps_approx_eq!(now, earlier, Duration::milliseconds(100));
|
||||
assert_timestamps_approx_eq!(earlier, now, Duration::milliseconds(100));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user