Files
pawctioneer-bot/src/db/models/listing.rs
2025-09-05 03:50:09 +00:00

214 lines
7.2 KiB
Rust

//! Listing models with type-safe enum-based structure
//!
//! This module provides a type-safe representation of different listing types:
//! - `BasicAuction`: Traditional time-based auction with bidding
//! - `MultiSlotAuction`: Auction with multiple winners/slots available
//! - `FixedPriceListing`: Fixed price sale with no bidding
//! - `BlindAuction`: Blind auction where seller chooses winner
//!
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
//! Database mapping is handled through `ListingRow` with conversion traits.
use crate::db::{CurrencyType, ListingDbId, ListingType, MoneyAmount, UserDbId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
pub type NewListing = Listing<()>;
pub type PersistedListing = Listing<PersistedListingFields>;
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct PersistedListingFields {
pub id: ListingDbId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Main listing/auction entity
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Listing<P: Debug + Clone> {
pub persisted: P,
pub base: ListingBase,
pub fields: ListingFields,
}
pub type ListingBaseFields<'a> = (&'a ListingBase, &'a ListingFields);
pub type ListingBaseFieldsMut<'a> = (&'a mut ListingBase, &'a mut ListingFields);
impl<'a, P: Debug + Clone> From<&'a Listing<P>> for ListingBaseFields<'a> {
fn from(value: &'a Listing<P>) -> Self {
(&value.base, &value.fields)
}
}
impl<'a, P: Debug + Clone> From<&'a mut Listing<P>> for ListingBaseFieldsMut<'a> {
fn from(value: &'a mut Listing<P>) -> Self {
(&mut value.base, &mut value.fields)
}
}
/// Common fields shared by all listing types
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct ListingBase {
pub seller_id: UserDbId,
pub title: String,
pub description: Option<String>,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub currency_type: CurrencyType,
}
impl ListingBase {
#[cfg(test)]
pub fn with_fields(self, fields: ListingFields) -> NewListing {
Listing {
persisted: (),
base: self,
fields,
}
}
}
/// Fields specific to basic auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BasicAuctionFields {
pub starting_bid: MoneyAmount,
pub buy_now_price: Option<MoneyAmount>,
pub min_increment: MoneyAmount,
pub anti_snipe_minutes: Option<i32>,
}
/// Fields specific to multi-slot auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MultiSlotAuctionFields {
pub starting_bid: MoneyAmount,
pub buy_now_price: MoneyAmount,
pub min_increment: Option<MoneyAmount>,
pub slots_available: i32,
pub anti_snipe_minutes: i32,
}
/// Fields specific to fixed price listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FixedPriceListingFields {
pub buy_now_price: MoneyAmount,
pub slots_available: i32,
}
/// Fields specific to blind auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlindAuctionFields {
pub starting_bid: MoneyAmount,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ListingFields {
BasicAuction(BasicAuctionFields),
MultiSlotAuction(MultiSlotAuctionFields),
FixedPriceListing(FixedPriceListingFields),
BlindAuction(BlindAuctionFields),
}
impl ListingFields {
pub fn listing_type(&self) -> ListingType {
match self {
ListingFields::BasicAuction(_) => ListingType::BasicAuction,
ListingFields::MultiSlotAuction(_) => ListingType::MultiSlotAuction,
ListingFields::FixedPriceListing(_) => ListingType::FixedPriceListing,
ListingFields::BlindAuction(_) => ListingType::BlindAuction,
}
}
}
impl From<&ListingFields> for ListingType {
fn from(fields: &ListingFields) -> ListingType {
fields.listing_type()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{TelegramUserDbId, UserDAO};
use chrono::Duration;
use rstest::rstest;
/// Create a test user using UserDAO and return their ID
async fn create_test_user(
user_dao: &UserDAO,
telegram_id: TelegramUserDbId,
username: Option<&str>,
) -> UserDbId {
use crate::db::models::user::NewUser;
let new_user = NewUser {
persisted: (),
telegram_id,
first_name: "Test User".to_string(),
last_name: None,
username: username.map(|s| s.to_string()),
is_banned: false,
};
let user = user_dao
.insert_user(&new_user)
.await
.expect("Failed to create test user");
user.persisted.id
}
fn build_base_listing(
seller_id: UserDbId,
title: impl Into<String>,
description: Option<&str>,
currency_type: CurrencyType,
) -> ListingBase {
ListingBase {
seller_id,
title: title.into(),
description: description.map(|s| s.to_string()),
currency_type,
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
}
}
#[rstest]
#[case(ListingFields::BlindAuction(BlindAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap() }))]
#[case(ListingFields::BasicAuction(BasicAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), min_increment: MoneyAmount::from_str("1.00").unwrap(), anti_snipe_minutes: Some(5) }))]
#[case(ListingFields::MultiSlotAuction(MultiSlotAuctionFields { starting_bid: MoneyAmount::from_str("100.00").unwrap(), buy_now_price: MoneyAmount::from_str("100.00").unwrap(), min_increment: Some(MoneyAmount::from_str("1.00").unwrap()), slots_available: 10, anti_snipe_minutes: 5 }))]
#[case(ListingFields::FixedPriceListing(FixedPriceListingFields { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 }))]
#[tokio::test]
async fn test_blind_auction_crud(#[case] fields: ListingFields) {
use crate::{db::ListingDAO, test_utils::create_test_pool};
let pool = create_test_pool().await;
let user_dao = UserDAO::new(pool.clone());
let listing_dao = ListingDAO::new(pool.clone());
let seller_id = create_test_user(&user_dao, 99999.into(), Some("testuser")).await;
let new_listing = build_base_listing(
seller_id,
"Test Auction",
Some("Test description"),
CurrencyType::Usd,
)
.with_fields(fields);
// Insert using DAO
let created_listing = listing_dao
.insert_listing(new_listing.clone())
.await
.expect("Failed to insert listing");
assert_eq!(created_listing.base, new_listing.base);
assert_eq!(created_listing.fields, new_listing.fields);
let read_listing = listing_dao
.find_by_id(created_listing.persisted.id)
.await
.expect("Failed to find listing")
.expect("Listing should exist");
assert_eq!(read_listing, created_listing);
}
}