Files
pawctioneer-bot/src/db/models/listing.rs
2025-08-29 23:25:12 +00:00

183 lines
6.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::{ListingDbId, ListingDuration, MoneyAmount, UserDbId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
pub type NewListing = Listing<NewListingFields>;
pub type PersistedListing = Listing<PersistedListingFields>;
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct PersistedListingFields {
pub id: ListingDbId,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Default)]
pub struct NewListingFields {
pub start_delay: ListingDuration,
pub end_delay: ListingDuration,
}
/// 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,
}
/// 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>,
}
impl ListingBase {
#[cfg(test)]
pub fn with_fields(self, fields: ListingFields) -> NewListing {
Listing {
persisted: NewListingFields::default(),
base: self,
fields,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub enum ListingFields {
BasicAuction {
starting_bid: MoneyAmount,
buy_now_price: Option<MoneyAmount>,
min_increment: MoneyAmount,
anti_snipe_minutes: Option<i32>,
},
MultiSlotAuction {
starting_bid: MoneyAmount,
buy_now_price: MoneyAmount,
min_increment: Option<MoneyAmount>,
slots_available: i32,
anti_snipe_minutes: i32,
},
FixedPriceListing {
buy_now_price: MoneyAmount,
slots_available: i32,
},
BlindAuction {
starting_bid: MoneyAmount,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ListingDbId;
use crate::db::{ListingDAO, TelegramUserDbId};
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>,
) -> ListingBase {
ListingBase {
seller_id,
title: title.into(),
description: description.map(|s| s.to_string()),
}
}
#[rstest]
#[case(ListingFields::BlindAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap() })]
#[case(ListingFields::BasicAuction { 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 { 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 { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 })]
#[case(ListingFields::BlindAuction { starting_bid: MoneyAmount::from_str("100.00").unwrap() })]
#[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"))
.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);
}
}