use std::collections::HashSet; use anyhow::{Result, anyhow}; use chrono::{Duration, Utc}; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Statement, TransactionTrait, sea_query::OnConflict, }; use uuid::Uuid; use crate::{ entity::{ attachments, channels, direct_messages, guild_members, guilds, invites, messages, sessions, soundboard_sounds, users, }, models::{ Attachment, BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, SoundboardSound, User, }, }; pub const CHANNEL_KIND_TEXT: &str = "text"; pub const CHANNEL_KIND_VOICE: &str = "voice"; pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result { let count = users::Entity::find_by_id(user_id).count(db).await?; Ok(count > 0) } pub async fn create_session( db: &DatabaseConnection, session_id: &str, user_id: Uuid, expires_at: chrono::DateTime, user_agent_hash: Option, ip_hash: Option, ) -> Result<()> { sessions::Entity::insert(sessions::ActiveModel { id: Set(session_id.to_string()), user_id: Set(user_id), expires_at: Set(expires_at), created_at: Set(Utc::now().fixed_offset()), last_seen_at: Set(Utc::now().fixed_offset()), revoked_at: Set(None), user_agent_hash: Set(user_agent_hash), ip_hash: Set(ip_hash), }) .exec(db) .await?; Ok(()) } pub async fn touch_active_session( db: &DatabaseConnection, session_id: &str, ) -> Result> { let session = sessions::Entity::find_by_id(session_id.to_string()) .one(db) .await?; let Some(session) = session else { return Ok(None); }; if session.revoked_at.is_some() || session.expires_at <= Utc::now().fixed_offset() { return Ok(None); } let user_id = session.user_id; sessions::Entity::update_many() .col_expr( sessions::Column::LastSeenAt, sea_orm::sea_query::Expr::value(Utc::now().fixed_offset()), ) .filter(sessions::Column::Id.eq(session_id.to_string())) .exec(db) .await?; Ok(Some(user_id)) } pub async fn revoke_session(db: &DatabaseConnection, session_id: &str) -> Result<()> { if let Some(session) = sessions::Entity::find_by_id(session_id.to_string()) .one(db) .await? { sessions::Entity::update_many() .col_expr( sessions::Column::RevokedAt, sea_orm::sea_query::Expr::value(Some(Utc::now().fixed_offset())), ) .filter(sessions::Column::Id.eq(session.id)) .exec(db) .await?; } Ok(()) } pub async fn cleanup_sessions(db: &DatabaseConnection) -> Result<()> { sessions::Entity::delete_many() .filter( Condition::any() .add(sessions::Column::ExpiresAt.lte(Utc::now().fixed_offset())) .add(sessions::Column::RevokedAt.is_not_null()), ) .exec(db) .await?; Ok(()) } pub async fn upsert_user_from_oidc( db: &DatabaseConnection, oidc_sub: &str, email: Option<&str>, display_name: &str, avatar_url: Option<&str>, ) -> Result { let user = users::Entity::insert(users::ActiveModel { id: Set(Uuid::new_v4()), oidc_sub: Set(oidc_sub.to_string()), email: Set(email.map(ToString::to_string)), display_name: Set(display_name.to_string()), avatar_url: Set(avatar_url.map(ToString::to_string)), ..Default::default() }) .on_conflict( OnConflict::column(users::Column::OidcSub) .update_columns([ users::Column::Email, users::Column::DisplayName, users::Column::AvatarUrl, ]) .value( users::Column::UpdatedAt, sea_orm::sea_query::Expr::current_timestamp(), ) .to_owned(), ) .exec_with_returning(db) .await?; Ok(map_user(user)) } pub async fn get_user_by_id(db: &DatabaseConnection, user_id: Uuid) -> Result> { let row = users::Entity::find_by_id(user_id).one(db).await?; Ok(row.map(map_user)) } pub async fn list_guild_members(db: &DatabaseConnection, guild_id: Uuid) -> Result> { let rows = guild_members::Entity::find() .filter(guild_members::Column::GuildId.eq(guild_id)) .find_also_related(users::Entity) .order_by_asc(guild_members::Column::CreatedAt) .all(db) .await?; Ok(rows .into_iter() .filter_map(|(_, user)| user) .map(|u| BasicUser { id: u.id, display_name: u.display_name, avatar_url: u.avatar_url, }) .collect()) } pub async fn list_guild_member_ids(db: &DatabaseConnection, guild_id: Uuid) -> Result> { let rows = guild_members::Entity::find() .filter(guild_members::Column::GuildId.eq(guild_id)) .all(db) .await?; Ok(rows.into_iter().map(|m| m.user_id).collect()) } pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result> { let rows = guild_members::Entity::find() .filter(guild_members::Column::UserId.eq(user_id)) .find_also_related(guilds::Entity) .all(db) .await?; Ok(rows .into_iter() .filter_map(|(_, guild)| guild.map(map_guild)) .collect()) } pub async fn list_visible_user_ids(db: &DatabaseConnection, user_id: Uuid) -> Result> { let guild_ids: Vec = guild_members::Entity::find() .filter(guild_members::Column::UserId.eq(user_id)) .select_only() .column(guild_members::Column::GuildId) .into_tuple() .all(db) .await?; let mut visible = HashSet::from([user_id]); if !guild_ids.is_empty() { let guild_users = guild_members::Entity::find() .filter(guild_members::Column::GuildId.is_in(guild_ids)) .all(db) .await?; visible.extend(guild_users.into_iter().map(|membership| membership.user_id)); } let dm_rows = direct_messages::Entity::find() .filter( Condition::any() .add(direct_messages::Column::SenderUserId.eq(user_id)) .add(direct_messages::Column::RecipientUserId.eq(user_id)), ) .all(db) .await?; for row in dm_rows { if row.sender_user_id == user_id { visible.insert(row.recipient_user_id); } else { visible.insert(row.sender_user_id); } } Ok(visible.into_iter().collect()) } pub async fn create_guild( db: &DatabaseConnection, owner_user_id: Uuid, name: &str, ) -> Result { let txn = db.begin().await?; let guild = guilds::Entity::insert(guilds::ActiveModel { id: Set(Uuid::new_v4()), name: Set(name.to_string()), owner_user_id: Set(owner_user_id), ..Default::default() }) .exec_with_returning(&txn) .await?; guild_members::Entity::insert(guild_members::ActiveModel { guild_id: Set(guild.id), user_id: Set(owner_user_id), ..Default::default() }) .on_conflict( OnConflict::columns([ guild_members::Column::GuildId, guild_members::Column::UserId, ]) .do_nothing() .to_owned(), ) .exec(&txn) .await?; txn.commit().await?; Ok(map_guild(guild)) } pub async fn is_guild_owner( db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid, ) -> Result { let guild = guilds::Entity::find_by_id(guild_id).one(db).await?; match guild { Some(g) => Ok(g.owner_user_id == user_id), None => Ok(false), } } pub async fn create_invite( db: &DatabaseConnection, guild_id: Uuid, created_by_user_id: Uuid, max_uses: Option, expires_in_hours: Option, ) -> Result { let expires_at = expires_in_hours.map(|h| (Utc::now() + Duration::hours(h)).fixed_offset()); for _ in 0..8 { let code = generate_invite_code(); let insert = invites::Entity::insert(invites::ActiveModel { code: Set(code.clone()), guild_id: Set(guild_id), created_by_user_id: Set(created_by_user_id), expires_at: Set(expires_at), max_uses: Set(max_uses), ..Default::default() }) .exec(db) .await; match insert { Ok(_) => { let row = invites::Entity::find_by_id(code) .one(db) .await? .ok_or_else(|| anyhow!("created invite missing"))?; return Ok(map_invite(row)); } Err(err) if err.to_string().contains("duplicate key") => continue, Err(err) => return Err(err.into()), } } Err(anyhow!("failed to generate unique invite code")) } pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result { let txn = db.begin().await?; let invite = invites::Entity::find() .from_raw_sql(Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, r#"SELECT * FROM invites WHERE code = $1 FOR UPDATE"#, [code.into()], )) .one(&txn) .await? .ok_or_else(|| anyhow!("invite not found"))?; validate_invite(&invite)?; let guild_id = invite.guild_id; let already_member = guild_members::Entity::find() .filter(guild_members::Column::GuildId.eq(invite.guild_id)) .filter(guild_members::Column::UserId.eq(user_id)) .one(&txn) .await? .is_some(); guild_members::Entity::insert(guild_members::ActiveModel { guild_id: Set(invite.guild_id), user_id: Set(user_id), ..Default::default() }) .on_conflict( OnConflict::columns([ guild_members::Column::GuildId, guild_members::Column::UserId, ]) .do_nothing() .to_owned(), ) .exec(&txn) .await?; if !already_member { increment_invite_use_count(&txn, invite).await?; } let guild = guilds::Entity::find_by_id(guild_id) .one(&txn) .await? .ok_or_else(|| anyhow!("guild for invite not found"))?; txn.commit().await?; Ok(map_guild(guild)) } pub async fn create_channel( db: &DatabaseConnection, guild_id: Uuid, name: &str, kind: &str, ) -> Result { let channel = channels::Entity::insert(channels::ActiveModel { id: Set(Uuid::new_v4()), guild_id: Set(guild_id), name: Set(name.to_string()), kind: Set(kind.to_string()), ..Default::default() }) .exec_with_returning(db) .await?; Ok(map_channel(channel)) } pub async fn get_channel_by_id( db: &DatabaseConnection, channel_id: Uuid, ) -> Result> { let row = channels::Entity::find_by_id(channel_id).one(db).await?; Ok(row.map(map_channel)) } pub async fn list_channels_for_guild( db: &DatabaseConnection, guild_id: Uuid, ) -> Result> { let channels = channels::Entity::find() .filter(channels::Column::GuildId.eq(guild_id)) .order_by_asc(channels::Column::CreatedAt) .all(db) .await?; Ok(channels.into_iter().map(map_channel).collect()) } pub async fn list_voice_channels_for_guild( db: &DatabaseConnection, guild_id: Uuid, ) -> Result> { let channels = channels::Entity::find() .filter(channels::Column::GuildId.eq(guild_id)) .filter(channels::Column::Kind.eq(CHANNEL_KIND_VOICE)) .order_by_asc(channels::Column::CreatedAt) .all(db) .await?; Ok(channels.into_iter().map(map_channel).collect()) } pub async fn is_member_of_guild( db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid, ) -> Result { let count = guild_members::Entity::find() .filter(guild_members::Column::GuildId.eq(guild_id)) .filter(guild_members::Column::UserId.eq(user_id)) .count(db) .await?; Ok(count > 0) } pub async fn guild_id_for_channel( db: &DatabaseConnection, channel_id: Uuid, ) -> Result> { let guild_id = channels::Entity::find_by_id(channel_id) .select_only() .column(channels::Column::GuildId) .into_tuple::() .one(db) .await?; Ok(guild_id) } pub async fn create_message( db: &DatabaseConnection, channel_id: Uuid, author_user_id: Uuid, body: &str, ) -> Result { let model = messages::Entity::insert(messages::ActiveModel { id: Set(Uuid::new_v4()), channel_id: Set(channel_id), author_user_id: Set(author_user_id), body: Set(body.to_string()), ..Default::default() }) .exec_with_returning(db) .await?; let user = users::Entity::find_by_id(author_user_id) .one(db) .await? .ok_or_else(|| anyhow!("author not found"))?; Ok(MessageWithAuthor { id: model.id, channel_id: model.channel_id, author_user_id: model.author_user_id, author_display_name: user.display_name, body: model.body, attachments: Vec::new(), created_at: model.created_at, }) } pub async fn create_message_with_attachment( db: &DatabaseConnection, channel_id: Uuid, author_user_id: Uuid, body: &str, object_key: &str, media_url: &str, mime_type: &str, size_bytes: i64, original_filename: &str, ) -> Result { let txn = db.begin().await?; let model = messages::Entity::insert(messages::ActiveModel { id: Set(Uuid::new_v4()), channel_id: Set(channel_id), author_user_id: Set(author_user_id), body: Set(body.to_string()), ..Default::default() }) .exec_with_returning(&txn) .await?; let attachment = attachments::Entity::insert(attachments::ActiveModel { id: Set(Uuid::new_v4()), channel_message_id: Set(Some(model.id)), direct_message_id: Set(None), uploader_user_id: Set(author_user_id), object_key: Set(object_key.to_string()), media_url: Set(media_url.to_string()), mime_type: Set(mime_type.to_string()), size_bytes: Set(size_bytes), original_filename: Set(original_filename.to_string()), ..Default::default() }) .exec_with_returning(&txn) .await?; let user = users::Entity::find_by_id(author_user_id) .one(&txn) .await? .ok_or_else(|| anyhow!("author not found"))?; txn.commit().await?; Ok(MessageWithAuthor { id: model.id, channel_id: model.channel_id, author_user_id: model.author_user_id, author_display_name: user.display_name, body: model.body, attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } pub async fn list_messages( db: &DatabaseConnection, channel_id: Uuid, limit: i64, ) -> Result> { let rows = messages::Entity::find() .filter(messages::Column::ChannelId.eq(channel_id)) .find_also_related(users::Entity) .order_by_desc(messages::Column::CreatedAt) .limit(limit as u64) .all(db) .await?; let attachment_map = list_attachments_for_channel_messages(db, rows.iter().map(|(msg, _)| msg.id).collect()) .await?; Ok(rows .into_iter() .map(|(msg, user)| { let author_display_name = user .map(|u| u.display_name) .unwrap_or_else(|| "Unknown User".to_string()); MessageWithAuthor { id: msg.id, channel_id: msg.channel_id, author_user_id: msg.author_user_id, author_display_name, body: msg.body, attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, } }) .collect()) } pub async fn create_direct_message( db: &DatabaseConnection, sender_user_id: Uuid, recipient_user_id: Uuid, body: &str, ) -> Result { let model = direct_messages::Entity::insert(direct_messages::ActiveModel { id: Set(Uuid::new_v4()), sender_user_id: Set(sender_user_id), recipient_user_id: Set(recipient_user_id), body: Set(body.to_string()), ..Default::default() }) .exec_with_returning(db) .await?; let user = users::Entity::find_by_id(sender_user_id) .one(db) .await? .ok_or_else(|| anyhow!("sender not found"))?; Ok(DmMessageWithAuthor { id: model.id, author_user_id: model.sender_user_id, recipient_user_id: model.recipient_user_id, author_display_name: user.display_name, body: model.body, attachments: Vec::new(), created_at: model.created_at, }) } pub async fn create_direct_message_with_attachment( db: &DatabaseConnection, sender_user_id: Uuid, recipient_user_id: Uuid, body: &str, object_key: &str, media_url: &str, mime_type: &str, size_bytes: i64, original_filename: &str, ) -> Result { let txn = db.begin().await?; let model = direct_messages::Entity::insert(direct_messages::ActiveModel { id: Set(Uuid::new_v4()), sender_user_id: Set(sender_user_id), recipient_user_id: Set(recipient_user_id), body: Set(body.to_string()), ..Default::default() }) .exec_with_returning(&txn) .await?; let attachment = attachments::Entity::insert(attachments::ActiveModel { id: Set(Uuid::new_v4()), channel_message_id: Set(None), direct_message_id: Set(Some(model.id)), uploader_user_id: Set(sender_user_id), object_key: Set(object_key.to_string()), media_url: Set(media_url.to_string()), mime_type: Set(mime_type.to_string()), size_bytes: Set(size_bytes), original_filename: Set(original_filename.to_string()), ..Default::default() }) .exec_with_returning(&txn) .await?; let user = users::Entity::find_by_id(sender_user_id) .one(&txn) .await? .ok_or_else(|| anyhow!("sender not found"))?; txn.commit().await?; Ok(DmMessageWithAuthor { id: model.id, author_user_id: model.sender_user_id, recipient_user_id: model.recipient_user_id, author_display_name: user.display_name, body: model.body, attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } pub async fn list_direct_messages( db: &DatabaseConnection, current_user_id: Uuid, other_user_id: Uuid, limit: i64, ) -> Result> { let rows = direct_messages::Entity::find() .filter( Condition::any() .add( Condition::all() .add(direct_messages::Column::SenderUserId.eq(current_user_id)) .add(direct_messages::Column::RecipientUserId.eq(other_user_id)), ) .add( Condition::all() .add(direct_messages::Column::SenderUserId.eq(other_user_id)) .add(direct_messages::Column::RecipientUserId.eq(current_user_id)), ), ) .order_by_desc(direct_messages::Column::CreatedAt) .limit(limit as u64) .all(db) .await?; let mut author_ids = rows.iter().map(|m| m.sender_user_id).collect::>(); author_ids.sort_unstable(); author_ids.dedup(); let authors = users::Entity::find() .filter(users::Column::Id.is_in(author_ids)) .all(db) .await?; let author_names: std::collections::HashMap = authors .into_iter() .map(|u| (u.id, u.display_name)) .collect(); let attachment_map = list_attachments_for_direct_messages(db, rows.iter().map(|msg| msg.id).collect()).await?; Ok(rows .into_iter() .map(|msg| DmMessageWithAuthor { id: msg.id, author_user_id: msg.sender_user_id, recipient_user_id: msg.recipient_user_id, author_display_name: author_names .get(&msg.sender_user_id) .cloned() .unwrap_or_else(|| "Unknown User".to_string()), body: msg.body, attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, }) .collect()) } async fn list_attachments_for_channel_messages( db: &DatabaseConnection, message_ids: Vec, ) -> Result>> { if message_ids.is_empty() { return Ok(std::collections::HashMap::new()); } let rows = attachments::Entity::find() .filter(attachments::Column::ChannelMessageId.is_in(message_ids)) .order_by_asc(attachments::Column::CreatedAt) .all(db) .await?; let mut grouped = std::collections::HashMap::>::new(); for row in rows { if let Some(message_id) = row.channel_message_id { grouped .entry(message_id) .or_default() .push(map_attachment(row)); } } Ok(grouped) } async fn list_attachments_for_direct_messages( db: &DatabaseConnection, message_ids: Vec, ) -> Result>> { if message_ids.is_empty() { return Ok(std::collections::HashMap::new()); } let rows = attachments::Entity::find() .filter(attachments::Column::DirectMessageId.is_in(message_ids)) .order_by_asc(attachments::Column::CreatedAt) .all(db) .await?; let mut grouped = std::collections::HashMap::>::new(); for row in rows { if let Some(message_id) = row.direct_message_id { grouped .entry(message_id) .or_default() .push(map_attachment(row)); } } Ok(grouped) } pub async fn list_dm_conversations( db: &DatabaseConnection, current_user_id: Uuid, ) -> Result> { let rows = direct_messages::Entity::find() .filter( Condition::any() .add(direct_messages::Column::SenderUserId.eq(current_user_id)) .add(direct_messages::Column::RecipientUserId.eq(current_user_id)), ) .order_by_desc(direct_messages::Column::CreatedAt) .all(db) .await?; let mut latest_by_peer = std::collections::HashMap::>::new(); for row in rows { let peer_id = if row.sender_user_id == current_user_id { row.recipient_user_id } else { row.sender_user_id }; latest_by_peer.entry(peer_id).or_insert(row.created_at); } if latest_by_peer.is_empty() { return Ok(Vec::new()); } let peer_ids: Vec = latest_by_peer.keys().copied().collect(); let peers = users::Entity::find() .filter(users::Column::Id.is_in(peer_ids)) .all(db) .await?; let mut conversations: Vec = peers .into_iter() .filter_map(|u| { let last = latest_by_peer.get(&u.id)?; Some(DmConversation { user_id: u.id, display_name: u.display_name, avatar_url: u.avatar_url, last_message_at: *last, }) }) .collect(); conversations.sort_by(|a, b| b.last_message_at.cmp(&a.last_message_at)); Ok(conversations) } fn map_user(model: users::Model) -> User { User { id: model.id, oidc_sub: model.oidc_sub, email: model.email, display_name: model.display_name, avatar_url: model.avatar_url, created_at: model.created_at, updated_at: model.updated_at, } } fn map_guild(model: guilds::Model) -> Guild { Guild { id: model.id, name: model.name, owner_user_id: model.owner_user_id, created_at: model.created_at, } } fn map_attachment(model: attachments::Model) -> Attachment { Attachment { id: model.id, media_url: model.media_url, mime_type: model.mime_type, size_bytes: model.size_bytes, original_filename: model.original_filename, } } fn map_channel(model: channels::Model) -> Channel { Channel { id: model.id, guild_id: model.guild_id, name: model.name, kind: model.kind, created_at: model.created_at, } } fn map_invite(model: invites::Model) -> Invite { Invite { code: model.code, guild_id: model.guild_id, created_by_user_id: model.created_by_user_id, created_at: model.created_at, expires_at: model.expires_at, max_uses: model.max_uses, use_count: model.use_count, } } fn generate_invite_code() -> String { Uuid::new_v4().simple().to_string()[..10].to_uppercase() } fn validate_invite(invite: &invites::Model) -> Result<()> { if let Some(expires_at) = invite.expires_at { if expires_at < Utc::now().fixed_offset() { return Err(anyhow!("invite expired")); } } if let Some(max_uses) = invite.max_uses { if invite.use_count >= max_uses { return Err(anyhow!("invite exhausted")); } } Ok(()) } async fn increment_invite_use_count( txn: &DatabaseTransaction, invite: invites::Model, ) -> Result<()> { let next_count = invite.use_count + 1; let mut active: invites::ActiveModel = invite.into(); active.use_count = Set(next_count); active.update(txn).await?; Ok(()) } pub async fn list_sounds(db: &DatabaseConnection, guild_id: Uuid) -> Result> { let rows = soundboard_sounds::Entity::find() .filter(soundboard_sounds::Column::GuildId.eq(guild_id)) .order_by_asc(soundboard_sounds::Column::CreatedAt) .all(db) .await?; Ok(rows.into_iter().map(map_sound).collect()) } pub async fn create_sound( db: &DatabaseConnection, guild_id: Uuid, created_by_user_id: Uuid, name: &str, icon: &str, object_key: &str, media_url: &str, mime_type: &str, size_bytes: i64, ) -> Result { let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel { id: Set(Uuid::new_v4()), guild_id: Set(guild_id), created_by_user_id: Set(created_by_user_id), name: Set(name.to_string()), icon: Set(icon.to_string()), object_key: Set(Some(object_key.to_string())), media_url: Set(media_url.to_string()), mime_type: Set(Some(mime_type.to_string())), size_bytes: Set(Some(size_bytes)), // Keep the legacy column populated until every deployment has applied // the nullable migration and old fallback paths are fully removed. file_path: Set(Some(media_url.to_string())), ..Default::default() }) .exec_with_returning(db) .await?; Ok(map_sound(model)) } pub async fn get_sound_by_id(db: &DatabaseConnection, id: Uuid) -> Result> { let row = soundboard_sounds::Entity::find_by_id(id).one(db).await?; Ok(row.map(map_sound)) } pub async fn get_sound_object_key(db: &DatabaseConnection, id: Uuid) -> Result> { let row = soundboard_sounds::Entity::find_by_id(id).one(db).await?; Ok(row.and_then(|sound| sound.object_key)) } pub async fn delete_sound(db: &DatabaseConnection, id: Uuid) -> Result<()> { soundboard_sounds::Entity::delete_by_id(id).exec(db).await?; Ok(()) } fn map_sound(model: soundboard_sounds::Model) -> SoundboardSound { SoundboardSound { id: model.id, guild_id: model.guild_id, name: model.name, icon: model.icon, media_url: model.media_url, mime_type: model.mime_type, size_bytes: model.size_bytes, created_by_user_id: model.created_by_user_id, created_at: model.created_at, } } #[cfg(test)] mod tests { use super::{create_guild, create_sound, join_invite, list_visible_user_ids, validate_invite}; use crate::entity::{direct_messages, guild_members, guilds, invites, soundboard_sounds}; use chrono::{Duration, Utc}; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Value}; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; #[tokio::test] async fn visible_users_include_self_shared_guilds_and_dm_partners() { let current_user_id = Uuid::new_v4(); let guild_a = Uuid::new_v4(); let guild_b = Uuid::new_v4(); let guild_peer = Uuid::new_v4(); let shared_dm_peer = Uuid::new_v4(); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![ BTreeMap::from([("guild_id".to_string(), Value::from(guild_a))]), BTreeMap::from([("guild_id".to_string(), Value::from(guild_b))]), ]]) .append_query_results([vec![ guild_members::Model { guild_id: guild_a, user_id: current_user_id, created_at: Utc::now().fixed_offset(), }, guild_members::Model { guild_id: guild_b, user_id: current_user_id, created_at: Utc::now().fixed_offset(), }, guild_members::Model { guild_id: guild_a, user_id: guild_peer, created_at: Utc::now().fixed_offset(), }, ]]) .append_query_results([vec![ direct_messages::Model { id: Uuid::new_v4(), sender_user_id: current_user_id, recipient_user_id: shared_dm_peer, body: "hello".to_string(), created_at: Utc::now().fixed_offset(), }, direct_messages::Model { id: Uuid::new_v4(), sender_user_id: shared_dm_peer, recipient_user_id: current_user_id, body: "hi".to_string(), created_at: Utc::now().fixed_offset(), }, ]]) .into_connection(); let visible = list_visible_user_ids(&db, current_user_id).await.unwrap(); let visible: BTreeSet<_> = visible.into_iter().collect(); assert_eq!( visible, BTreeSet::from([current_user_id, guild_peer, shared_dm_peer]) ); } #[tokio::test] async fn visible_users_returns_self_when_no_relationships_exist() { let current_user_id = Uuid::new_v4(); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::>::new()]) .append_query_results([Vec::::new()]) .into_connection(); let visible = list_visible_user_ids(&db, current_user_id).await.unwrap(); assert_eq!(visible, vec![current_user_id]); } #[test] fn validate_invite_rejects_expired_invites() { let invite = invites::Model { code: "expired".to_string(), guild_id: Uuid::new_v4(), created_by_user_id: Uuid::new_v4(), created_at: Utc::now().fixed_offset(), expires_at: Some((Utc::now() - Duration::minutes(1)).fixed_offset()), max_uses: Some(5), use_count: 0, }; let err = validate_invite(&invite).unwrap_err(); assert!(err.to_string().contains("invite expired")); } #[test] fn validate_invite_rejects_exhausted_invites() { let invite = invites::Model { code: "used".to_string(), guild_id: Uuid::new_v4(), created_by_user_id: Uuid::new_v4(), created_at: Utc::now().fixed_offset(), expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()), max_uses: Some(1), use_count: 1, }; let err = validate_invite(&invite).unwrap_err(); assert!(err.to_string().contains("invite exhausted")); } #[test] fn validate_invite_accepts_active_invites() { let invite = invites::Model { code: "active".to_string(), guild_id: Uuid::new_v4(), created_by_user_id: Uuid::new_v4(), created_at: Utc::now().fixed_offset(), expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()), max_uses: Some(3), use_count: 1, }; validate_invite(&invite).unwrap(); } #[tokio::test] async fn create_guild_runs_guild_and_membership_in_one_transaction() { let owner_user_id = Uuid::new_v4(); let guild_id = Uuid::new_v4(); let created_at = Utc::now().fixed_offset(); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![guilds::Model { id: guild_id, name: "Guild".to_string(), owner_user_id, created_at, }]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let guild = create_guild(&db, owner_user_id, "Guild").await.unwrap(); let transaction_log = format!("{:?}", db.into_transaction_log()); assert_eq!(guild.id, guild_id); assert!(transaction_log.contains("BEGIN"), "{transaction_log}"); assert!(transaction_log.contains("guilds"), "{transaction_log}"); assert!( transaction_log.contains("guild_members"), "{transaction_log}" ); assert!(transaction_log.contains("COMMIT"), "{transaction_log}"); let guild_insert = transaction_log.find("guilds"); let membership_insert = transaction_log.find("guild_members"); assert!(guild_insert.is_some() && membership_insert.is_some()); assert!(guild_insert.unwrap() < membership_insert.unwrap()); } #[tokio::test] async fn join_invite_locks_invite_and_updates_use_count_in_one_transaction() { let user_id = Uuid::new_v4(); let guild_id = Uuid::new_v4(); let created_by_user_id = Uuid::new_v4(); let created_at = Utc::now().fixed_offset(); let invite = invites::Model { code: "invite123".to_string(), guild_id, created_by_user_id, created_at, expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()), max_uses: Some(5), use_count: 0, }; let updated_invite = invites::Model { use_count: 1, ..invite.clone() }; let guild = guilds::Model { id: guild_id, name: "Guild".to_string(), owner_user_id: created_by_user_id, created_at, }; let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![invite]]) .append_query_results([Vec::::new()]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .append_query_results([vec![updated_invite]]) .append_query_results([vec![guild]]) .into_connection(); let joined_guild = join_invite(&db, "invite123", user_id).await.unwrap(); let transaction_log = format!("{:?}", db.into_transaction_log()); assert_eq!(joined_guild.id, guild_id); assert!(transaction_log.contains("BEGIN"), "{transaction_log}"); assert!(transaction_log.contains("FOR UPDATE"), "{transaction_log}"); assert!( transaction_log.contains("guild_members"), "{transaction_log}" ); assert!(transaction_log.contains("UPDATE"), "{transaction_log}"); assert!(transaction_log.contains("invites"), "{transaction_log}"); assert!(transaction_log.contains("COMMIT"), "{transaction_log}"); } #[tokio::test] async fn join_invite_does_not_increment_use_count_for_existing_member() { let user_id = Uuid::new_v4(); let guild_id = Uuid::new_v4(); let created_by_user_id = Uuid::new_v4(); let created_at = Utc::now().fixed_offset(); let invite = invites::Model { code: "invite123".to_string(), guild_id, created_by_user_id, created_at, expires_at: Some((Utc::now() + Duration::minutes(5)).fixed_offset()), max_uses: Some(5), use_count: 3, }; let existing_member = guild_members::Model { guild_id, user_id, created_at, }; let guild = guilds::Model { id: guild_id, name: "Guild".to_string(), owner_user_id: created_by_user_id, created_at, }; let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![invite]]) .append_query_results([vec![existing_member]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .append_query_results([vec![guild]]) .into_connection(); let joined_guild = join_invite(&db, "invite123", user_id).await.unwrap(); let transaction_log = format!("{:?}", db.into_transaction_log()); assert_eq!(joined_guild.id, guild_id); assert!(transaction_log.contains("FOR UPDATE"), "{transaction_log}"); assert!( transaction_log.contains("guild_members"), "{transaction_log}" ); assert!(transaction_log.contains("COMMIT"), "{transaction_log}"); assert!( !transaction_log.contains("UPDATE \"invites\""), "{transaction_log}" ); } #[tokio::test] async fn create_sound_keeps_legacy_file_path_populated() { let guild_id = Uuid::new_v4(); let sound_id = Uuid::new_v4(); let created_by_user_id = Uuid::new_v4(); let created_at = Utc::now().fixed_offset(); let media_url = "https://media.example.com/soundboard/test.mp3"; let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![soundboard_sounds::Model { id: sound_id, guild_id, name: "Airhorn".to_string(), icon: "AH".to_string(), object_key: Some("soundboard/test.mp3".to_string()), media_url: media_url.to_string(), mime_type: Some("audio/mpeg".to_string()), size_bytes: Some(1234), file_path: Some(media_url.to_string()), created_by_user_id, created_at, }]]) .into_connection(); let sound = create_sound( &db, guild_id, created_by_user_id, "Airhorn", "AH", "soundboard/test.mp3", media_url, "audio/mpeg", 1234, ) .await .unwrap(); let transaction_log = format!("{:?}", db.into_transaction_log()); assert_eq!(sound.id, sound_id); assert!(transaction_log.contains("file_path"), "{transaction_log}"); assert!(transaction_log.contains(media_url), "{transaction_log}"); } }