diff --git a/fluxer_users/src/shard_impl.rs b/fluxer_users/src/shard_impl.rs index aac479a99..e9a03ac1a 100644 --- a/fluxer_users/src/shard_impl.rs +++ b/fluxer_users/src/shard_impl.rs @@ -17,6 +17,7 @@ use scylla::statement::prepared::PreparedStatement; #[cfg(feature = "scylla")] use scylla::value::MaybeEmpty; use serde::Deserialize; +use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -51,6 +52,12 @@ const FULL_USER_COLUMNS: &str = "\ premium_grace_ends_at, mention_flags, \ last_voice_activity_sharing_change_at, \ timezone, timezone_privacy_flags"; +#[cfg(feature = "scylla")] +const PARTIAL_USER_COLUMNS: &str = "\ + user_id, username, discriminator, global_name, \ + avatar_hash, bot, system, flags, \ + banner_hash, banner_color, accent_color, avatar_color, \ + mention_flags"; const USER_BATCH_SIZE: usize = 128; const USER_BATCH_CONCURRENCY: usize = 8; const FLUXER_SYSTEM_USER_ID: i64 = 0; @@ -60,15 +67,20 @@ const USER_FLAG_STAFF: i64 = 1; pub struct UsersShard { storage: UsersStorage, - cache: Cache>, + caches: UserCaches, transport: NatsTransport, } +struct UserCaches { + full: Cache>, + partial: Cache>, +} + #[derive(Clone)] enum UsersStorage { Postgres(PostgresUsersStorage), #[cfg(feature = "scylla")] - Scylla(ScyllaUsersStorage), + Scylla(Arc), } #[derive(Clone)] @@ -77,11 +89,11 @@ struct PostgresUsersStorage { } #[cfg(feature = "scylla")] -#[derive(Clone)] struct ScyllaUsersStorage { db: Arc, stmt_full: PreparedStatement, - stmt_full_batch: PreparedStatement, + stmt_partial: PreparedStatement, + stmt_partial_batch: PreparedStatement, } #[cfg(feature = "scylla")] @@ -147,6 +159,41 @@ struct FullUserDbRow { timezone_privacy_flags: Option, } +#[cfg(feature = "scylla")] +#[derive(Debug, DeserializeRow)] +struct PartialUserDbRow { + user_id: i64, + username: String, + discriminator: i32, + global_name: Option, + avatar_hash: Option, + bot: Option, + system: Option, + flags: Option, + banner_hash: Option, + banner_color: Option, + accent_color: Option, + avatar_color: Option, + mention_flags: Option, +} + +#[derive(Debug, Deserialize)] +struct PartialUserKvRow { + user_id: i64, + username: String, + discriminator: i32, + global_name: Option, + avatar_hash: Option, + bot: Option, + system: Option, + flags: Option, + banner_hash: Option, + banner_color: Option, + accent_color: Option, + avatar_color: Option, + mention_flags: Option, +} + #[derive(Debug, Deserialize)] struct FullUserKvRow { user_id: i64, @@ -209,6 +256,63 @@ struct FullUserKvRow { timezone_privacy_flags: Option, } +impl UserCaches { + fn new(max_entries: u64, ttl: Duration) -> Self { + Self { + full: Cache::builder() + .max_capacity(max_entries) + .time_to_live(ttl) + .build(), + partial: Cache::builder() + .max_capacity(max_entries) + .time_to_live(ttl) + .build(), + } + } + + async fn get_or_fetch_full(&self, user_id: i64, fetch: F) -> anyhow::Result> + where + F: Future>>, + { + let user = self + .full + .try_get_with(user_id, fetch) + .await + .map_err(|e: Arc| anyhow::anyhow!("{e}"))?; + self.partial + .insert(user_id, user.as_ref().map(User::to_partial)) + .await; + Ok(user) + } + + async fn get_or_fetch_partial( + &self, + user_id: i64, + fetch: F, + ) -> anyhow::Result> + where + F: Future>>, + { + self.partial + .try_get_with(user_id, fetch) + .await + .map_err(|e: Arc| anyhow::anyhow!("{e}")) + } + + async fn get_partial(&self, user_id: i64) -> Option> { + self.partial.get(&user_id).await + } + + async fn insert_partial(&self, user_id: i64, partial: Option) { + self.partial.insert(user_id, partial).await; + } + + async fn invalidate(&self, user_id: i64) { + self.full.invalidate(&user_id).await; + self.partial.invalidate(&user_id).await; + } +} + impl UsersShard { pub fn new_postgres( kv: postgres::KvClient, @@ -216,14 +320,9 @@ impl UsersShard { max_entries: u64, ttl: Duration, ) -> anyhow::Result { - let cache = Cache::builder() - .max_capacity(max_entries) - .time_to_live(ttl) - .build(); - Ok(Self { storage: UsersStorage::Postgres(PostgresUsersStorage { kv }), - cache, + caches: UserCaches::new(max_entries, ttl), transport, }) } @@ -240,23 +339,25 @@ impl UsersShard { "SELECT {FULL_USER_COLUMNS} FROM users WHERE user_id = ? LIMIT 1" )) .await?; - let stmt_full_batch = db + let stmt_partial = db .prepare(format!( - "SELECT {FULL_USER_COLUMNS} FROM users WHERE user_id IN ?" + "SELECT {PARTIAL_USER_COLUMNS} FROM users WHERE user_id = ? LIMIT 1" + )) + .await?; + let stmt_partial_batch = db + .prepare(format!( + "SELECT {PARTIAL_USER_COLUMNS} FROM users WHERE user_id IN ?" )) .await?; - let cache = Cache::builder() - .max_capacity(max_entries) - .time_to_live(ttl) - .build(); Ok(Self { - storage: UsersStorage::Scylla(ScyllaUsersStorage { + storage: UsersStorage::Scylla(Arc::new(ScyllaUsersStorage { db, stmt_full, - stmt_full_batch, - }), - cache, + stmt_partial, + stmt_partial_batch, + })), + caches: UserCaches::new(max_entries, ttl), transport, }) } @@ -266,17 +367,25 @@ impl UsersShard { return Ok(Some(fluxer_system_user())); } let storage = self.storage.clone(); - self.cache - .try_get_with( + self.caches + .get_or_fetch_full( user_id, async move { storage.fetch_full_user(user_id).await }, ) .await - .map_err(|e: Arc| anyhow::anyhow!("{e}")) } async fn get_partial_user(&self, user_id: i64) -> anyhow::Result> { - Ok(self.get_full_user(user_id).await?.map(|u| u.to_partial())) + if user_id == FLUXER_SYSTEM_USER_ID { + return Ok(Some(fluxer_system_user().to_partial())); + } + let storage = self.storage.clone(); + self.caches + .get_or_fetch_partial( + user_id, + async move { storage.fetch_partial_user(user_id).await }, + ) + .await } async fn get_partial_users(&self, user_ids: Vec) -> anyhow::Result> { @@ -290,8 +399,8 @@ impl UsersShard { partials.push(fluxer_system_user().to_partial()); continue; } - match self.cache.get(&user_id).await { - Some(Some(user)) => partials.push(user.to_partial()), + match self.caches.get_partial(user_id).await { + Some(Some(partial)) => partials.push(partial), Some(None) => {} None => misses.push(user_id), } @@ -304,12 +413,12 @@ impl UsersShard { .map(<[i64]>::to_vec) .collect::>(); let fetched_batches = stream::iter(batches) - .map(|batch| async move { self.fetch_user_batch(batch).await }) + .map(|batch| async move { self.fetch_partial_batch(batch).await }) .buffer_unordered(USER_BATCH_CONCURRENCY) .collect::>() .await; for fetched in fetched_batches { - partials.extend(fetched?.into_iter().map(|user| user.to_partial())); + partials.extend(fetched?); } Ok(partials) } @@ -341,13 +450,13 @@ impl UsersShard { .collect()) } - async fn fetch_user_batch(&self, user_ids: Vec) -> anyhow::Result> { - let mut users = Vec::new(); + async fn fetch_partial_batch(&self, user_ids: Vec) -> anyhow::Result> { + let mut partials = Vec::new(); let user_ids = user_ids .into_iter() .filter(|user_id| { if *user_id == FLUXER_SYSTEM_USER_ID { - users.push(fluxer_system_user()); + partials.push(fluxer_system_user().to_partial()); false } else { true @@ -355,34 +464,39 @@ impl UsersShard { }) .collect::>(); if user_ids.is_empty() { - return Ok(users); + return Ok(partials); } - let fetched_users = match self.storage.fetch_user_batch(user_ids.clone()).await { - Ok(users) => users, + let fetched_partials = match self.storage.fetch_partial_batch(user_ids.clone()).await { + Ok(partials) => partials, Err(_) => { - users.extend(self.fetch_user_batch_individually(user_ids).await?); - return Ok(users); + partials.extend(self.fetch_partial_batch_individually(user_ids).await?); + return Ok(partials); } }; - let found_ids = fetched_users + let found_ids = fetched_partials .iter() - .map(|user| user.user_id) + .map(|partial| partial.user_id) .collect::>(); - for user in &fetched_users { - self.cache.insert(user.user_id, Some(user.clone())).await; + for partial in &fetched_partials { + self.caches + .insert_partial(partial.user_id, Some(partial.clone())) + .await; } for user_id in user_ids { if !found_ids.contains(&user_id) { - self.cache.insert(user_id, None).await; + self.caches.insert_partial(user_id, None).await; } } - users.extend(fetched_users); - Ok(users) + partials.extend(fetched_partials); + Ok(partials) } - async fn fetch_user_batch_individually(&self, user_ids: Vec) -> anyhow::Result> { - let users = stream::iter(user_ids) - .map(|user_id| async move { self.get_full_user(user_id).await }) + async fn fetch_partial_batch_individually( + &self, + user_ids: Vec, + ) -> anyhow::Result> { + let partials = stream::iter(user_ids) + .map(|user_id| async move { self.get_partial_user(user_id).await }) .buffer_unordered(USER_BATCH_CONCURRENCY) .collect::>() .await @@ -391,7 +505,7 @@ impl UsersShard { .into_iter() .flatten() .collect(); - Ok(users) + Ok(partials) } } @@ -404,11 +518,19 @@ impl UsersStorage { } } - async fn fetch_user_batch(&self, user_ids: Vec) -> anyhow::Result> { + async fn fetch_partial_user(&self, user_id: i64) -> anyhow::Result> { match self { - UsersStorage::Postgres(storage) => storage.fetch_user_batch(user_ids).await, + UsersStorage::Postgres(storage) => storage.fetch_partial_user(user_id).await, #[cfg(feature = "scylla")] - UsersStorage::Scylla(storage) => storage.fetch_user_batch(user_ids).await, + UsersStorage::Scylla(storage) => storage.fetch_partial_user(user_id).await, + } + } + + async fn fetch_partial_batch(&self, user_ids: Vec) -> anyhow::Result> { + match self { + UsersStorage::Postgres(storage) => storage.fetch_partial_batch(user_ids).await, + #[cfg(feature = "scylla")] + UsersStorage::Scylla(storage) => storage.fetch_partial_batch(user_ids).await, } } } @@ -422,14 +544,22 @@ impl PostgresUsersStorage { decode_postgres_user(row).map(Some) } - async fn fetch_user_batch(&self, user_ids: Vec) -> anyhow::Result> { + async fn fetch_partial_user(&self, user_id: i64) -> anyhow::Result> { + let key = postgres::kv_key(&[KeyPart::BigInt(user_id)])?; + let Some(row) = self.kv.get_row("users", &key).await? else { + return Ok(None); + }; + decode_postgres_user_partial(row).map(Some) + } + + async fn fetch_partial_batch(&self, user_ids: Vec) -> anyhow::Result> { let keys = user_ids .iter() .map(|user_id| postgres::kv_key(&[KeyPart::BigInt(*user_id)])) .collect::>>()?; let rows = self.kv.get_rows("users", &keys).await?; rows.into_iter() - .map(|(_, row)| decode_postgres_user(row)) + .map(|(_, row)| decode_postgres_user_partial(row)) .collect() } } @@ -443,14 +573,25 @@ impl ScyllaUsersStorage { Ok(user) } - async fn fetch_user_batch(&self, user_ids: Vec) -> anyhow::Result> { + async fn fetch_partial_user(&self, user_id: i64) -> anyhow::Result> { let result = self .db - .execute_unpaged(&self.stmt_full_batch, (user_ids,)) + .execute_unpaged(&self.stmt_partial, (user_id,)) .await?; let rows = result.into_rows_result()?; - let rows: Vec = rows.rows::()?.collect::>()?; - Ok(rows.into_iter().map(User::from).collect::>()) + let partial = rows.maybe_first_row::()?.map(Into::into); + Ok(partial) + } + + async fn fetch_partial_batch(&self, user_ids: Vec) -> anyhow::Result> { + let result = self + .db + .execute_unpaged(&self.stmt_partial_batch, (user_ids,)) + .await?; + let rows = result.into_rows_result()?; + let rows: Vec = + rows.rows::()?.collect::>()?; + Ok(rows.into_iter().map(UserPartial::from).collect::>()) } } @@ -460,6 +601,12 @@ fn decode_postgres_user(row: serde_json::Value) -> anyhow::Result { Ok(row.into()) } +fn decode_postgres_user_partial(row: serde_json::Value) -> anyhow::Result { + let row = postgres::decode_row_dates_as_millis(row)?; + let row: PartialUserKvRow = serde_json::from_value(row)?; + Ok(row.into()) +} + fn fluxer_system_user() -> User { User { user_id: FLUXER_SYSTEM_USER_ID, @@ -556,7 +703,7 @@ impl ShardService for UsersShard { self.get_api_partial_users(user_ids).await?, )), UserRequest::Invalidate { user_id } => { - self.cache.invalidate(&user_id).await; + self.caches.invalidate(user_id).await; let subject = format!("svc.users.invalidate.{user_id}"); self.transport.publish(&subject, &[]).await?; Ok(UserResponse::Invalidated) @@ -587,6 +734,47 @@ fn optional_date_string(value: OptionalDate) -> Option { }) } +#[cfg(feature = "scylla")] +impl From for UserPartial { + fn from(row: PartialUserDbRow) -> Self { + Self { + user_id: row.user_id, + username: row.username, + discriminator: row.discriminator, + global_name: row.global_name, + avatar_hash: row.avatar_hash, + bot: row.bot, + system: row.system, + flags: row.flags, + banner_hash: row.banner_hash, + banner_color: row.banner_color, + accent_color: row.accent_color, + avatar_color: row.avatar_color, + mention_flags: row.mention_flags, + } + } +} + +impl From for UserPartial { + fn from(row: PartialUserKvRow) -> Self { + Self { + user_id: row.user_id, + username: row.username, + discriminator: row.discriminator, + global_name: row.global_name, + avatar_hash: row.avatar_hash, + bot: row.bot, + system: row.system, + flags: row.flags, + banner_hash: row.banner_hash, + banner_color: row.banner_color, + accent_color: row.accent_color, + avatar_color: row.avatar_color, + mention_flags: row.mention_flags, + } + } +} + #[cfg(feature = "scylla")] impl From for User { fn from(row: FullUserDbRow) -> Self { @@ -744,6 +932,152 @@ mod tests { use super::*; use serde_json::json; + fn test_user(user_id: i64) -> User { + let mut user = fluxer_system_user(); + user.user_id = user_id; + user.username = "Ada".to_owned(); + user.discriminator = 7; + user.global_name = Some("Ada Lovelace".to_owned()); + user.avatar_hash = Some("avatar_hash".to_owned()); + user.bot = Some(false); + user.system = Some(false); + user.email = Some("ada@example.com".to_owned()); + user.bio = Some("analytical engine enjoyer".to_owned()); + user.stripe_customer_id = Some("cus_123".to_owned()); + user + } + + fn caches() -> UserCaches { + UserCaches::new(16, Duration::from_secs(60)) + } + + #[tokio::test] + async fn partial_reads_populate_only_the_partial_cache() { + let caches = caches(); + let partial = test_user(42).to_partial(); + + let fetched = caches + .get_or_fetch_partial(42, async { Ok(Some(partial)) }) + .await + .unwrap() + .unwrap(); + + assert_eq!(fetched.username, "Ada"); + assert_eq!( + caches.get_partial(42).await.unwrap().unwrap().username, + "Ada" + ); + assert!(caches.full.get(&42).await.is_none()); + } + + #[tokio::test] + async fn partial_cache_hits_do_not_reach_storage_again() { + let caches = caches(); + let partial = test_user(42).to_partial(); + caches + .get_or_fetch_partial(42, async { Ok(Some(partial)) }) + .await + .unwrap(); + + let fetched = caches + .get_or_fetch_partial(42, async { Ok(Some(test_user(7).to_partial())) }) + .await + .unwrap() + .unwrap(); + + assert_eq!(fetched.user_id, 42); + } + + #[tokio::test] + async fn full_reads_populate_both_caches() { + let caches = caches(); + let user = test_user(42); + + let fetched = caches + .get_or_fetch_full(42, async { Ok(Some(user)) }) + .await + .unwrap() + .unwrap(); + + assert_eq!(fetched.email.as_deref(), Some("ada@example.com")); + assert_eq!( + caches.full.get(&42).await.unwrap().unwrap().bio.as_deref(), + Some("analytical engine enjoyer") + ); + let cached_partial = caches.get_partial(42).await.unwrap().unwrap(); + assert_eq!(cached_partial.username, "Ada"); + assert_eq!(cached_partial.avatar_hash.as_deref(), Some("avatar_hash")); + } + + #[tokio::test] + async fn missing_users_are_negatively_cached_in_both_caches() { + let caches = caches(); + + let fetched = caches + .get_or_fetch_full(42, async { Ok(None) }) + .await + .unwrap(); + + assert!(fetched.is_none()); + assert!(matches!(caches.full.get(&42).await, Some(None))); + assert!(matches!(caches.get_partial(42).await, Some(None))); + } + + #[tokio::test] + async fn invalidation_clears_both_caches() { + let caches = caches(); + caches + .get_or_fetch_full(42, async { Ok(Some(test_user(42))) }) + .await + .unwrap(); + assert!(caches.full.get(&42).await.is_some()); + assert!(caches.get_partial(42).await.is_some()); + + caches.invalidate(42).await; + + assert!(caches.full.get(&42).await.is_none()); + assert!(caches.get_partial(42).await.is_none()); + } + + #[tokio::test] + async fn invalidation_clears_a_partial_only_entry() { + let caches = caches(); + caches + .get_or_fetch_partial(42, async { Ok(Some(test_user(42).to_partial())) }) + .await + .unwrap(); + + caches.invalidate(42).await; + + assert!(caches.get_partial(42).await.is_none()); + } + + #[cfg(feature = "scylla")] + #[test] + fn partial_columns_match_the_user_partial_fields_exactly() { + use std::collections::BTreeSet; + + let columns = PARTIAL_USER_COLUMNS + .split(',') + .map(str::trim) + .collect::>(); + let partial = serde_json::to_value(test_user(42).to_partial()).unwrap(); + let fields = partial + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(); + let full_columns = FULL_USER_COLUMNS + .split(',') + .map(str::trim) + .collect::>(); + + assert_eq!(columns, fields); + assert!(columns.is_subset(&full_columns)); + assert!(columns.len() < full_columns.len()); + } + #[test] fn fluxer_system_user_partial_is_virtual_id_zero() { let partial = fluxer_system_user().to_partial(); diff --git a/fluxer_users/src/types.rs b/fluxer_users/src/types.rs index 6835743a8..dab47acfa 100644 --- a/fluxer_users/src/types.rs +++ b/fluxer_users/src/types.rs @@ -173,22 +173,10 @@ impl User { } } - pub fn to_api_partial(&self) -> ApiUserPartial { - self.to_partial().to_api_partial() - } -} - -impl UserPartial { pub fn to_api_partial(&self) -> ApiUserPartial { if self.user_id == FLUXER_SYSTEM_USER_ID { return fluxer_system_user(); } - let flags = self.flags.unwrap_or_default(); - let visible_flags = if (flags & USER_FLAG_STAFF_HIDDEN) != 0 { - PUBLIC_USER_FLAGS_WITHOUT_STAFF - } else { - PUBLIC_USER_FLAGS - }; ApiUserPartial { id: self.user_id.to_string(), username: self.username.clone(), @@ -198,12 +186,41 @@ impl UserPartial { avatar_color: self.avatar_color, bot: self.bot.filter(|bot| *bot), system: self.system.filter(|system| *system), - flags: (flags & visible_flags) as i32, + flags: visible_user_flags(self.flags.unwrap_or_default()), mention_flags: self.mention_flags.filter(|flags| *flags != 0), } } } +impl UserPartial { + pub fn to_api_partial(&self) -> ApiUserPartial { + if self.user_id == FLUXER_SYSTEM_USER_ID { + return fluxer_system_user(); + } + ApiUserPartial { + id: self.user_id.to_string(), + username: self.username.clone(), + discriminator: format!("{:04}", self.discriminator), + global_name: self.global_name.clone(), + avatar: self.avatar_hash.clone(), + avatar_color: self.avatar_color, + bot: self.bot.filter(|bot| *bot), + system: self.system.filter(|system| *system), + flags: visible_user_flags(self.flags.unwrap_or_default()), + mention_flags: self.mention_flags.filter(|flags| *flags != 0), + } + } +} + +fn visible_user_flags(flags: i64) -> i32 { + let visible_flags = if (flags & USER_FLAG_STAFF_HIDDEN) != 0 { + PUBLIC_USER_FLAGS_WITHOUT_STAFF + } else { + PUBLIC_USER_FLAGS + }; + (flags & visible_flags) as i32 +} + fn fluxer_system_user() -> ApiUserPartial { ApiUserPartial { id: FLUXER_SYSTEM_USER_ID.to_string(), @@ -261,6 +278,107 @@ mod tests { } } + fn user_with_flags(user_id: i64, flags: i64) -> User { + User { + user_id, + username: "Ada".to_owned(), + discriminator: 7, + bot: Some(false), + system: Some(false), + email: Some("ada@example.com".to_owned()), + email_verified: Some(true), + email_bounced: Some(false), + authenticator_types: vec![1], + avatar_hash: Some("avatar_hash".to_owned()), + avatar_color: Some(0x336699), + banner_hash: Some("banner_hash".to_owned()), + banner_color: Some(0x112233), + bio: Some("analytical engine enjoyer".to_owned()), + accent_color: Some(0x445566), + date_of_birth: Some("1815-12-10".to_owned()), + locale: Some("en-US".to_owned()), + flags: Some(flags), + premium_flags: Some(1), + global_name: Some("Ada Lovelace".to_owned()), + pronouns: Some("she/her".to_owned()), + traits: vec!["founder".to_owned()], + premium_type: Some(2), + premium_since: Some(1_781_526_896_789), + premium_until: Some(1_781_526_896_789), + premium_gift_extension_ends_at: None, + premium_lifetime_sequence: None, + premium_billing_cycle: Some("monthly".to_owned()), + premium_will_cancel: Some(false), + premium_onboarding_dismissed_at: None, + has_ever_purchased: Some(true), + stripe_subscription_id: Some("sub_123".to_owned()), + stripe_customer_id: Some("cus_123".to_owned()), + gift_inventory_server_seq: Some(3), + gift_inventory_client_seq: Some(3), + suspicious_activity_flags: Some(0), + terms_agreed_at: Some(1_781_526_896_789), + privacy_agreed_at: Some(1_781_526_896_789), + last_active_at: Some(1_781_526_896_789), + last_active_ip: Some("203.0.113.7".to_owned()), + temp_banned_until: None, + pending_deletion_at: None, + pending_bulk_message_deletion_at: None, + pending_bulk_message_deletion_channel_count: None, + pending_bulk_message_deletion_message_count: None, + password_last_changed_at: Some(1_781_526_896_789), + acls: vec!["admin".to_owned()], + deletion_reason_code: None, + deletion_public_reason: None, + deletion_audit_log_reason: None, + first_refund_at: None, + version: 3, + has_verified_phone: Some(true), + premium_grace_ends_at: None, + mention_flags: Some(2), + last_voice_activity_sharing_change_at: None, + timezone: Some("Europe/London".to_owned()), + timezone_privacy_flags: Some(1), + } + } + + #[test] + fn direct_api_partial_matches_the_two_step_conversion() { + for user_id in [123, FLUXER_SYSTEM_USER_ID] { + for flags in [ + 0, + USER_FLAG_STAFF, + USER_FLAG_STAFF | USER_FLAG_STAFF_HIDDEN | USER_FLAG_PARTNER, + USER_FLAG_DELETED, + ] { + let user = user_with_flags(user_id, flags); + + assert_eq!( + serde_json::to_value(user.to_api_partial()).unwrap(), + serde_json::to_value(user.to_partial().to_api_partial()).unwrap(), + "user_id {user_id} flags {flags}" + ); + } + } + } + + #[test] + fn direct_api_partial_ignores_fields_outside_the_partial() { + let mut user = user_with_flags(123, USER_FLAG_STAFF); + user.mention_flags = Some(0); + let api_partial = user.to_api_partial(); + + assert_eq!(api_partial.id, "123"); + assert_eq!(api_partial.username, "Ada"); + assert_eq!(api_partial.discriminator, "0007"); + assert_eq!(api_partial.global_name, Some("Ada Lovelace".to_owned())); + assert_eq!(api_partial.avatar, Some("avatar_hash".to_owned())); + assert_eq!(api_partial.avatar_color, Some(0x336699)); + assert_eq!(api_partial.bot, None); + assert_eq!(api_partial.system, None); + assert_eq!(api_partial.flags, USER_FLAG_STAFF as i32); + assert_eq!(api_partial.mention_flags, None); + } + #[test] fn api_partial_requests_use_string_snowflakes() { let request: UserRequest = serde_json::from_value(json!({