refactor: organize DAOs into dedicated db/dao/ directory
- Move listing_dao.rs and user_dao.rs to src/db/dao/ - Create dao/mod.rs with proper re-exports for ListingDAO and UserDAO - Update import paths in DAO files to work from new location - Update db/mod.rs to import from new dao module - All tests still pass - no functionality changes
This commit is contained in:
@@ -10,9 +10,9 @@ CREATE TABLE users (
|
||||
telegram_id INTEGER UNIQUE NOT NULL,
|
||||
username TEXT,
|
||||
display_name TEXT,
|
||||
is_banned BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
is_banned INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
|
||||
-- Main listing table (handles all listing types)
|
||||
@@ -32,12 +32,12 @@ CREATE TABLE listings (
|
||||
slots_available INTEGER DEFAULT 1,
|
||||
|
||||
-- Timing
|
||||
starts_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ends_at TIMESTAMP NOT NULL,
|
||||
starts_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
ends_at TEXT NOT NULL,
|
||||
anti_snipe_minutes INTEGER DEFAULT 5,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (seller_id) REFERENCES users(id)
|
||||
) STRICT;
|
||||
|
||||
@@ -47,10 +47,10 @@ CREATE TABLE proxy_bids (
|
||||
listing_id INTEGER NOT NULL,
|
||||
buyer_id INTEGER NOT NULL,
|
||||
max_amount INTEGER NOT NULL, -- stored as cents
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (listing_id) REFERENCES listings(id),
|
||||
FOREIGN KEY (buyer_id) REFERENCES users(id),
|
||||
@@ -68,14 +68,14 @@ CREATE TABLE bids (
|
||||
description TEXT,
|
||||
|
||||
-- Status
|
||||
is_cancelled BOOLEAN DEFAULT FALSE,
|
||||
is_cancelled INTEGER DEFAULT 0,
|
||||
slot_number INTEGER, -- For multi-slot listings
|
||||
|
||||
-- NULL = manual bid, NOT NULL = generated from proxy
|
||||
proxy_bid_id INTEGER,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (listing_id) REFERENCES listings(id),
|
||||
FOREIGN KEY (buyer_id) REFERENCES users(id),
|
||||
FOREIGN KEY (proxy_bid_id) REFERENCES proxy_bids(id)
|
||||
@@ -88,8 +88,8 @@ CREATE TABLE listing_medias (
|
||||
telegram_file_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL, -- 'photo', 'video'
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (listing_id) REFERENCES listings(id)
|
||||
) STRICT;
|
||||
|
||||
@@ -97,10 +97,10 @@ CREATE TABLE listing_medias (
|
||||
CREATE TABLE user_settings (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
language_code TEXT DEFAULT 'en',
|
||||
notify_outbid BOOLEAN DEFAULT TRUE,
|
||||
notify_won BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
notify_outbid INTEGER DEFAULT 1,
|
||||
notify_won INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
) STRICT;
|
||||
|
||||
|
||||
233
src/db/dao/listing_dao.rs
Normal file
233
src/db/dao/listing_dao.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! Listing Data Access Object (DAO)
|
||||
//!
|
||||
//! Provides encapsulated CRUD operations for Listing entities
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::{sqlite::SqliteRow, Row, SqlitePool};
|
||||
|
||||
use crate::db::{
|
||||
new_listing::{NewListing, NewListingFields},
|
||||
ListingBase, ListingFields,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
listing_id::ListingId, models::listing::Listing, models::listing_type::ListingType,
|
||||
user_id::UserId,
|
||||
};
|
||||
|
||||
/// Data Access Object for Listing operations
|
||||
pub struct ListingDAO;
|
||||
|
||||
impl ListingDAO {
|
||||
/// Insert a new listing into the database
|
||||
pub async fn insert_listing(pool: &SqlitePool, new_listing: &NewListing) -> Result<Listing> {
|
||||
let listing_type = new_listing.listing_type();
|
||||
let base = &new_listing.base;
|
||||
let fields = &new_listing.fields;
|
||||
|
||||
let base_query = match listing_type {
|
||||
ListingType::BasicAuction => sqlx::query(
|
||||
r#"
|
||||
INSERT INTO listings (
|
||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
||||
starting_bid, buy_now_price, min_increment, anti_snipe_minutes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
||||
starting_bid, buy_now_price, min_increment, anti_snipe_minutes
|
||||
"#,
|
||||
),
|
||||
ListingType::MultiSlotAuction => sqlx::query(
|
||||
r#"
|
||||
INSERT INTO listings (
|
||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
||||
starting_bid, buy_now_price, min_increment, slots_available, anti_snipe_minutes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
||||
starting_bid, buy_now_price, min_increment, slots_available, anti_snipe_minutes
|
||||
"#,
|
||||
),
|
||||
ListingType::FixedPriceListing => sqlx::query(
|
||||
r#"
|
||||
INSERT INTO listings (
|
||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
||||
buy_now_price, slots_available
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
||||
buy_now_price, slots_available
|
||||
"#,
|
||||
),
|
||||
ListingType::BlindAuction => sqlx::query(
|
||||
r#"
|
||||
INSERT INTO listings (
|
||||
seller_id, listing_type, title, description, starts_at, ends_at,
|
||||
starting_bid
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, seller_id, listing_type, title, description, starts_at, ends_at, created_at, updated_at,
|
||||
starting_bid
|
||||
"#,
|
||||
),
|
||||
};
|
||||
|
||||
let row = base_query
|
||||
.bind(base.seller_id)
|
||||
.bind(listing_type)
|
||||
.bind(&base.title)
|
||||
.bind(&base.description)
|
||||
.bind(base.starts_at)
|
||||
.bind(base.ends_at);
|
||||
|
||||
let row = match &fields {
|
||||
NewListingFields::BasicAuction {
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
anti_snipe_minutes,
|
||||
} => row
|
||||
.bind(starting_bid)
|
||||
.bind(buy_now_price)
|
||||
.bind(min_increment)
|
||||
.bind(anti_snipe_minutes),
|
||||
NewListingFields::MultiSlotAuction {
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
slots_available,
|
||||
anti_snipe_minutes,
|
||||
} => row
|
||||
.bind(starting_bid)
|
||||
.bind(buy_now_price)
|
||||
.bind(min_increment)
|
||||
.bind(slots_available)
|
||||
.bind(anti_snipe_minutes),
|
||||
NewListingFields::FixedPriceListing {
|
||||
buy_now_price,
|
||||
slots_available,
|
||||
} => row.bind(buy_now_price).bind(slots_available),
|
||||
NewListingFields::BlindAuction { starting_bid } => row.bind(starting_bid),
|
||||
};
|
||||
|
||||
let row = row.fetch_one(pool).await?;
|
||||
Self::row_to_listing(row)
|
||||
}
|
||||
|
||||
/// Find a listing by its ID
|
||||
pub async fn find_by_id(pool: &SqlitePool, listing_id: ListingId) -> Result<Option<Listing>> {
|
||||
let result = sqlx::query("SELECT * FROM listings WHERE id = ?")
|
||||
.bind(listing_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(Self::row_to_listing).transpose()?)
|
||||
}
|
||||
|
||||
/// Find all listings by a seller
|
||||
pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result<Vec<Listing>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
|
||||
.bind(seller_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(Self::row_to_listing)
|
||||
.collect::<Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Find all listings of a specific type
|
||||
pub async fn find_by_type(
|
||||
pool: &SqlitePool,
|
||||
listing_type: ListingType,
|
||||
) -> Result<Vec<Listing>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT * FROM listings WHERE listing_type = ? ORDER BY created_at DESC")
|
||||
.bind(listing_type)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(Self::row_to_listing)
|
||||
.collect::<Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Find active listings (not ended yet)
|
||||
pub async fn find_active_listings(pool: &SqlitePool) -> Result<Vec<Listing>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT * FROM listings WHERE ends_at > CURRENT_TIMESTAMP ORDER BY ends_at ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(Self::row_to_listing)
|
||||
.collect::<Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Delete a listing
|
||||
pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> {
|
||||
sqlx::query("DELETE FROM listings WHERE id = ?")
|
||||
.bind(listing_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Count total listings
|
||||
pub async fn count_listings(pool: &SqlitePool) -> Result<i64> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as count FROM listings")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.get("count"))
|
||||
}
|
||||
|
||||
/// Count listings by seller
|
||||
pub async fn count_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result<i64> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as count FROM listings WHERE seller_id = ?")
|
||||
.bind(seller_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.get("count"))
|
||||
}
|
||||
|
||||
fn row_to_listing(row: SqliteRow) -> Result<Listing> {
|
||||
let listing_type = row.get("listing_type");
|
||||
let base = ListingBase {
|
||||
id: ListingId::new(row.get("id")),
|
||||
seller_id: row.get("seller_id"),
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
starts_at: row.get("starts_at"),
|
||||
ends_at: row.get("ends_at"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
};
|
||||
let fields = match listing_type {
|
||||
ListingType::BasicAuction => ListingFields::BasicAuction {
|
||||
starting_bid: row.get("starting_bid"),
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
min_increment: row.get("min_increment"),
|
||||
anti_snipe_minutes: row.get("anti_snipe_minutes"),
|
||||
},
|
||||
ListingType::MultiSlotAuction => ListingFields::MultiSlotAuction {
|
||||
starting_bid: row.get("starting_bid"),
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
min_increment: row.get("min_increment"),
|
||||
slots_available: row.get("slots_available"),
|
||||
anti_snipe_minutes: row.get("anti_snipe_minutes"),
|
||||
},
|
||||
ListingType::FixedPriceListing => ListingFields::FixedPriceListing {
|
||||
buy_now_price: row.get("buy_now_price"),
|
||||
slots_available: row.get("slots_available"),
|
||||
},
|
||||
ListingType::BlindAuction => ListingFields::BlindAuction {
|
||||
starting_bid: row.get("starting_bid"),
|
||||
},
|
||||
};
|
||||
Ok(Listing { base, fields })
|
||||
}
|
||||
}
|
||||
6
src/db/dao/mod.rs
Normal file
6
src/db/dao/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod listing_dao;
|
||||
pub mod user_dao;
|
||||
|
||||
// Re-export DAO structs for easy access
|
||||
pub use listing_dao::ListingDAO;
|
||||
pub use user_dao::UserDAO;
|
||||
323
src/db/dao/user_dao.rs
Normal file
323
src/db/dao/user_dao.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
//! User Data Access Object (DAO)
|
||||
//!
|
||||
//! Provides encapsulated CRUD operations for User entities
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::TelegramUserId;
|
||||
|
||||
use super::super::{
|
||||
models::user::{NewUser, User},
|
||||
user_id::UserId,
|
||||
};
|
||||
|
||||
/// Data Access Object for User operations
|
||||
pub struct UserDAO;
|
||||
|
||||
impl UserDAO {
|
||||
/// Insert a new user into the database
|
||||
pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result<User> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (telegram_id, username, display_name)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(new_user.telegram_id)
|
||||
.bind(&new_user.username)
|
||||
.bind(&new_user.display_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Find a user by their ID
|
||||
pub async fn find_by_id(pool: &SqlitePool, user_id: UserId) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Find a user by their Telegram ID
|
||||
pub async fn find_by_telegram_id(
|
||||
pool: &SqlitePool,
|
||||
telegram_id: TelegramUserId,
|
||||
) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?"
|
||||
)
|
||||
.bind(telegram_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update a user's information
|
||||
pub async fn update_user(pool: &SqlitePool, user: &User) -> Result<User> {
|
||||
let updated_user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET username = ?, display_name = ?, is_banned = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
RETURNING id, telegram_id, username, display_name, is_banned, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(&user.username)
|
||||
.bind(&user.display_name)
|
||||
.bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite
|
||||
.bind(user.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(updated_user)
|
||||
}
|
||||
|
||||
/// Set a user's ban status
|
||||
pub async fn set_ban_status(pool: &SqlitePool, user_id: UserId, is_banned: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(is_banned) // sqlx automatically converts bool to INTEGER for SQLite
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a user (soft delete by setting is_banned = true might be better in production)
|
||||
pub async fn delete_user(pool: &SqlitePool, user_id: UserId) -> Result<()> {
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get or create a user (find by telegram_id, create if not found)
|
||||
pub async fn get_or_create_user(
|
||||
pool: &SqlitePool,
|
||||
telegram_id: TelegramUserId,
|
||||
username: Option<String>,
|
||||
display_name: Option<String>,
|
||||
) -> Result<User> {
|
||||
// Try to find existing user first
|
||||
if let Some(existing_user) = Self::find_by_telegram_id(pool, telegram_id).await? {
|
||||
return Ok(existing_user);
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
let new_user = NewUser {
|
||||
telegram_id,
|
||||
username,
|
||||
display_name,
|
||||
};
|
||||
|
||||
Self::insert_user(pool, &new_user).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::models::user::{NewUser, User};
|
||||
use rstest::rstest;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Create test database for UserDAO tests
|
||||
async fn create_test_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("Failed to create in-memory database");
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run database migrations");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_insert_and_find_user() {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
let new_user = NewUser {
|
||||
telegram_id: 12345.into(),
|
||||
username: Some("testuser".to_string()),
|
||||
display_name: Some("Test User".to_string()),
|
||||
};
|
||||
|
||||
// Insert user
|
||||
let inserted_user = UserDAO::insert_user(&pool, &new_user)
|
||||
.await
|
||||
.expect("Failed to insert user");
|
||||
|
||||
assert_eq!(inserted_user.telegram_id, 12345.into());
|
||||
assert_eq!(inserted_user.username, Some("testuser".to_string()));
|
||||
assert_eq!(inserted_user.display_name, Some("Test User".to_string()));
|
||||
assert_eq!(inserted_user.is_banned, false);
|
||||
|
||||
// Find by ID
|
||||
let found_user = UserDAO::find_by_id(&pool, inserted_user.id)
|
||||
.await
|
||||
.expect("Failed to find user by id")
|
||||
.expect("User should be found");
|
||||
|
||||
assert_eq!(found_user.id, inserted_user.id);
|
||||
assert_eq!(found_user.telegram_id, inserted_user.telegram_id);
|
||||
|
||||
// Find by telegram ID
|
||||
let found_by_telegram = UserDAO::find_by_telegram_id(&pool, 12345.into())
|
||||
.await
|
||||
.expect("Failed to find user by telegram_id")
|
||||
.expect("User should be found");
|
||||
|
||||
assert_eq!(found_by_telegram.id, inserted_user.id);
|
||||
assert_eq!(found_by_telegram.telegram_id, 12345.into());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_or_create_user() {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
// First call should create the user
|
||||
let user1 = UserDAO::get_or_create_user(
|
||||
&pool,
|
||||
67890.into(),
|
||||
Some("newuser".to_string()),
|
||||
Some("New User".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to get or create user");
|
||||
|
||||
assert_eq!(user1.telegram_id, 67890.into());
|
||||
assert_eq!(user1.username, Some("newuser".to_string()));
|
||||
|
||||
// Second call should return the same user
|
||||
let user2 = UserDAO::get_or_create_user(
|
||||
&pool,
|
||||
67890.into(),
|
||||
Some("differentusername".to_string()), // This should be ignored
|
||||
Some("Different Name".to_string()), // This should be ignored
|
||||
)
|
||||
.await
|
||||
.expect("Failed to get or create user");
|
||||
|
||||
assert_eq!(user1.id, user2.id);
|
||||
assert_eq!(user2.username, Some("newuser".to_string())); // Original username preserved
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(true)]
|
||||
#[case(false)]
|
||||
#[tokio::test]
|
||||
async fn test_ban_status_operations(#[case] is_banned: bool) {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
let new_user = NewUser {
|
||||
telegram_id: 99999.into(),
|
||||
username: Some("bantest".to_string()),
|
||||
display_name: Some("Ban Test User".to_string()),
|
||||
};
|
||||
|
||||
let user = UserDAO::insert_user(&pool, &new_user)
|
||||
.await
|
||||
.expect("Failed to insert user");
|
||||
|
||||
// Set ban status
|
||||
UserDAO::set_ban_status(&pool, user.id, is_banned)
|
||||
.await
|
||||
.expect("Failed to set ban status");
|
||||
|
||||
// Verify ban status
|
||||
let updated_user = UserDAO::find_by_id(&pool, user.id)
|
||||
.await
|
||||
.expect("Failed to find user")
|
||||
.expect("User should exist");
|
||||
|
||||
assert_eq!(updated_user.is_banned, is_banned);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_user() {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
let new_user = NewUser {
|
||||
telegram_id: 55555.into(),
|
||||
username: Some("oldname".to_string()),
|
||||
display_name: Some("Old Name".to_string()),
|
||||
};
|
||||
|
||||
let mut user = UserDAO::insert_user(&pool, &new_user)
|
||||
.await
|
||||
.expect("Failed to insert user");
|
||||
|
||||
// Update user information
|
||||
user.username = Some("newname".to_string());
|
||||
user.display_name = Some("New Name".to_string());
|
||||
user.is_banned = true;
|
||||
|
||||
let updated_user = UserDAO::update_user(&pool, &user)
|
||||
.await
|
||||
.expect("Failed to update user");
|
||||
|
||||
assert_eq!(updated_user.username, Some("newname".to_string()));
|
||||
assert_eq!(updated_user.display_name, Some("New Name".to_string()));
|
||||
assert_eq!(updated_user.is_banned, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_user() {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
let new_user = NewUser {
|
||||
telegram_id: 77777.into(),
|
||||
username: Some("deleteme".to_string()),
|
||||
display_name: Some("Delete Me".to_string()),
|
||||
};
|
||||
|
||||
let user = UserDAO::insert_user(&pool, &new_user)
|
||||
.await
|
||||
.expect("Failed to insert user");
|
||||
|
||||
// Delete user
|
||||
UserDAO::delete_user(&pool, user.id)
|
||||
.await
|
||||
.expect("Failed to delete user");
|
||||
|
||||
// Verify user is gone
|
||||
let not_found = UserDAO::find_by_id(&pool, user.id)
|
||||
.await
|
||||
.expect("Database operation should succeed");
|
||||
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_nonexistent_user() {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
// Try to find a user that doesn't exist
|
||||
let not_found = UserDAO::find_by_id(&pool, UserId::new(99999))
|
||||
.await
|
||||
.expect("Database operation should succeed");
|
||||
|
||||
assert!(not_found.is_none());
|
||||
|
||||
let not_found_by_telegram = UserDAO::find_by_telegram_id(&pool, 88888.into())
|
||||
.await
|
||||
.expect("Database operation should succeed");
|
||||
|
||||
assert!(not_found_by_telegram.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
pub mod currency_type;
|
||||
pub mod dao;
|
||||
pub mod listing_id;
|
||||
pub mod models;
|
||||
pub mod money_amount;
|
||||
pub mod telegram_user_id;
|
||||
pub mod user_id;
|
||||
pub mod users;
|
||||
|
||||
// Re-export all models for easy access
|
||||
pub use currency_type::*;
|
||||
pub use dao::*;
|
||||
pub use listing_id::*;
|
||||
pub use models::*;
|
||||
pub use money_amount::*;
|
||||
pub use telegram_user_id::*;
|
||||
pub use user_id::*;
|
||||
pub use users::UserRepository;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod bid;
|
||||
pub mod listing;
|
||||
pub mod listing_media;
|
||||
pub mod listing_type;
|
||||
pub mod new_listing;
|
||||
pub mod proxy_bid;
|
||||
pub mod user;
|
||||
pub mod user_settings;
|
||||
|
||||
134
src/db/models/new_listing.rs
Normal file
134
src/db/models/new_listing.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use crate::db::{ListingType, MoneyAmount, UserId};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// New listing data for insertion
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewListing {
|
||||
pub base: NewListingBase,
|
||||
pub fields: NewListingFields,
|
||||
}
|
||||
|
||||
impl NewListing {
|
||||
pub fn listing_type(&self) -> ListingType {
|
||||
match &self.fields {
|
||||
NewListingFields::BasicAuction { .. } => ListingType::BasicAuction,
|
||||
NewListingFields::MultiSlotAuction { .. } => ListingType::MultiSlotAuction,
|
||||
NewListingFields::FixedPriceListing { .. } => ListingType::FixedPriceListing,
|
||||
NewListingFields::BlindAuction { .. } => ListingType::BlindAuction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewListingBase {
|
||||
pub seller_id: UserId,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NewListingFields {
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
impl NewListingBase {
|
||||
pub fn new(
|
||||
seller_id: UserId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starts_at: DateTime<Utc>,
|
||||
ends_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
seller_id,
|
||||
title,
|
||||
description,
|
||||
starts_at,
|
||||
ends_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new basic auction listing
|
||||
pub fn new_basic_auction(
|
||||
self,
|
||||
starting_bid: MoneyAmount,
|
||||
buy_now_price: Option<MoneyAmount>,
|
||||
min_increment: MoneyAmount,
|
||||
anti_snipe_minutes: Option<i32>,
|
||||
) -> NewListing {
|
||||
NewListing {
|
||||
base: self,
|
||||
fields: NewListingFields::BasicAuction {
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
anti_snipe_minutes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new multi-slot auction listing
|
||||
pub fn new_multi_slot_auction(
|
||||
self,
|
||||
starting_bid: MoneyAmount,
|
||||
buy_now_price: MoneyAmount,
|
||||
min_increment: Option<MoneyAmount>,
|
||||
slots_available: i32,
|
||||
anti_snipe_minutes: i32,
|
||||
) -> NewListing {
|
||||
NewListing {
|
||||
base: self,
|
||||
fields: NewListingFields::MultiSlotAuction {
|
||||
starting_bid,
|
||||
buy_now_price,
|
||||
min_increment,
|
||||
slots_available,
|
||||
anti_snipe_minutes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new fixed price listing
|
||||
pub fn new_fixed_price_listing(
|
||||
self,
|
||||
buy_now_price: MoneyAmount,
|
||||
slots_available: i32,
|
||||
) -> NewListing {
|
||||
NewListing {
|
||||
base: self,
|
||||
fields: NewListingFields::FixedPriceListing {
|
||||
buy_now_price,
|
||||
slots_available,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new blind auction listing
|
||||
pub fn new_blind_auction(self, starting_bid: MoneyAmount) -> NewListing {
|
||||
NewListing {
|
||||
base: self,
|
||||
fields: NewListingFields::BlindAuction { starting_bid },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::db::{TelegramUserId, UserId};
|
||||
|
||||
/// Core user information
|
||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub telegram_id: i64,
|
||||
pub id: UserId,
|
||||
pub telegram_id: TelegramUserId,
|
||||
pub username: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
// SQLite stores booleans as INTEGER (0/1), sqlx FromRow handles the conversion automatically
|
||||
pub is_banned: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -17,7 +20,7 @@ pub struct User {
|
||||
/// New user data for insertion
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewUser {
|
||||
pub telegram_id: i64,
|
||||
pub telegram_id: TelegramUserId,
|
||||
pub username: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ use sqlx::{
|
||||
use std::ops::{Add, Sub};
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::CurrencyType;
|
||||
|
||||
/// Newtype wrapper for monetary amounts stored as integer cents
|
||||
/// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -119,6 +117,7 @@ impl<'r> Decode<'r, Sqlite> for MoneyAmount {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::CurrencyType;
|
||||
use rstest::rstest;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
|
||||
79
src/db/telegram_user_id.rs
Normal file
79
src/db/telegram_user_id.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! TelegramUserId
|
||||
//! newtype for type-safe user identification
|
||||
//!
|
||||
//! This newtype prevents accidentally mixing up user IDs with other ID types
|
||||
//! while maintaining compatibility with the database layer through SQLx traits.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{
|
||||
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
|
||||
};
|
||||
use std::fmt;
|
||||
|
||||
/// Type-safe wrapper for user IDs
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct TelegramUserId(teloxide::types::UserId);
|
||||
|
||||
impl TelegramUserId {
|
||||
/// Create a new TelegramUserId
|
||||
/// from an i64
|
||||
pub fn new(id: teloxide::types::UserId) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Get the inner i64 value
|
||||
pub fn get(&self) -> teloxide::types::UserId {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<teloxide::types::UserId> for TelegramUserId {
|
||||
fn from(id: teloxide::types::UserId) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u64> for TelegramUserId {
|
||||
fn from(id: u64) -> Self {
|
||||
Self(teloxide::types::UserId(id))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TelegramUserId> for teloxide::types::UserId {
|
||||
fn from(user_id: TelegramUserId) -> Self {
|
||||
user_id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TelegramUserId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// SQLx implementations for database compatibility
|
||||
impl Type<Sqlite> for TelegramUserId {
|
||||
fn type_info() -> SqliteTypeInfo {
|
||||
<i64 as Type<Sqlite>>::type_info()
|
||||
}
|
||||
|
||||
fn compatible(ty: &SqliteTypeInfo) -> bool {
|
||||
<i64 as Type<Sqlite>>::compatible(ty)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'q> Encode<'q, Sqlite> for TelegramUserId {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
||||
) -> Result<IsNull, BoxDynError> {
|
||||
<i64 as Encode<'q, Sqlite>>::encode(self.0 .0 as i64, args)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Decode<'r, Sqlite> for TelegramUserId {
|
||||
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
||||
Ok(Self(teloxide::types::UserId(id as u64)))
|
||||
}
|
||||
}
|
||||
101
src/db/users.rs
101
src/db/users.rs
@@ -1,101 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::{NewUser, User, UserSettings};
|
||||
|
||||
/// User database operations
|
||||
pub struct UserRepository;
|
||||
|
||||
impl UserRepository {
|
||||
/// Create a new user in the database
|
||||
pub async fn create_user(pool: &SqlitePool, new_user: &NewUser) -> Result<User> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (telegram_id, username, display_name, is_banned, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, FALSE, datetime('now'), datetime('now'))
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(new_user.telegram_id)
|
||||
.bind(&new_user.username)
|
||||
.bind(&new_user.display_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Find user by Telegram ID
|
||||
pub async fn find_by_telegram_id(pool: &SqlitePool, telegram_id: i64) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE telegram_id = ?1")
|
||||
.bind(telegram_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Find user by internal ID
|
||||
pub async fn find_by_id(pool: &SqlitePool, user_id: i64) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Get or create user (common pattern for Telegram bots)
|
||||
pub async fn get_or_create_user(
|
||||
pool: &SqlitePool,
|
||||
telegram_id: i64,
|
||||
username: Option<String>,
|
||||
display_name: Option<String>,
|
||||
) -> Result<User> {
|
||||
// Try to find existing user
|
||||
if let Some(user) = Self::find_by_telegram_id(pool, telegram_id).await? {
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
let new_user = NewUser {
|
||||
telegram_id,
|
||||
username,
|
||||
display_name,
|
||||
};
|
||||
|
||||
let user = Self::create_user(pool, &new_user).await?;
|
||||
|
||||
// Create default user settings
|
||||
Self::create_default_settings(pool, user.id).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Create default user settings
|
||||
pub async fn create_default_settings(pool: &SqlitePool, user_id: i64) -> Result<UserSettings> {
|
||||
let settings = sqlx::query_as::<_, UserSettings>(
|
||||
r#"
|
||||
INSERT INTO user_settings (user_id, language_code, notify_outbid, notify_won, created_at, updated_at)
|
||||
VALUES (?1, 'en', TRUE, TRUE, datetime('now'), datetime('now'))
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Update user ban status
|
||||
pub async fn set_ban_status(pool: &SqlitePool, user_id: i64, is_banned: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET is_banned = ?1, updated_at = datetime('now') WHERE id = ?2")
|
||||
.bind(is_banned)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user