- Refactor new_listing from single file to modular structure - Add handler factory pattern for state management - Improve keyboard utilities and validations - Update database models for bid, listing, and user systems - Add new types: listing_duration, user_row_id - Remove deprecated user_id type - Update Docker configuration - Enhance test utilities and message handling
328 lines
10 KiB
Rust
328 lines
10 KiB
Rust
//! User Data Access Object (DAO)
|
|
//!
|
|
//! Provides encapsulated CRUD operations for User entities
|
|
|
|
use anyhow::Result;
|
|
use sqlx::SqlitePool;
|
|
|
|
use crate::db::{
|
|
models::user::{NewUser, User},
|
|
TelegramUserId, UserRowId,
|
|
};
|
|
|
|
/// 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: UserRowId) -> 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: impl Into<TelegramUserId>,
|
|
) -> Result<Option<User>> {
|
|
let telegram_id = telegram_id.into();
|
|
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: UserRowId,
|
|
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: UserRowId) -> 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;
|
|
use teloxide::types::UserId;
|
|
|
|
/// 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, UserId(12345))
|
|
.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, UserRowId::new(99999))
|
|
.await
|
|
.expect("Database operation should succeed");
|
|
|
|
assert!(not_found.is_none());
|
|
|
|
let not_found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(88888))
|
|
.await
|
|
.expect("Database operation should succeed");
|
|
|
|
assert!(not_found_by_telegram.is_none());
|
|
}
|
|
}
|