237 lines
7.9 KiB
Rust
237 lines
7.9 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)]
|
|
#[allow(unused)]
|
|
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> Into<ListingBaseFields<'a>> for &'a Listing<P> {
|
|
fn into(self) -> ListingBaseFields<'a> {
|
|
(&self.base, &self.fields)
|
|
}
|
|
}
|
|
impl<'a, P: Debug + Clone> Into<ListingBaseFieldsMut<'a>> for &'a mut Listing<P> {
|
|
fn into(self) -> ListingBaseFieldsMut<'a> {
|
|
(&mut self.base, &mut self.fields)
|
|
}
|
|
}
|
|
|
|
/// Common fields shared by all listing types
|
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
|
#[allow(unused)]
|
|
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)]
|
|
#[allow(unused)]
|
|
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)]
|
|
#[allow(unused)]
|
|
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)]
|
|
#[allow(unused)]
|
|
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)]
|
|
#[allow(unused)]
|
|
pub struct BlindAuctionFields {
|
|
pub starting_bid: MoneyAmount,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[allow(unused)]
|
|
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::{ListingDAO, TelegramUserDbId};
|
|
use chrono::Duration;
|
|
use rstest::rstest;
|
|
use sqlx::SqlitePool;
|
|
|
|
/// Test utilities for creating an in-memory database with migrations
|
|
async fn create_test_pool() -> SqlitePool {
|
|
// Create an in-memory SQLite database for testing
|
|
let pool = SqlitePool::connect("sqlite::memory:")
|
|
.await
|
|
.expect("Failed to create in-memory database");
|
|
|
|
// Run the migration
|
|
apply_test_migrations(&pool).await;
|
|
|
|
pool
|
|
}
|
|
|
|
/// Apply the database migrations for testing
|
|
async fn apply_test_migrations(pool: &SqlitePool) {
|
|
// Run the actual migrations from the migrations directory
|
|
sqlx::migrate!("./migrations")
|
|
.run(pool)
|
|
.await
|
|
.expect("Failed to run database migrations");
|
|
}
|
|
|
|
/// Create a test user using UserDAO and return their ID
|
|
async fn create_test_user(
|
|
pool: &SqlitePool,
|
|
telegram_id: TelegramUserDbId,
|
|
username: Option<&str>,
|
|
) -> UserDbId {
|
|
use crate::db::{models::user::NewUser, UserDAO};
|
|
|
|
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 = UserDAO::insert_user(pool, &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) {
|
|
let pool = create_test_pool().await;
|
|
let seller_id = create_test_user(&pool, 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 = ListingDAO::insert_listing(&pool, 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 = ListingDAO::find_by_id(&pool, created_listing.persisted.id)
|
|
.await
|
|
.expect("Failed to find listing")
|
|
.expect("Listing should exist");
|
|
|
|
assert_eq!(read_listing, created_listing);
|
|
}
|
|
}
|