Files
pawctioneer-bot/src/db/dao/user_dao.rs
Dylan Knutson 34de9b6d59 Major refactor: restructure new listing command and update data models
- 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
2025-08-29 10:21:39 -07:00

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());
}
}