1289 lines
40 KiB
Rust
1289 lines
40 KiB
Rust
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<bool> {
|
|
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<chrono::FixedOffset>,
|
|
user_agent_hash: Option<String>,
|
|
ip_hash: Option<String>,
|
|
) -> 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<Option<Uuid>> {
|
|
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<User> {
|
|
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<Option<User>> {
|
|
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<Vec<BasicUser>> {
|
|
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<Vec<Uuid>> {
|
|
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<Vec<Guild>> {
|
|
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<Vec<Uuid>> {
|
|
let guild_ids: Vec<Uuid> = 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<Guild> {
|
|
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<bool> {
|
|
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<i32>,
|
|
expires_in_hours: Option<i64>,
|
|
) -> Result<Invite> {
|
|
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<Guild> {
|
|
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<Channel> {
|
|
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<Option<Channel>> {
|
|
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<Vec<Channel>> {
|
|
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<Vec<Channel>> {
|
|
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<bool> {
|
|
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<Option<Uuid>> {
|
|
let guild_id = channels::Entity::find_by_id(channel_id)
|
|
.select_only()
|
|
.column(channels::Column::GuildId)
|
|
.into_tuple::<Uuid>()
|
|
.one(db)
|
|
.await?;
|
|
|
|
Ok(guild_id)
|
|
}
|
|
|
|
pub async fn create_message(
|
|
db: &DatabaseConnection,
|
|
channel_id: Uuid,
|
|
author_user_id: Uuid,
|
|
body: &str,
|
|
) -> Result<MessageWithAuthor> {
|
|
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<MessageWithAuthor> {
|
|
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<Vec<MessageWithAuthor>> {
|
|
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<DmMessageWithAuthor> {
|
|
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<DmMessageWithAuthor> {
|
|
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<Vec<DmMessageWithAuthor>> {
|
|
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::<Vec<_>>();
|
|
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<Uuid, String> = 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<Uuid>,
|
|
) -> Result<std::collections::HashMap<Uuid, Vec<Attachment>>> {
|
|
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::<Uuid, Vec<Attachment>>::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<Uuid>,
|
|
) -> Result<std::collections::HashMap<Uuid, Vec<Attachment>>> {
|
|
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::<Uuid, Vec<Attachment>>::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<Vec<DmConversation>> {
|
|
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::<Uuid, chrono::DateTime<chrono::FixedOffset>>::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<Uuid> = 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<DmConversation> = 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<Vec<SoundboardSound>> {
|
|
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<SoundboardSound> {
|
|
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<Option<SoundboardSound>> {
|
|
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<Option<String>> {
|
|
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::<BTreeMap<String, Value>>::new()])
|
|
.append_query_results([Vec::<direct_messages::Model>::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::<guild_members::Model>::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}");
|
|
}
|
|
}
|