feat: add epsilon-based timestamp comparison macros for tests

This commit is contained in:
Dylan Knutson
2025-08-28 01:05:16 +00:00
parent fc1a61ff32
commit 7a16c5859b
3 changed files with 546 additions and 357 deletions

View File

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

View File

@@ -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
View 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));
}
}