pub mod contact;
pub mod misc;
pub mod space_ban;
pub mod space_invite;
pub mod blob;
pub mod custom_emoji;
pub mod quota;
use std::collections::HashMap;
use serde::Deserialize;
use jmap_types::Id;
pub use jmap_types::{
AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
SetResponse,
};
#[non_exhaustive]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushSubscriptionCreateResponse {
#[serde(default)]
pub account_id: Option<Id>,
pub created: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub not_created: Option<HashMap<String, SetError>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TypingResponse {
pub account_id: Id,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpaceJoinResponse {
pub account_id: Id,
pub space_id: Id,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub enum Patch<T> {
#[default]
Keep,
Set(T),
Clear,
}
impl<T> Patch<T> {
pub fn is_keep(&self) -> bool {
matches!(self, Patch::Keep)
}
}
impl<T> From<T> for Patch<T> {
fn from(v: T) -> Self {
Patch::Set(v)
}
}
impl<T: serde::Serialize> Patch<T> {
pub(crate) fn map_entry(&self) -> Result<Option<serde_json::Value>, serde_json::Error> {
match self {
Patch::Keep => Ok(None),
Patch::Clear => Ok(Some(serde_json::Value::Null)),
Patch::Set(v) => serde_json::to_value(v).map(Some),
}
}
}
impl<T: serde::Serialize> serde::Serialize for Patch<T> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Patch::Keep => Err(serde::ser::Error::custom(
"Patch::Keep cannot be serialized; add \
#[serde(skip_serializing_if = \"Patch::is_keep\")] to the field",
)),
Patch::Clear => s.serialize_none(),
Patch::Set(v) => v.serialize(s),
}
}
}
impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Patch<T> {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Option::<T>::deserialize(d).map(|opt| match opt {
None => Patch::Clear,
Some(v) => Patch::Set(v),
})
}
}
pub const CALL_ID: &str = "r1";
pub(crate) const USING_CHAT: &[&str] =
&["urn:ietf:params:jmap:core", jmap_chat_types::JMAP_CHAT_URI];
pub(crate) const USING_QUOTA: &[&str] =
&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:quota"];
pub(crate) const USING_CORE: &[&str] = &["urn:ietf:params:jmap:core"];
pub(crate) const USING_CHAT_PUSH: &[&str] = &[
"urn:ietf:params:jmap:core",
jmap_chat_types::JMAP_CHAT_PUSH_URI,
];
pub(crate) fn build_request(
method: &str,
args: serde_json::Value,
using: &[&str],
) -> jmap_types::JmapRequest {
let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
}
pub(crate) fn resolve_client_id(id: Option<&str>) -> String {
match id {
Some(s) if !s.is_empty() => s.to_owned(),
_ => ulid::Ulid::new().to_string(),
}
}
#[non_exhaustive]
#[derive(Clone)]
pub struct SessionClient {
pub(crate) client: jmap_base_client::JmapClient,
pub(crate) session: jmap_base_client::Session,
}
impl std::fmt::Debug for SessionClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionClient")
.field("client", &"<JmapClient>")
.field("session", &self.session)
.finish()
}
}
impl SessionClient {
pub fn client(&self) -> &jmap_base_client::JmapClient {
&self.client
}
pub fn session(&self) -> &jmap_base_client::Session {
&self.session
}
pub fn chat_account_id(&self) -> Result<&str, jmap_base_client::ClientError> {
self.session
.primary_account_id(jmap_chat_types::JMAP_CHAT_URI)
.ok_or_else(|| {
jmap_base_client::ClientError::InvalidSession(
"no primary account for urn:ietf:params:jmap:chat".into(),
)
})
}
pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
let api_url = self.session.api_url.as_str();
let account_id = self
.session
.primary_account_id(jmap_chat_types::JMAP_CHAT_URI)
.ok_or_else(|| {
jmap_base_client::ClientError::InvalidSession(
"no primary account for urn:ietf:params:jmap:chat".into(),
)
})?;
Ok((api_url, account_id))
}
pub(crate) fn api_url(&self) -> &str {
self.session.api_url.as_str()
}
pub(crate) async fn call_internal(
&self,
api_url: &str,
req: &jmap_types::JmapRequest,
) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
self.client.call(api_url, req).await
}
}
#[allow(dead_code)]
fn _assert_session_client_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SessionClient>();
}
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub struct ChatQueryInput {
pub filter_kind: Option<jmap_chat_types::ChatKind>,
pub filter_muted: Option<bool>,
pub position: Option<u64>,
pub limit: Option<u64>,
}
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub struct MessageQueryInput<'a> {
pub chat_id: Option<&'a Id>,
pub has_mention: Option<bool>,
pub has_attachment: Option<bool>,
pub text: Option<&'a str>,
pub thread_root_id: Option<&'a Id>,
pub after: Option<&'a jmap_types::UTCDate>,
pub before: Option<&'a jmap_types::UTCDate>,
pub position: Option<u64>,
pub limit: Option<u64>,
pub sort_ascending: bool,
}
impl<'a> MessageQueryInput<'a> {
pub fn with_sort_ascending(mut self, v: bool) -> Self {
self.sort_ascending = v;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct MessageCreateInput<'a> {
pub client_id: Option<&'a str>,
pub chat_id: &'a Id,
pub body: &'a str,
pub body_type: jmap_chat_types::BodyType,
pub sent_at: &'a jmap_types::UTCDate,
pub reply_to: Option<&'a Id>,
}
impl<'a> MessageCreateInput<'a> {
pub fn new(
chat_id: &'a Id,
body: &'a str,
body_type: jmap_chat_types::BodyType,
sent_at: &'a jmap_types::UTCDate,
) -> Self {
Self {
client_id: None,
chat_id,
body,
body_type,
sent_at,
reply_to: None,
}
}
pub fn with_client_id(mut self, id: &'a str) -> Self {
self.client_id = Some(id);
self
}
pub fn with_reply_to(mut self, id: &'a Id) -> Self {
self.reply_to = Some(id);
self
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum ReactionChange<'a> {
Add {
sender_reaction_id: &'a str,
emoji: &'a str,
sent_at: &'a jmap_types::UTCDate,
},
Remove {
sender_reaction_id: &'a str,
},
}
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct MessagePatch<'a> {
pub body: Option<&'a str>,
pub body_type: Option<jmap_chat_types::BodyType>,
pub reaction_changes: Option<&'a [ReactionChange<'a>]>,
pub read_at: Option<&'a jmap_types::UTCDate>,
pub read_disposition: Option<jmap_chat_types::ReadDisposition>,
pub deleted_at: Option<&'a jmap_types::UTCDate>,
pub deleted_for_all: Option<bool>,
}
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct PresenceStatusPatch<'a> {
pub presence: Option<jmap_chat_types::Presence>,
pub status_text: Patch<&'a str>,
pub status_emoji: Patch<&'a str>,
pub expires_at: Patch<&'a jmap_types::UTCDate>,
pub receipt_sharing: Option<bool>,
}
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub struct CustomEmojiQueryInput<'a> {
pub filter_space_id: Option<&'a Id>,
pub position: Option<u64>,
pub limit: Option<u64>,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct CustomEmojiCreateInput<'a> {
pub client_id: Option<&'a str>,
pub name: &'a str,
pub blob_id: &'a Id,
pub space_id: Option<&'a Id>,
}
impl<'a> CustomEmojiCreateInput<'a> {
pub fn new(name: &'a str, blob_id: &'a Id) -> Self {
Self {
client_id: None,
name,
blob_id,
space_id: None,
}
}
pub fn with_client_id(mut self, id: &'a str) -> Self {
self.client_id = Some(id);
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceInviteCreateInput<'a> {
pub client_id: Option<&'a str>,
pub space_id: &'a Id,
pub default_channel_id: Option<&'a Id>,
pub expires_at: Option<&'a jmap_types::UTCDate>,
pub max_uses: Option<u64>,
}
impl<'a> SpaceInviteCreateInput<'a> {
pub fn new(space_id: &'a Id) -> Self {
Self {
client_id: None,
space_id,
default_channel_id: None,
expires_at: None,
max_uses: None,
}
}
pub fn with_client_id(mut self, id: &'a str) -> Self {
self.client_id = Some(id);
self
}
pub fn with_max_uses(mut self, max: u64) -> Self {
self.max_uses = Some(max);
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceBanCreateInput<'a> {
pub client_id: Option<&'a str>,
pub space_id: &'a Id,
pub user_id: &'a Id,
pub reason: Option<&'a str>,
pub expires_at: Option<&'a jmap_types::UTCDate>,
}
impl<'a> SpaceBanCreateInput<'a> {
pub fn new(space_id: &'a Id, user_id: &'a Id) -> Self {
Self {
client_id: None,
space_id,
user_id,
reason: None,
expires_at: None,
}
}
pub fn with_client_id(mut self, id: &'a str) -> Self {
self.client_id = Some(id);
self
}
}
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct ChatContactPatch<'a> {
pub blocked: Option<bool>,
pub display_name: Patch<&'a str>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ContactSortProperty {
LastSeenAt,
Login,
LastActiveAt,
}
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub struct ChatContactQueryInput {
pub filter_blocked: Option<bool>,
pub filter_presence: Option<crate::types::ContactPresenceFilter>,
pub position: Option<u64>,
pub limit: Option<u64>,
pub sort_property: Option<ContactSortProperty>,
pub sort_ascending: Option<bool>,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceCreateInput<'a> {
pub client_id: Option<&'a str>,
pub name: &'a str,
pub description: Option<&'a str>,
pub icon_blob_id: Option<&'a Id>,
}
impl<'a> SpaceCreateInput<'a> {
pub fn new(name: &'a str) -> Self {
Self {
client_id: None,
name,
description: None,
icon_blob_id: None,
}
}
pub fn with_client_id(mut self, id: &'a str) -> Self {
self.client_id = Some(id);
self
}
}
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub struct SpaceQueryInput<'a> {
pub filter_name: Option<&'a str>,
pub filter_is_public: Option<bool>,
pub position: Option<u64>,
pub limit: Option<u64>,
}
#[non_exhaustive]
pub enum SpaceJoinInput<'a> {
InviteCode(&'a str),
SpaceId(&'a Id),
}
impl<'a> std::fmt::Debug for SpaceJoinInput<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InviteCode(_) => f.debug_tuple("InviteCode").field(&"[REDACTED]").finish(),
Self::SpaceId(id) => f.debug_tuple("SpaceId").field(id).finish(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct AddMemberInput<'a> {
pub id: &'a Id,
pub role: Option<crate::types::ChatMemberRole>,
}
impl<'a> AddMemberInput<'a> {
pub fn new(id: &'a Id) -> Self {
Self { id, role: None }
}
pub fn with_role(mut self, role: crate::types::ChatMemberRole) -> Self {
self.role = Some(role);
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct UpdateMemberRoleInput<'a> {
pub id: &'a Id,
pub role: crate::types::ChatMemberRole,
}
impl<'a> UpdateMemberRoleInput<'a> {
pub fn new(id: &'a Id, role: crate::types::ChatMemberRole) -> Self {
Self { id, role }
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum ChatCreateInput<'a> {
Direct {
client_id: Option<&'a str>,
contact_id: &'a Id,
},
Group {
client_id: Option<&'a str>,
name: &'a str,
member_ids: &'a [Id],
description: Option<&'a str>,
avatar_blob_id: Option<&'a Id>,
message_expiry_seconds: Option<u64>,
},
}
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct ChatPatch<'a> {
pub muted: Option<bool>,
pub mute_until: Patch<&'a jmap_types::UTCDate>,
pub receive_typing_indicators: Option<bool>,
pub pinned_message_ids: Option<&'a [Id]>,
pub message_expiry_seconds: Patch<u64>,
pub receipt_sharing: Option<bool>,
pub name: Option<&'a str>,
pub description: Patch<&'a str>,
pub avatar_blob_id: Patch<&'a Id>,
pub add_members: Option<&'a [AddMemberInput<'a>]>,
pub remove_members: Option<&'a [Id]>,
pub update_member_roles: Option<&'a [UpdateMemberRoleInput<'a>]>,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceAddMemberInput<'a> {
pub id: &'a Id,
pub role_ids: Option<&'a [Id]>,
}
impl<'a> SpaceAddMemberInput<'a> {
pub fn new(id: &'a Id) -> Self {
Self { id, role_ids: None }
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceUpdateMemberInput<'a> {
pub id: &'a Id,
pub role_ids: Option<&'a [Id]>,
pub nick: Patch<&'a str>,
}
impl<'a> SpaceUpdateMemberInput<'a> {
pub fn new(id: &'a Id) -> Self {
Self {
id,
role_ids: None,
nick: Patch::Keep,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceAddChannelInput<'a> {
pub name: &'a str,
pub category_id: Option<&'a Id>,
pub position: Option<u64>,
pub topic: Option<&'a str>,
}
impl<'a> SpaceAddChannelInput<'a> {
pub fn new(name: &'a str) -> Self {
Self {
name,
category_id: None,
position: None,
topic: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceAddRoleInput<'a> {
pub name: &'a str,
pub permissions: &'a [&'a str],
pub position: u64,
pub color: Option<&'a str>,
}
impl<'a> SpaceAddRoleInput<'a> {
pub fn new(name: &'a str, permissions: &'a [&'a str], position: u64) -> Self {
Self {
name,
permissions,
position,
color: None,
}
}
pub fn with_color(mut self, color: &'a str) -> Self {
self.color = Some(color);
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceUpdateRoleInput<'a> {
pub id: &'a Id,
pub name: Option<&'a str>,
pub color: Patch<&'a str>,
pub permissions: Option<&'a [&'a str]>,
pub position: Option<u64>,
}
impl<'a> SpaceUpdateRoleInput<'a> {
pub fn new(id: &'a Id) -> Self {
Self {
id,
name: None,
color: Patch::Keep,
permissions: None,
position: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceUpdateChannelInput<'a> {
pub id: &'a Id,
pub name: Option<&'a str>,
pub topic: Patch<&'a str>,
pub category_id: Patch<&'a Id>,
pub position: Option<u64>,
pub slow_mode_seconds: Option<u64>,
pub permission_overrides: Option<&'a [jmap_chat_types::ChannelPermission]>,
}
impl<'a> SpaceUpdateChannelInput<'a> {
pub fn new(id: &'a Id) -> Self {
Self {
id,
name: None,
topic: Patch::Keep,
category_id: Patch::Keep,
position: None,
slow_mode_seconds: None,
permission_overrides: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceAddCategoryInput<'a> {
pub name: &'a str,
pub position: Option<u64>,
pub channel_ids: Option<&'a [Id]>,
}
impl<'a> SpaceAddCategoryInput<'a> {
pub fn new(name: &'a str) -> Self {
Self {
name,
position: None,
channel_ids: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SpaceUpdateCategoryInput<'a> {
pub id: &'a Id,
pub name: Option<&'a str>,
pub position: Option<u64>,
pub channel_ids: Option<&'a [Id]>,
}
impl<'a> SpaceUpdateCategoryInput<'a> {
pub fn new(id: &'a Id) -> Self {
Self {
id,
name: None,
position: None,
channel_ids: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct SpacePatch<'a> {
pub name: Option<&'a str>,
pub description: Patch<&'a str>,
pub icon_blob_id: Patch<&'a Id>,
pub is_public: Option<bool>,
pub is_publicly_previewable: Option<bool>,
pub add_members: Option<&'a [SpaceAddMemberInput<'a>]>,
pub remove_members: Option<&'a [Id]>,
pub update_members: Option<&'a [SpaceUpdateMemberInput<'a>]>,
pub add_channels: Option<&'a [SpaceAddChannelInput<'a>]>,
pub remove_channels: Option<&'a [Id]>,
pub update_channels: Option<&'a [SpaceUpdateChannelInput<'a>]>,
pub add_roles: Option<&'a [SpaceAddRoleInput<'a>]>,
pub remove_roles: Option<&'a [Id]>,
pub update_roles: Option<&'a [SpaceUpdateRoleInput<'a>]>,
pub add_categories: Option<&'a [SpaceAddCategoryInput<'a>]>,
pub remove_categories: Option<&'a [Id]>,
pub update_categories: Option<&'a [SpaceUpdateCategoryInput<'a>]>,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct PushSubscriptionCreateInput<'a> {
pub client_id: Option<&'a str>,
pub device_client_id: &'a str,
pub url: &'a str,
pub expires: Option<&'a jmap_types::UTCDate>,
pub types: Option<&'a [&'a str]>,
pub chat_push: Option<&'a [(&'a Id, jmap_chat_types::ChatPushConfig)]>,
}
impl<'a> PushSubscriptionCreateInput<'a> {
pub fn new(device_client_id: &'a str, url: &'a str) -> Self {
Self {
client_id: None,
device_client_id,
url,
expires: None,
types: None,
chat_push: None,
}
}
pub fn with_client_id(mut self, id: &'a str) -> Self {
self.client_id = Some(id);
self
}
pub fn with_types(mut self, types: &'a [&'a str]) -> Self {
self.types = Some(types);
self
}
pub fn with_chat_push(
mut self,
chat_push: &'a [(&'a Id, jmap_chat_types::ChatPushConfig)],
) -> Self {
self.chat_push = Some(chat_push);
self
}
}
#[non_exhaustive]
#[derive(Default)]
pub struct PushSubscriptionPatch<'a> {
pub verification_code: Option<&'a str>,
pub expires: Patch<&'a jmap_types::UTCDate>,
pub types: Patch<&'a [&'a str]>,
pub chat_push: Patch<&'a [(&'a Id, jmap_chat_types::ChatPushConfig)]>,
}
impl<'a> std::fmt::Debug for PushSubscriptionPatch<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let redacted_verification_code: Option<&'static str> =
self.verification_code.map(|_| "[REDACTED]");
f.debug_struct("PushSubscriptionPatch")
.field("verification_code", &redacted_verification_code)
.field("expires", &self.expires)
.field("types", &self.types)
.field("chat_push", &self.chat_push)
.finish()
}
}
pub mod chat;
pub mod message;
pub mod space;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerRetryAfterError {
Malformed(serde_json::Value),
}
impl std::fmt::Display for ServerRetryAfterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ServerRetryAfterError::Malformed(raw) => {
write!(f, "serverRetryAfter present but malformed: {raw}")
}
}
}
}
impl std::error::Error for ServerRetryAfterError {}
pub fn server_retry_after(
err: &SetError,
) -> Result<Option<jmap_types::UTCDate>, ServerRetryAfterError> {
match err.extra.get("serverRetryAfter") {
None => Ok(None),
Some(v) => match jmap_types::UTCDate::deserialize(v) {
Ok(d) => Ok(Some(d)),
Err(_) => Err(ServerRetryAfterError::Malformed(v.clone())),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
const _: fn() = || {
fn _assert_send_sync<T: Send + Sync>() {}
_assert_send_sync::<SessionClient>();
};
#[test]
fn patch_keep_via_map_entry() {
let p: Patch<String> = Patch::Keep;
let result = p.map_entry().expect("map_entry must not fail for Keep");
assert!(
result.is_none(),
"Patch::Keep must produce None from map_entry (key omitted from patch)"
);
}
#[test]
fn patch_set_via_map_entry() {
let p = Patch::Set("hello".to_string());
let result = p.map_entry().expect("map_entry must not fail for Set");
assert_eq!(
result,
Some(serde_json::Value::String("hello".to_string())),
"Patch::Set must produce Some(json_value) from map_entry"
);
}
#[test]
fn patch_clear_via_map_entry() {
let p: Patch<String> = Patch::Clear;
let result = p.map_entry().expect("map_entry must not fail for Clear");
assert_eq!(
result,
Some(serde_json::Value::Null),
"Patch::Clear must produce Some(null) from map_entry"
);
}
#[test]
fn space_join_input_invite_code_debug_does_not_leak() {
const CANARY: &str = "CANARY-JOIN-CODE-DO-NOT-LEAK-A1B2C3";
let input = SpaceJoinInput::InviteCode(CANARY);
let dbg = format!("{input:?}");
assert!(
!dbg.contains(CANARY),
"SpaceJoinInput::InviteCode Debug must not contain the raw code; got: {dbg}"
);
}
#[test]
fn space_join_input_space_id_debug_shows_id() {
let id = Id::from("s-public-space");
let input = SpaceJoinInput::SpaceId(&id);
let dbg = format!("{input:?}");
assert!(
dbg.contains("s-public-space"),
"SpaceJoinInput::SpaceId Debug must expose the public id; got: {dbg}"
);
}
#[test]
fn push_subscription_patch_debug_does_not_leak_verification_code() {
const CANARY: &str = "CANARY-VERIFICATION-CODE-DO-NOT-LEAK-D4E5F6";
let patch = PushSubscriptionPatch {
verification_code: Some(CANARY),
..PushSubscriptionPatch::default()
};
let dbg = format!("{patch:?}");
assert!(
!dbg.contains(CANARY),
"PushSubscriptionPatch Debug must not contain the raw verification_code; got: {dbg}"
);
assert!(
dbg.contains("verification_code"),
"PushSubscriptionPatch Debug must still mention the verification_code field name; got: {dbg}"
);
}
#[test]
fn push_subscription_patch_debug_none_verification_code() {
let patch = PushSubscriptionPatch::default();
let dbg = format!("{patch:?}");
assert!(
dbg.contains("verification_code: None"),
"PushSubscriptionPatch Debug with None verification_code must render as None; got: {dbg}"
);
assert!(
!dbg.contains("REDACTED"),
"PushSubscriptionPatch Debug with None verification_code must not show REDACTED; got: {dbg}"
);
}
#[test]
fn push_subscription_create_response_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": null,
"created": {},
"notCreated": {},
"acmeCorpPushBackend": "fcm"
});
let obj: PushSubscriptionCreateResponse =
serde_json::from_value(raw).expect("PushSubscriptionCreateResponse must deserialize");
assert_eq!(
obj.extra
.get("acmeCorpPushBackend")
.and_then(|v| v.as_str()),
Some("fcm")
);
}
#[test]
fn typing_response_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": "acc1",
"acmeCorpEchoLatencyMs": 12
});
let obj: TypingResponse =
serde_json::from_value(raw).expect("TypingResponse must deserialize");
assert_eq!(
obj.extra
.get("acmeCorpEchoLatencyMs")
.and_then(|v| v.as_u64()),
Some(12)
);
}
#[test]
fn space_join_response_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": "acc1",
"spaceId": "S1",
"acmeCorpWelcomeChannelId": "C-welcome"
});
let obj: SpaceJoinResponse =
serde_json::from_value(raw).expect("SpaceJoinResponse must deserialize");
assert_eq!(
obj.extra
.get("acmeCorpWelcomeChannelId")
.and_then(|v| v.as_str()),
Some("C-welcome")
);
}
}