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.
|
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
|
||||||
//! Database mapping is handled through `ListingRow` with conversion traits.
|
//! 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 super::listing_type::ListingType;
|
||||||
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
|
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
/// Common fields shared by all listing types
|
/// Common fields shared by all listing types
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ListingBase {
|
pub struct ListingBase {
|
||||||
pub id: ListingId,
|
pub id: ListingId,
|
||||||
pub seller_id: UserId,
|
pub seller_id: UserId,
|
||||||
@@ -29,394 +26,368 @@ pub struct ListingBase {
|
|||||||
pub updated_at: DateTime<Utc>,
|
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
|
/// Main listing/auction entity enum
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone)]
|
||||||
#[serde(tag = "listing_type")]
|
pub struct Listing {
|
||||||
pub enum Listing {
|
pub base: ListingBase,
|
||||||
#[serde(rename = "basic_auction")]
|
pub fields: ListingFields,
|
||||||
BasicAuction(BasicAuction),
|
|
||||||
#[serde(rename = "multi_slot_auction")]
|
|
||||||
MultiSlotAuction(MultiSlotAuction),
|
|
||||||
#[serde(rename = "fixed_price_listing")]
|
|
||||||
FixedPriceListing(FixedPriceListing),
|
|
||||||
#[serde(rename = "blind_auction")]
|
|
||||||
BlindAuction(BlindAuction),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// New listing data for insertion
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum NewListing {
|
pub enum ListingFields {
|
||||||
BasicAuction {
|
BasicAuction {
|
||||||
seller_id: UserId,
|
starting_bid: MoneyAmount,
|
||||||
title: String,
|
|
||||||
description: Option<String>,
|
|
||||||
starting_bid: Option<MoneyAmount>,
|
|
||||||
buy_now_price: Option<MoneyAmount>,
|
buy_now_price: Option<MoneyAmount>,
|
||||||
min_increment: MoneyAmount,
|
min_increment: MoneyAmount,
|
||||||
starts_at: DateTime<Utc>,
|
anti_snipe_minutes: Option<i32>,
|
||||||
ends_at: DateTime<Utc>,
|
|
||||||
anti_snipe_minutes: i32,
|
|
||||||
},
|
},
|
||||||
MultiSlotAuction {
|
MultiSlotAuction {
|
||||||
seller_id: UserId,
|
starting_bid: MoneyAmount,
|
||||||
title: String,
|
buy_now_price: MoneyAmount,
|
||||||
description: Option<String>,
|
min_increment: Option<MoneyAmount>,
|
||||||
starting_bid: Option<MoneyAmount>,
|
|
||||||
buy_now_price: Option<MoneyAmount>,
|
|
||||||
min_increment: MoneyAmount,
|
|
||||||
slots_available: i32,
|
slots_available: i32,
|
||||||
starts_at: DateTime<Utc>,
|
|
||||||
ends_at: DateTime<Utc>,
|
|
||||||
anti_snipe_minutes: i32,
|
anti_snipe_minutes: i32,
|
||||||
},
|
},
|
||||||
FixedPriceListing {
|
FixedPriceListing {
|
||||||
seller_id: UserId,
|
|
||||||
title: String,
|
|
||||||
description: Option<String>,
|
|
||||||
buy_now_price: MoneyAmount,
|
buy_now_price: MoneyAmount,
|
||||||
slots_available: i32,
|
slots_available: i32,
|
||||||
starts_at: DateTime<Utc>,
|
|
||||||
ends_at: DateTime<Utc>,
|
|
||||||
},
|
},
|
||||||
BlindAuction {
|
BlindAuction {
|
||||||
seller_id: UserId,
|
starting_bid: MoneyAmount,
|
||||||
title: String,
|
|
||||||
description: Option<String>,
|
|
||||||
starts_at: DateTime<Utc>,
|
|
||||||
ends_at: DateTime<Utc>,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Listing {
|
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
|
/// Get the listing type as an enum value
|
||||||
pub fn listing_type(&self) -> ListingType {
|
pub fn listing_type(&self) -> ListingType {
|
||||||
match self {
|
match &self.fields {
|
||||||
Listing::BasicAuction(_) => ListingType::BasicAuction,
|
ListingFields::BasicAuction { .. } => ListingType::BasicAuction,
|
||||||
Listing::MultiSlotAuction(_) => ListingType::MultiSlotAuction,
|
ListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
||||||
Listing::FixedPriceListing(_) => ListingType::FixedPriceListing,
|
ListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
||||||
Listing::BlindAuction(_) => ListingType::BlindAuction,
|
ListingFields::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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flat database row structure for SQLx FromRow compatibility
|
#[cfg(test)]
|
||||||
#[derive(Debug, Clone, FromRow)]
|
mod tests {
|
||||||
pub struct ListingRow {
|
use crate::db::{new_listing::NewListingBase, ListingDAO, TelegramUserId};
|
||||||
pub id: ListingId,
|
use crate::{assert_listing_timestamps_approx_eq, assert_timestamps_approx_eq_default};
|
||||||
pub seller_id: UserId,
|
|
||||||
pub listing_type: ListingType,
|
|
||||||
pub title: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
|
|
||||||
// Pricing fields - nullable for types that don't use them
|
use super::*;
|
||||||
pub starting_bid: Option<MoneyAmount>,
|
use chrono::{Duration, Utc};
|
||||||
pub buy_now_price: Option<MoneyAmount>,
|
use rstest::rstest;
|
||||||
pub min_increment: Option<MoneyAmount>,
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
// Multi-slot/fixed price
|
/// Test utilities for creating an in-memory database with migrations
|
||||||
pub slots_available: Option<i32>,
|
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
|
// Run the migration
|
||||||
pub starts_at: DateTime<Utc>,
|
apply_test_migrations(&pool).await;
|
||||||
pub ends_at: DateTime<Utc>,
|
|
||||||
pub anti_snipe_minutes: Option<i32>,
|
|
||||||
|
|
||||||
pub created_at: DateTime<Utc>,
|
pool
|
||||||
pub updated_at: DateTime<Utc>,
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ListingRow> for Listing {
|
/// Apply the database migrations for testing
|
||||||
fn from(row: ListingRow) -> Self {
|
async fn apply_test_migrations(pool: &SqlitePool) {
|
||||||
let base = ListingBase {
|
// Run the actual migrations from the migrations directory
|
||||||
id: row.id,
|
sqlx::migrate!("./migrations")
|
||||||
seller_id: row.seller_id,
|
.run(pool)
|
||||||
title: row.title,
|
.await
|
||||||
description: row.description,
|
.expect("Failed to run database migrations");
|
||||||
starts_at: row.starts_at,
|
}
|
||||||
ends_at: row.ends_at,
|
|
||||||
created_at: row.created_at,
|
/// Create a test user using UserDAO and return their ID
|
||||||
updated_at: row.updated_at,
|
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 {
|
let user = UserDAO::insert_user(pool, &new_user)
|
||||||
ListingType::BasicAuction => Listing::BasicAuction(BasicAuction {
|
.await
|
||||||
base,
|
.expect("Failed to create test user");
|
||||||
starting_bid: row.starting_bid,
|
user.id
|
||||||
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 }),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Listing> for ListingRow {
|
/// Fetch a listing using ListingDAO by ID
|
||||||
fn from(listing: Listing) -> Self {
|
async fn fetch_listing_using_dao(pool: &SqlitePool, id: ListingId) -> Listing {
|
||||||
match listing {
|
use crate::db::ListingDAO;
|
||||||
Listing::BasicAuction(auction) => ListingRow {
|
|
||||||
id: auction.base.id,
|
ListingDAO::find_by_id(pool, id)
|
||||||
seller_id: auction.base.seller_id,
|
.await
|
||||||
listing_type: ListingType::BasicAuction,
|
.expect("Failed to fetch listing using DAO")
|
||||||
title: auction.base.title,
|
.expect("Listing should exist")
|
||||||
description: auction.base.description,
|
}
|
||||||
starting_bid: auction.starting_bid,
|
|
||||||
buy_now_price: auction.buy_now_price,
|
#[tokio::test]
|
||||||
min_increment: Some(auction.min_increment),
|
async fn test_basic_auction_crud() {
|
||||||
slots_available: Some(1),
|
let pool = create_test_pool().await;
|
||||||
starts_at: auction.base.starts_at,
|
let seller_id = create_test_user(&pool, 12345.into(), Some("testuser")).await;
|
||||||
ends_at: auction.base.ends_at,
|
|
||||||
anti_snipe_minutes: Some(auction.anti_snipe_minutes),
|
// Create a basic auction listing
|
||||||
created_at: auction.base.created_at,
|
let starts_at = Utc::now();
|
||||||
updated_at: auction.base.updated_at,
|
let ends_at = starts_at + Duration::hours(24);
|
||||||
},
|
|
||||||
Listing::MultiSlotAuction(auction) => ListingRow {
|
let new_listing = build_base_listing(
|
||||||
id: auction.base.id,
|
seller_id,
|
||||||
seller_id: auction.base.seller_id,
|
"Test Basic Auction",
|
||||||
listing_type: ListingType::MultiSlotAuction,
|
Some("A test auction for basic functionality"),
|
||||||
title: auction.base.title,
|
)
|
||||||
description: auction.base.description,
|
.new_basic_auction(
|
||||||
starting_bid: auction.starting_bid,
|
MoneyAmount::from_str("10.00").unwrap(),
|
||||||
buy_now_price: auction.buy_now_price,
|
Some(MoneyAmount::from_str("100.00").unwrap()),
|
||||||
min_increment: Some(auction.min_increment),
|
MoneyAmount::from_str("1.00").unwrap(),
|
||||||
slots_available: Some(auction.slots_available),
|
Some(5),
|
||||||
starts_at: auction.base.starts_at,
|
);
|
||||||
ends_at: auction.base.ends_at,
|
|
||||||
anti_snipe_minutes: Some(auction.anti_snipe_minutes),
|
// Insert using DAO
|
||||||
created_at: auction.base.created_at,
|
let actual_id = ListingDAO::insert_listing(&pool, &new_listing)
|
||||||
updated_at: auction.base.updated_at,
|
.await
|
||||||
},
|
.expect("Failed to insert listing")
|
||||||
Listing::FixedPriceListing(listing) => ListingRow {
|
.base
|
||||||
id: listing.base.id,
|
.id;
|
||||||
seller_id: listing.base.seller_id,
|
|
||||||
listing_type: ListingType::FixedPriceListing,
|
// Fetch back from database using DAO
|
||||||
title: listing.base.title,
|
let reconstructed_listing = fetch_listing_using_dao(&pool, actual_id).await;
|
||||||
description: listing.base.description,
|
|
||||||
starting_bid: None,
|
// Verify the round trip worked correctly
|
||||||
buy_now_price: Some(listing.buy_now_price),
|
match reconstructed_listing.fields {
|
||||||
min_increment: None,
|
ListingFields::BasicAuction {
|
||||||
slots_available: Some(listing.slots_available),
|
starting_bid,
|
||||||
starts_at: listing.base.starts_at,
|
buy_now_price,
|
||||||
ends_at: listing.base.ends_at,
|
min_increment,
|
||||||
anti_snipe_minutes: None,
|
anti_snipe_minutes,
|
||||||
created_at: listing.base.created_at,
|
} => {
|
||||||
updated_at: listing.base.updated_at,
|
assert_eq!(reconstructed_listing.base.seller_id, seller_id);
|
||||||
},
|
assert_eq!(reconstructed_listing.base.title, "Test Basic Auction");
|
||||||
Listing::BlindAuction(auction) => ListingRow {
|
assert_eq!(
|
||||||
id: auction.base.id,
|
reconstructed_listing.base.description,
|
||||||
seller_id: auction.base.seller_id,
|
Some("A test auction for basic functionality".to_string())
|
||||||
listing_type: ListingType::BlindAuction,
|
);
|
||||||
title: auction.base.title,
|
assert_eq!(starting_bid, MoneyAmount::from_str("10.00").unwrap());
|
||||||
description: auction.base.description,
|
assert_eq!(
|
||||||
starting_bid: None,
|
buy_now_price,
|
||||||
buy_now_price: None,
|
Some(MoneyAmount::from_str("100.00").unwrap())
|
||||||
min_increment: None,
|
);
|
||||||
slots_available: Some(1),
|
assert_eq!(min_increment, MoneyAmount::from_str("1.00").unwrap());
|
||||||
starts_at: auction.base.starts_at,
|
assert_eq!(anti_snipe_minutes, Some(5));
|
||||||
ends_at: auction.base.ends_at,
|
assert_timestamps_approx_eq_default!(
|
||||||
anti_snipe_minutes: None,
|
reconstructed_listing.base.starts_at,
|
||||||
created_at: auction.base.created_at,
|
starts_at
|
||||||
updated_at: auction.base.updated_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 commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_utils;
|
||||||
|
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use config::Config;
|
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