This commit is contained in:
parent
24975c2e0d
commit
3acd082fb0
28 changed files with 3454 additions and 836 deletions
478
src/db.rs
478
src/db.rs
|
|
@ -1,15 +1,17 @@
|
|||
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,
|
||||
TransactionTrait, sea_query::OnConflict,
|
||||
Statement, TransactionTrait, sea_query::OnConflict,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
entity::{
|
||||
attachments, channels, direct_messages, guild_members, guilds, invites, messages,
|
||||
attachments, channels, direct_messages, guild_members, guilds, invites, messages, sessions,
|
||||
soundboard_sounds, users,
|
||||
},
|
||||
models::{
|
||||
|
|
@ -26,6 +28,88 @@ pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result<bool>
|
|||
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,
|
||||
|
|
@ -106,18 +190,58 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res
|
|||
.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(db)
|
||||
.exec_with_returning(&txn)
|
||||
.await?;
|
||||
|
||||
guild_members::Entity::insert(guild_members::ActiveModel {
|
||||
|
|
@ -133,17 +257,13 @@ pub async fn create_guild(
|
|||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(db)
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
Ok(map_guild(guild))
|
||||
}
|
||||
|
||||
pub async fn get_guild_by_id(db: &DatabaseConnection, guild_id: Uuid) -> Result<Option<Guild>> {
|
||||
let row = guilds::Entity::find_by_id(guild_id).one(db).await?;
|
||||
Ok(row.map(map_guild))
|
||||
}
|
||||
|
||||
pub async fn is_guild_owner(
|
||||
db: &DatabaseConnection,
|
||||
guild_id: Uuid,
|
||||
|
|
@ -197,7 +317,12 @@ pub async fn create_invite(
|
|||
pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result<Guild> {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let invite = invites::Entity::find_by_id(code.to_string())
|
||||
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"))?;
|
||||
|
|
@ -797,7 +922,10 @@ pub async fn create_sound(
|
|||
created_by_user_id: Uuid,
|
||||
name: &str,
|
||||
icon: &str,
|
||||
file_path: &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()),
|
||||
|
|
@ -805,7 +933,13 @@ pub async fn create_sound(
|
|||
created_by_user_id: Set(created_by_user_id),
|
||||
name: Set(name.to_string()),
|
||||
icon: Set(icon.to_string()),
|
||||
file_path: Set(file_path.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)
|
||||
|
|
@ -819,6 +953,11 @@ pub async fn get_sound_by_id(db: &DatabaseConnection, id: Uuid) -> Result<Option
|
|||
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(())
|
||||
|
|
@ -830,8 +969,321 @@ fn map_sound(model: soundboard_sounds::Model) -> SoundboardSound {
|
|||
guild_id: model.guild_id,
|
||||
name: model.name,
|
||||
icon: model.icon,
|
||||
file_path: model.file_path,
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue