mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
4418 lines
160 KiB
Rust
4418 lines
160 KiB
Rust
//! Room operations: discovery, create, get, join, leave, delete, settings, chat, public settings
|
|
|
|
use crate::impls::ApiError;
|
|
use futures::{stream, StreamExt as _, TryStreamExt as _};
|
|
use std::collections::{HashMap, HashSet};
|
|
use synctv_core::models::{
|
|
ChatMentionInput, ChatMessageEvent, ChatMessageType, ChatMessageWithAttachments, ChatPinEvent,
|
|
ChatPlaybackMessagesQuery, CreateChatAttachmentUploadSession, MarkChatRead, PageParams,
|
|
PlaybackSourceIdentity, SendChatMessage, SetChatReaction, StoreFileUploadResult, UserId,
|
|
};
|
|
use synctv_core::provider::ExecutionControl;
|
|
use synctv_core::service::ClientResourceAvailability;
|
|
|
|
use super::convert::{
|
|
apply_room_settings_patch_from_proto, chat_message_selection_from_proto_values,
|
|
chat_metadata_from_proto, file_metadata_from_proto, provider_target_from_proto,
|
|
room_role_to_proto, room_settings_from_proto, room_settings_to_proto, try_members_to_proto,
|
|
try_playback_state_to_proto,
|
|
};
|
|
use super::media::{
|
|
complete_upload_response_fields, complete_upload_session_request,
|
|
prepare_delete_entries_outbox_fanout, proto_file_range_request, proto_file_upload_range,
|
|
proto_upload_manifest_parts, required_file_upload_reference, room_cover_object_to_proto,
|
|
room_cover_upload_create_result_to_proto, uploaded_parts_response_fields,
|
|
PrepareDeleteEntriesOutboxFanout,
|
|
};
|
|
use super::{ClientApiImpl, RoomActor};
|
|
|
|
mod support;
|
|
use support::*;
|
|
pub use support::{
|
|
build_search_chat_messages_query, parse_optional_room_category_id,
|
|
parse_proto_chat_attachments, parse_room_label_ids,
|
|
};
|
|
#[cfg(test)]
|
|
pub use support::{chat_reaction_count, chat_reaction_summary_to_proto};
|
|
|
|
const CLIENT_ROOM_LOAD_CONCURRENCY: usize = 16;
|
|
|
|
struct DiscoveryRoomProjection {
|
|
room_id: synctv_core::models::RoomId,
|
|
room: synctv_proto::client::Room,
|
|
settings: synctv_core::models::RoomSettings,
|
|
member_count: i32,
|
|
availability: ClientResourceAvailability,
|
|
password_enabled: bool,
|
|
}
|
|
|
|
struct DiscoveryRoomWindow {
|
|
featured_rooms: Vec<synctv_core::models::Room>,
|
|
rooms: Vec<synctv_core::models::Room>,
|
|
total: i32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum DiscoveryReadConsistency {
|
|
Primary,
|
|
EventuallyConsistent,
|
|
}
|
|
|
|
fn discovery_room_list_total(total: i64, featured_count: i64) -> i64 {
|
|
total.saturating_sub(featured_count).max(0)
|
|
}
|
|
|
|
fn required_room_availability(
|
|
availability_map: &HashMap<synctv_core::models::RoomId, ClientResourceAvailability>,
|
|
room_id: &synctv_core::models::RoomId,
|
|
) -> Result<ClientResourceAvailability, ApiError> {
|
|
availability_map.get(room_id).copied().ok_or_else(|| {
|
|
ApiError::Internal(format!(
|
|
"Missing client availability for room {room_id} in batch response"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn room_discovery_access(
|
|
viewer_state: synctv_core::repository::RoomDiscoveryViewerState,
|
|
settings: &synctv_core::models::RoomSettings,
|
|
member_count: i32,
|
|
availability: ClientResourceAvailability,
|
|
password_enabled: bool,
|
|
) -> (bool, i32) {
|
|
use synctv_proto::client::RoomDiscoveryAccess;
|
|
|
|
if !availability.is_available() {
|
|
return (false, RoomDiscoveryAccess::Unavailable as i32);
|
|
}
|
|
if viewer_state.joined {
|
|
return (false, RoomDiscoveryAccess::Enter as i32);
|
|
}
|
|
if viewer_state.pending_join_request {
|
|
return (false, RoomDiscoveryAccess::PendingApproval as i32);
|
|
}
|
|
if viewer_state.in_kick_cooldown {
|
|
return (false, RoomDiscoveryAccess::Cooldown as i32);
|
|
}
|
|
let max_members = settings.max_members.0;
|
|
if max_members > 0 && u64::try_from(member_count).unwrap_or(u64::MAX) >= max_members {
|
|
return (false, RoomDiscoveryAccess::Full as i32);
|
|
}
|
|
if !settings.allow_auto_join.0 {
|
|
return (false, RoomDiscoveryAccess::Invitation as i32);
|
|
}
|
|
if password_enabled {
|
|
return (true, RoomDiscoveryAccess::Password as i32);
|
|
}
|
|
if settings.require_approval.0 {
|
|
return (true, RoomDiscoveryAccess::RequestApproval as i32);
|
|
}
|
|
(true, RoomDiscoveryAccess::Join as i32)
|
|
}
|
|
|
|
fn public_room_discovery_access(
|
|
guest_enabled: bool,
|
|
settings: &synctv_core::models::RoomSettings,
|
|
availability: ClientResourceAvailability,
|
|
password_enabled: bool,
|
|
) -> bool {
|
|
guest_enabled && availability.is_available() && settings.allow_guest_join.0 && !password_enabled
|
|
}
|
|
|
|
fn build_server_time_response(
|
|
req: synctv_proto::client::GetServerTimeRequest,
|
|
clock: &dyn synctv_core::Clock,
|
|
) -> synctv_proto::client::GetServerTimeResponse {
|
|
let server_received_at_nanos = clock.now_nanos();
|
|
let server_sent_at_nanos = clock.now_nanos().max(server_received_at_nanos);
|
|
|
|
synctv_proto::client::GetServerTimeResponse {
|
|
client_sent_at_nanos: req.client_sent_at_nanos,
|
|
server_received_at_nanos,
|
|
server_sent_at_nanos,
|
|
}
|
|
}
|
|
|
|
impl ClientApiImpl {
|
|
async fn load_room_creator_public_views(
|
|
&self,
|
|
rooms: &[synctv_core::models::Room],
|
|
consistency: DiscoveryReadConsistency,
|
|
) -> Result<HashMap<UserId, synctv_proto::client::UserPublicView>, ApiError> {
|
|
let creator_ids: Vec<UserId> = rooms
|
|
.iter()
|
|
.map(|room| room.created_by)
|
|
.collect::<std::collections::HashSet<_>>()
|
|
.into_iter()
|
|
.collect();
|
|
|
|
let mut creators = match consistency {
|
|
DiscoveryReadConsistency::Primary => self
|
|
.user_service
|
|
.get_users_by_ids(&creator_ids)
|
|
.await
|
|
.map_err(ApiError::from)?,
|
|
DiscoveryReadConsistency::EventuallyConsistent => self
|
|
.user_service
|
|
.get_users_by_ids_eventually_consistent(&creator_ids)
|
|
.await
|
|
.map_err(ApiError::from)?,
|
|
};
|
|
|
|
if consistency == DiscoveryReadConsistency::EventuallyConsistent {
|
|
let loaded_ids = creators
|
|
.iter()
|
|
.map(|creator| creator.id)
|
|
.collect::<std::collections::HashSet<_>>();
|
|
let missing_ids = creator_ids
|
|
.iter()
|
|
.copied()
|
|
.filter(|creator_id| !loaded_ids.contains(creator_id))
|
|
.collect::<Vec<_>>();
|
|
if !missing_ids.is_empty() {
|
|
creators.extend(
|
|
self.user_service
|
|
.get_users_by_ids(&missing_ids)
|
|
.await
|
|
.map_err(ApiError::from)?,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Batch load all avatars in parallel
|
|
let creator_views = self
|
|
.batch_user_public_views_with_loaded_avatars(&creators)
|
|
.await?;
|
|
|
|
// Build map from user ID to public view
|
|
let mut map = HashMap::with_capacity(creator_views.len());
|
|
for (i, creator) in creators.iter().enumerate() {
|
|
map.insert(creator.id, creator_views[i].clone());
|
|
}
|
|
|
|
Ok(map)
|
|
}
|
|
|
|
async fn load_room_member_count(
|
|
&self,
|
|
room_id: &synctv_core::models::RoomId,
|
|
) -> Result<Option<i32>, ApiError> {
|
|
self.room_service
|
|
.get_member_count(room_id)
|
|
.await
|
|
.map(Some)
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
async fn favorite_room_ids_for_viewer_eventually_consistent(
|
|
&self,
|
|
viewer_id: Option<UserId>,
|
|
room_ids: &[synctv_core::models::RoomId],
|
|
) -> Result<std::collections::HashSet<synctv_core::models::RoomId>, ApiError> {
|
|
match viewer_id {
|
|
Some(user_id) => self
|
|
.room_service
|
|
.favorite_room_ids_for_user_eventually_consistent(&user_id, room_ids)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
None => Ok(std::collections::HashSet::new()),
|
|
}
|
|
}
|
|
|
|
async fn room_to_proto_for_favorite_view(
|
|
&self,
|
|
room: &synctv_core::models::Room,
|
|
creator_blocked: bool,
|
|
) -> Result<synctv_proto::client::Room, ApiError> {
|
|
let (settings, presence, availability, member_count) = tokio::join!(
|
|
self.room_service.get_room_settings(&room.id),
|
|
self.presence_service.room_stats(room.id),
|
|
self.room_service.room_availability(room),
|
|
self.load_room_member_count(&room.id),
|
|
);
|
|
let settings = settings.map_err(ApiError::from)?;
|
|
let presence = presence.map_err(ApiError::from)?;
|
|
let availability = availability.map_err(ApiError::from)?;
|
|
let member_count = member_count?;
|
|
let mut proto_room = self
|
|
.room_to_proto_with_availability_presence_and_loaded_cover(
|
|
room,
|
|
Some(&settings),
|
|
member_count,
|
|
availability,
|
|
Some(&presence),
|
|
None,
|
|
)
|
|
.await?;
|
|
proto_room.creator_blocked = creator_blocked;
|
|
Ok(proto_room)
|
|
}
|
|
|
|
async fn load_room_playback_state_proto(
|
|
&self,
|
|
room_id: &synctv_core::models::RoomId,
|
|
) -> Result<synctv_proto::client::PlaybackState, ApiError> {
|
|
let state = self
|
|
.room_service
|
|
.get_playback_state(room_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
try_playback_state_to_proto(&state, &self.public_id_codec)
|
|
}
|
|
|
|
async fn user_username_for_event(&self, user_id: &UserId) -> Result<String, ApiError> {
|
|
self.user_service
|
|
.get_user(user_id)
|
|
.await
|
|
.map(|user| user.username)
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
/// Get the currently playing media for a room.
|
|
///
|
|
/// Requires the caller to be a member of the room.
|
|
pub async fn get_playing_media(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
) -> Result<Option<synctv_proto::client::Media>, ApiError> {
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
|
|
// Check membership before returning playing media
|
|
self.room_service
|
|
.check_membership(&rid, &uid)
|
|
.await
|
|
.map_err(Self::map_room_access_error)?;
|
|
|
|
let media = self
|
|
.room_service
|
|
.get_playing_media(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
match media {
|
|
Some(media) => {
|
|
let availability = self
|
|
.room_service
|
|
.media_availability(&media)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(Some(
|
|
self.media_to_proto_for_viewer_with_loaded_cover(
|
|
&media,
|
|
availability.is_available(),
|
|
Some(uid),
|
|
)
|
|
.await?,
|
|
))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
async fn rooms_to_discovery_projections(
|
|
&self,
|
|
rooms: Vec<synctv_core::models::Room>,
|
|
consistency: DiscoveryReadConsistency,
|
|
) -> Result<Vec<DiscoveryRoomProjection>, ApiError> {
|
|
let room_ids = rooms.iter().map(|room| room.id).collect::<Vec<_>>();
|
|
let room_id_refs = room_ids.iter().collect::<Vec<_>>();
|
|
let (
|
|
availability_map,
|
|
member_counts,
|
|
room_settings_map,
|
|
password_room_ids,
|
|
presence_stats,
|
|
creator_views,
|
|
) = tokio::try_join!(
|
|
async {
|
|
match consistency {
|
|
DiscoveryReadConsistency::Primary => self
|
|
.room_service
|
|
.room_availability_batch(&rooms)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
DiscoveryReadConsistency::EventuallyConsistent => self
|
|
.room_service
|
|
.room_availability_batch_eventually_consistent(&rooms)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
}
|
|
},
|
|
async {
|
|
match consistency {
|
|
DiscoveryReadConsistency::Primary => self
|
|
.room_service
|
|
.get_member_count_batch(&room_id_refs)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
DiscoveryReadConsistency::EventuallyConsistent => self
|
|
.room_service
|
|
.get_member_count_batch_eventually_consistent(&room_id_refs)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
}
|
|
},
|
|
async {
|
|
match consistency {
|
|
DiscoveryReadConsistency::Primary => self
|
|
.room_service
|
|
.get_room_settings_batch(&room_ids)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
DiscoveryReadConsistency::EventuallyConsistent => self
|
|
.room_service
|
|
.get_room_settings_batch_eventually_consistent(&room_ids)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
}
|
|
},
|
|
async {
|
|
match consistency {
|
|
DiscoveryReadConsistency::Primary => self
|
|
.room_service
|
|
.password_enabled_room_ids(&room_ids)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
DiscoveryReadConsistency::EventuallyConsistent => self
|
|
.room_service
|
|
.password_enabled_room_ids_eventually_consistent(&room_ids)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
}
|
|
},
|
|
async {
|
|
self.presence_service
|
|
.room_stats_batch(&room_ids)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
self.load_room_creator_public_views(&rooms, consistency),
|
|
)?;
|
|
let presence_by_room = presence_stats
|
|
.iter()
|
|
.map(|stats| (stats.room_id, stats))
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
let rooms_with_creators = rooms
|
|
.into_iter()
|
|
.filter_map(|room| {
|
|
creator_views
|
|
.get(&room.created_by)
|
|
.cloned()
|
|
.map(|creator| (room, creator))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
stream::iter(rooms_with_creators)
|
|
.map(|(room, creator)| {
|
|
let availability_map = &availability_map;
|
|
let member_counts = &member_counts;
|
|
let room_settings_map = &room_settings_map;
|
|
let password_room_ids = &password_room_ids;
|
|
let presence_by_room = &presence_by_room;
|
|
async move {
|
|
let member_count =
|
|
crate::impls::room_member_count_or_zero(member_counts, &room.id);
|
|
let availability = required_room_availability(availability_map, &room.id)?;
|
|
let settings = required_room_settings(room_settings_map, &room.id)?;
|
|
let proto_room = self
|
|
.room_to_proto_with_availability_presence_and_loaded_cover(
|
|
&room,
|
|
Some(settings),
|
|
Some(member_count),
|
|
availability,
|
|
presence_by_room.get(&room.id).copied(),
|
|
Some(creator),
|
|
)
|
|
.await?;
|
|
Ok::<_, ApiError>(DiscoveryRoomProjection {
|
|
room_id: room.id,
|
|
room: proto_room,
|
|
settings: settings.clone(),
|
|
member_count,
|
|
availability,
|
|
password_enabled: password_room_ids.contains(&room.id),
|
|
})
|
|
}
|
|
})
|
|
.buffered(CLIENT_ROOM_LOAD_CONCURRENCY)
|
|
.try_collect()
|
|
.await
|
|
}
|
|
|
|
async fn rooms_to_discovery_items(
|
|
&self,
|
|
rooms: Vec<synctv_core::models::Room>,
|
|
viewer_id: &UserId,
|
|
consistency: DiscoveryReadConsistency,
|
|
) -> Result<Vec<synctv_proto::client::RoomDiscoveryItem>, ApiError> {
|
|
let room_ids = rooms.iter().map(|room| room.id).collect::<Vec<_>>();
|
|
let (projections, mut viewer_states) = tokio::try_join!(
|
|
self.rooms_to_discovery_projections(rooms, consistency),
|
|
async {
|
|
match consistency {
|
|
DiscoveryReadConsistency::Primary => self
|
|
.room_service
|
|
.discovery_viewer_states(viewer_id, &room_ids)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
DiscoveryReadConsistency::EventuallyConsistent => self
|
|
.room_service
|
|
.discovery_viewer_states_eventually_consistent(viewer_id, &room_ids)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
}
|
|
},
|
|
)?;
|
|
|
|
if consistency == DiscoveryReadConsistency::EventuallyConsistent {
|
|
let missing_room_ids = room_ids
|
|
.iter()
|
|
.copied()
|
|
.filter(|room_id| !viewer_states.contains_key(room_id))
|
|
.collect::<Vec<_>>();
|
|
if !missing_room_ids.is_empty() {
|
|
viewer_states.extend(
|
|
self.room_service
|
|
.discovery_viewer_states(viewer_id, &missing_room_ids)
|
|
.await
|
|
.map_err(ApiError::from)?,
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(projections
|
|
.into_iter()
|
|
.filter_map(|projection| {
|
|
let viewer_state = viewer_states.get(&projection.room_id).copied()?;
|
|
let (can_join, access) = room_discovery_access(
|
|
viewer_state,
|
|
&projection.settings,
|
|
projection.member_count,
|
|
projection.availability,
|
|
projection.password_enabled,
|
|
);
|
|
Some(synctv_proto::client::RoomDiscoveryItem {
|
|
room: Some(projection.room),
|
|
joined: viewer_state.joined,
|
|
favorited: viewer_state.favorited,
|
|
can_join,
|
|
access,
|
|
})
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn rooms_to_public_discovery_items(
|
|
&self,
|
|
rooms: Vec<synctv_core::models::Room>,
|
|
guest_enabled: bool,
|
|
consistency: DiscoveryReadConsistency,
|
|
) -> Result<Vec<synctv_proto::client::RoomDiscoveryItem>, ApiError> {
|
|
let rooms = rooms.into_iter().filter(|room| room.is_public).collect();
|
|
Ok(self
|
|
.rooms_to_discovery_projections(rooms, consistency)
|
|
.await?
|
|
.into_iter()
|
|
.map(|projection| {
|
|
let can_join = public_room_discovery_access(
|
|
guest_enabled,
|
|
&projection.settings,
|
|
projection.availability,
|
|
projection.password_enabled,
|
|
);
|
|
let access = if !projection.availability.is_available() {
|
|
synctv_proto::client::RoomDiscoveryAccess::Unavailable
|
|
} else if can_join {
|
|
synctv_proto::client::RoomDiscoveryAccess::Guest
|
|
} else {
|
|
synctv_proto::client::RoomDiscoveryAccess::SignIn
|
|
};
|
|
synctv_proto::client::RoomDiscoveryItem {
|
|
room: Some(projection.room),
|
|
joined: false,
|
|
favorited: false,
|
|
can_join,
|
|
access: access as i32,
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn public_guest_access_enabled(&self) -> Result<bool, ApiError> {
|
|
self.runtime_settings_store
|
|
.as_ref()
|
|
.map_or(Ok(false), |settings| {
|
|
settings.user.enable_guest.get().map_err(ApiError::from)
|
|
})
|
|
}
|
|
|
|
pub async fn get_room_discovery(
|
|
&self,
|
|
viewer_id: &UserId,
|
|
req: synctv_proto::client::GetRoomDiscoveryRequest,
|
|
) -> Result<synctv_proto::client::RoomDiscoveryItem, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let room_id = self.parse_room_id(&req.room_id)?;
|
|
let room = self
|
|
.room_service
|
|
.get_room(&room_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if room.is_banned {
|
|
let is_member = self
|
|
.room_service
|
|
.member_service()
|
|
.is_member(&room_id, viewer_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let is_admin = self
|
|
.user_service
|
|
.get_user(viewer_id)
|
|
.await
|
|
.map_err(ApiError::from)?
|
|
.role
|
|
.is_admin_or_above();
|
|
if !is_member && !is_admin {
|
|
return Err(ApiError::NotFound("Room not found".to_string()));
|
|
}
|
|
} else if room.status.is_closed() {
|
|
return Err(ApiError::NotFound("Room not found".to_string()));
|
|
}
|
|
let creator_blocked = self
|
|
.user_service
|
|
.is_blocking(viewer_id, &room.created_by)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let mut item = self
|
|
.rooms_to_discovery_items(vec![room], viewer_id, DiscoveryReadConsistency::Primary)
|
|
.await?
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| ApiError::NotFound("Room not found".to_string()))?;
|
|
if let Some(room) = item.room.as_mut() {
|
|
room.creator_blocked = creator_blocked;
|
|
}
|
|
Ok(item)
|
|
}
|
|
|
|
pub async fn get_public_room_discovery(
|
|
&self,
|
|
req: synctv_proto::client::GetRoomDiscoveryRequest,
|
|
) -> Result<synctv_proto::client::RoomDiscoveryItem, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let room_id = self.parse_room_id(&req.room_id)?;
|
|
let room = self
|
|
.room_service
|
|
.list_active_unbanned_rooms_by_ids(&[room_id])
|
|
.await
|
|
.map_err(ApiError::from)?
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| ApiError::NotFound("Room not found".to_string()))?;
|
|
if !room.is_public {
|
|
return Err(ApiError::NotFound("Room not found".to_string()));
|
|
}
|
|
self.rooms_to_public_discovery_items(
|
|
vec![room],
|
|
self.public_guest_access_enabled()?,
|
|
DiscoveryReadConsistency::Primary,
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| ApiError::NotFound("Room not found".to_string()))
|
|
}
|
|
|
|
async fn discover_room_window(
|
|
&self,
|
|
viewer_id: Option<&UserId>,
|
|
req: synctv_proto::client::DiscoverRoomsRequest,
|
|
) -> Result<DiscoveryRoomWindow, ApiError> {
|
|
let mut query = build_room_discovery_query(req, &self.public_id_codec)?;
|
|
if let Some(viewer_id) = viewer_id {
|
|
query.excluded_creator_ids = self
|
|
.user_service
|
|
.blocked_user_ids_eventually_consistent(viewer_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
}
|
|
let hot_stats = self
|
|
.presence_service
|
|
.hot_room_stats()
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let hot_room_ids = hot_stats
|
|
.iter()
|
|
.map(|stats| stats.room_id)
|
|
.collect::<Vec<_>>();
|
|
let has_filters =
|
|
query.search.is_some() || query.category_id.is_some() || !query.label_ids.is_empty();
|
|
let featured_count = if has_filters {
|
|
0
|
|
} else {
|
|
u64::try_from(DISCOVERY_FEATURED_ROOM_COUNT).map_err(|_| {
|
|
ApiError::Internal("featured room count exceeds u64::MAX".to_string())
|
|
})?
|
|
};
|
|
let page_offset = query.pagination.offset();
|
|
let page_size = query.pagination.limit();
|
|
let include_featured = featured_count > 0 && query.pagination.page == 1;
|
|
let offset = if include_featured {
|
|
0
|
|
} else {
|
|
page_offset.saturating_add(featured_count)
|
|
};
|
|
let window_size = if include_featured {
|
|
page_size.saturating_add(featured_count)
|
|
} else {
|
|
page_size
|
|
};
|
|
let (mut rooms, hot_total) = self
|
|
.room_service
|
|
.list_ranked_rooms_window_eventually_consistent(
|
|
&query,
|
|
&hot_room_ids,
|
|
offset,
|
|
window_size,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let hot_count = u64::try_from(hot_total)
|
|
.map_err(|_| ApiError::Internal("hot room count exceeds u64::MAX".to_string()))?;
|
|
let loaded_hot_count = u64::try_from(rooms.len())
|
|
.map_err(|_| ApiError::Internal("hot room window exceeds u64::MAX".to_string()))?;
|
|
let offline_limit = window_size.saturating_sub(loaded_hot_count);
|
|
let offline_offset = offset.saturating_sub(hot_count);
|
|
let (offline_rooms, offline_total) = self
|
|
.room_service
|
|
.list_rooms_window_excluding_eventually_consistent(
|
|
&query,
|
|
&hot_room_ids,
|
|
offline_offset,
|
|
offline_limit,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
rooms.extend(offline_rooms);
|
|
let total = hot_total
|
|
.checked_add(offline_total)
|
|
.ok_or_else(|| ApiError::Internal("room discovery total overflow".to_string()))?;
|
|
|
|
let mut room_list = rooms;
|
|
let featured_rooms = if include_featured {
|
|
let split_at = DISCOVERY_FEATURED_ROOM_COUNT.min(room_list.len());
|
|
room_list.drain(..split_at).collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let featured_count = i64::try_from(featured_count)
|
|
.map_err(|_| ApiError::Internal("featured room count exceeds i64::MAX".to_string()))?;
|
|
let rooms_total = discovery_room_list_total(total, featured_count);
|
|
|
|
Ok(DiscoveryRoomWindow {
|
|
featured_rooms,
|
|
rooms: room_list,
|
|
total: i64_to_i32_api(rooms_total, "room discovery total")?,
|
|
})
|
|
}
|
|
|
|
pub async fn discover_rooms(
|
|
&self,
|
|
viewer_id: &UserId,
|
|
req: synctv_proto::client::DiscoverRoomsRequest,
|
|
) -> Result<synctv_proto::client::DiscoverRoomsResponse, ApiError> {
|
|
let window = self.discover_room_window(Some(viewer_id), req).await?;
|
|
let (featured_rooms, rooms) = tokio::try_join!(
|
|
self.rooms_to_discovery_items(
|
|
window.featured_rooms,
|
|
viewer_id,
|
|
DiscoveryReadConsistency::EventuallyConsistent,
|
|
),
|
|
self.rooms_to_discovery_items(
|
|
window.rooms,
|
|
viewer_id,
|
|
DiscoveryReadConsistency::EventuallyConsistent,
|
|
),
|
|
)?;
|
|
Ok(synctv_proto::client::DiscoverRoomsResponse {
|
|
featured_rooms,
|
|
rooms,
|
|
total: window.total,
|
|
})
|
|
}
|
|
|
|
pub async fn discover_public_rooms(
|
|
&self,
|
|
req: synctv_proto::client::DiscoverRoomsRequest,
|
|
) -> Result<synctv_proto::client::DiscoverRoomsResponse, ApiError> {
|
|
let window = self.discover_room_window(None, req).await?;
|
|
let guest_enabled = self.public_guest_access_enabled()?;
|
|
let (featured_rooms, rooms) = tokio::try_join!(
|
|
self.rooms_to_public_discovery_items(
|
|
window.featured_rooms,
|
|
guest_enabled,
|
|
DiscoveryReadConsistency::EventuallyConsistent,
|
|
),
|
|
self.rooms_to_public_discovery_items(
|
|
window.rooms,
|
|
guest_enabled,
|
|
DiscoveryReadConsistency::EventuallyConsistent,
|
|
),
|
|
)?;
|
|
Ok(synctv_proto::client::DiscoverRoomsResponse {
|
|
featured_rooms,
|
|
rooms,
|
|
total: window.total,
|
|
})
|
|
}
|
|
|
|
pub async fn list_my_rooms(
|
|
&self,
|
|
user_id: &UserId,
|
|
req: synctv_proto::client::ListMyRoomsRequest,
|
|
) -> Result<synctv_proto::client::ListMyRoomsResponse, ApiError> {
|
|
let uid = *user_id;
|
|
let query = build_my_room_list_query(req)?;
|
|
let (rooms, total) = self
|
|
.room_service
|
|
.list_accessible_joined_rooms_with_query_eventually_consistent(&uid, &query)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
// Batch-fetch room settings for full permission calculation.
|
|
let room_ids: Vec<synctv_core::models::RoomId> =
|
|
rooms.iter().map(|(room, _, _)| room.id).collect();
|
|
let room_models: Vec<synctv_core::models::Room> =
|
|
rooms.iter().map(|(room, _, _)| room.clone()).collect();
|
|
let (
|
|
room_settings_map,
|
|
presence_stats,
|
|
availability_map,
|
|
creator_views,
|
|
favorite_room_ids,
|
|
blocked_creator_ids,
|
|
) = tokio::try_join!(
|
|
async {
|
|
self.room_service
|
|
.get_room_settings_batch_eventually_consistent(&room_ids)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
async {
|
|
self.presence_service
|
|
.room_stats_batch(&room_ids)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
async {
|
|
self.room_service
|
|
.room_availability_batch_eventually_consistent(&room_models)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
self.load_room_creator_public_views(
|
|
&room_models,
|
|
DiscoveryReadConsistency::EventuallyConsistent,
|
|
),
|
|
self.favorite_room_ids_for_viewer_eventually_consistent(Some(uid), &room_ids),
|
|
async {
|
|
self.user_service
|
|
.blocked_user_ids_eventually_consistent(&uid)
|
|
.await
|
|
.map(|ids| ids.into_iter().collect::<HashSet<_>>())
|
|
.map_err(ApiError::from)
|
|
},
|
|
)?;
|
|
let presence_by_room: HashMap<synctv_core::models::RoomId, _> = presence_stats
|
|
.iter()
|
|
.map(|stats| (stats.room_id, stats))
|
|
.collect();
|
|
let rooms_with_creators = rooms
|
|
.into_iter()
|
|
.filter_map(|room_details| {
|
|
creator_views
|
|
.get(&room_details.0.created_by)
|
|
.cloned()
|
|
.map(|creator| (room_details, creator))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let room_list = stream::iter(rooms_with_creators)
|
|
.map(|((room, role, member_count), creator)| {
|
|
let room_settings_map = &room_settings_map;
|
|
let presence_by_room = &presence_by_room;
|
|
let availability_map = &availability_map;
|
|
let favorite_room_ids = &favorite_room_ids;
|
|
let blocked_creator_ids = &blocked_creator_ids;
|
|
async move {
|
|
let settings = required_room_settings(room_settings_map, &room.id)?;
|
|
let availability = required_room_availability(availability_map, &room.id)?;
|
|
let permissions = self
|
|
.room_service
|
|
.permission_service()
|
|
.calculate_role_default_permissions(&role, settings)
|
|
.0;
|
|
let relation = if room.created_by == uid {
|
|
synctv_proto::client::MyRoomRelation::Created as i32
|
|
} else {
|
|
synctv_proto::client::MyRoomRelation::Participating as i32
|
|
};
|
|
let mut proto_room = self
|
|
.room_to_proto_with_availability_presence_and_loaded_cover(
|
|
&room,
|
|
Some(settings),
|
|
Some(member_count),
|
|
availability,
|
|
presence_by_room.get(&room.id).copied(),
|
|
Some(creator),
|
|
)
|
|
.await?;
|
|
proto_room.creator_blocked = blocked_creator_ids.contains(&room.created_by);
|
|
Ok::<_, ApiError>(synctv_proto::client::MyRoom {
|
|
room: Some(proto_room),
|
|
permissions,
|
|
role: room_role_to_proto(role),
|
|
relation,
|
|
favorited: favorite_room_ids.contains(&room.id),
|
|
})
|
|
}
|
|
})
|
|
.buffered(CLIENT_ROOM_LOAD_CONCURRENCY)
|
|
.try_collect()
|
|
.await?;
|
|
|
|
Ok(synctv_proto::client::ListMyRoomsResponse {
|
|
rooms: room_list,
|
|
total: i64_to_i32_api(total, "my room total")?,
|
|
})
|
|
}
|
|
|
|
pub async fn favorite_room(
|
|
&self,
|
|
user_id: &UserId,
|
|
req: synctv_proto::client::FavoriteRoomRequest,
|
|
) -> Result<synctv_proto::client::FavoriteRoomResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let room_id = self.parse_room_id(&req.room_id)?;
|
|
let room = self
|
|
.room_service
|
|
.favorite_room(&uid, &room_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let creator_blocked = self
|
|
.user_service
|
|
.is_blocking(user_id, &room.created_by)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let proto_room = self
|
|
.room_to_proto_for_favorite_view(&room, creator_blocked)
|
|
.await?;
|
|
Ok(synctv_proto::client::FavoriteRoomResponse {
|
|
room: Some(proto_room),
|
|
})
|
|
}
|
|
|
|
pub async fn unfavorite_room(
|
|
&self,
|
|
user_id: &UserId,
|
|
req: synctv_proto::client::UnfavoriteRoomRequest,
|
|
) -> Result<synctv_proto::client::UnfavoriteRoomResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let room_id = self.parse_room_id(&req.room_id)?;
|
|
let room = self
|
|
.room_service
|
|
.unfavorite_room(&uid, &room_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let creator_blocked = self
|
|
.user_service
|
|
.is_blocking(user_id, &room.created_by)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let proto_room = self
|
|
.room_to_proto_for_favorite_view(&room, creator_blocked)
|
|
.await?;
|
|
Ok(synctv_proto::client::UnfavoriteRoomResponse {
|
|
room: Some(proto_room),
|
|
})
|
|
}
|
|
|
|
pub async fn list_favorite_rooms(
|
|
&self,
|
|
user_id: &UserId,
|
|
req: synctv_proto::client::ListFavoriteRoomsRequest,
|
|
) -> Result<synctv_proto::client::ListFavoriteRoomsResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let page = positive_i32_to_u32(req.page, DEFAULT_ROOM_PAGE);
|
|
let page_size = if req.page_size > 0 {
|
|
req.page_size.cast_unsigned().min(MAX_ROOM_PAGE_SIZE)
|
|
} else {
|
|
DEFAULT_ROOM_PAGE_SIZE
|
|
};
|
|
let pagination = PageParams::new(Some(page), Some(page_size));
|
|
let search = (!req.search.is_empty()).then_some(req.search);
|
|
let (rooms, total) = self
|
|
.room_service
|
|
.list_favorite_rooms(&uid, pagination, search.as_deref())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let blocked_creator_ids = self
|
|
.user_service
|
|
.blocked_user_ids(user_id)
|
|
.await
|
|
.map_err(ApiError::from)?
|
|
.into_iter()
|
|
.collect::<HashSet<_>>();
|
|
let rooms_ref = &rooms;
|
|
let blocked_creator_ids = &blocked_creator_ids;
|
|
let response_rooms = stream::iter(0..rooms.len())
|
|
.map(|index| async move {
|
|
let room = &rooms_ref[index];
|
|
self.room_to_proto_for_favorite_view(
|
|
room,
|
|
blocked_creator_ids.contains(&room.created_by),
|
|
)
|
|
.await
|
|
})
|
|
.buffered(16)
|
|
.try_collect()
|
|
.await?;
|
|
|
|
Ok(synctv_proto::client::ListFavoriteRoomsResponse {
|
|
rooms: response_rooms,
|
|
total: i64_to_i32_api(total, "favorite room total")?,
|
|
})
|
|
}
|
|
|
|
pub async fn create_room(
|
|
&self,
|
|
user_id: &UserId,
|
|
mut req: synctv_proto::client::CreateRoomRequest,
|
|
) -> Result<synctv_proto::client::Room, ApiError> {
|
|
// Validate and sanitize room name
|
|
req.name = crate::impls::validate_room_name_input(&req.name)?;
|
|
|
|
// Validate and sanitize room description against ROOM_DESCRIPTION_MAX
|
|
if !req.description.is_empty() {
|
|
req.description = crate::impls::validate_room_description_input(&req.description)?;
|
|
}
|
|
|
|
crate::impls::validate_proto_request(&req)?;
|
|
|
|
let uid = *user_id;
|
|
|
|
let settings = req
|
|
.settings
|
|
.map(|settings| room_settings_from_proto(Some(settings)))
|
|
.transpose()?;
|
|
let password = if req.password.is_empty() {
|
|
None
|
|
} else {
|
|
validate_room_password_for_set(&req.password)?;
|
|
Some(req.password)
|
|
};
|
|
let category_id = parse_optional_room_category_id(&req.category_id, &self.public_id_codec)?;
|
|
let label_ids = parse_room_label_ids(&req.label_ids, &self.public_id_codec)?;
|
|
|
|
let response_settings = settings.clone().unwrap_or_default();
|
|
let prepared_outbox_fanout = self
|
|
.room_lifecycle_fanout
|
|
.prepare_room_created_outbox_fanout(uid);
|
|
let room = self
|
|
.room_service
|
|
.create_room_with_taxonomy_outbox(
|
|
synctv_core::service::CreateRoomWithTaxonomyRequest {
|
|
name: req.name,
|
|
description: req.description,
|
|
created_by: uid,
|
|
password,
|
|
settings,
|
|
category_id,
|
|
label_ids,
|
|
is_public: req.is_public.unwrap_or(true),
|
|
},
|
|
Some(prepared_outbox_fanout.outbox_factory()),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
prepared_outbox_fanout.publish_after_outbox_commit();
|
|
|
|
self.room_to_proto_basic_with_loaded_cover(
|
|
&room,
|
|
Some(&response_settings),
|
|
self.load_room_member_count(&room.id).await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_room_categories(
|
|
&self,
|
|
req: synctv_proto::client::ListRoomCategoriesRequest,
|
|
) -> Result<synctv_proto::client::ListRoomCategoriesResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let categories = self
|
|
.room_service
|
|
.list_room_categories_eventually_consistent(!req.include_disabled)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(synctv_proto::client::ListRoomCategoriesResponse {
|
|
categories: categories
|
|
.iter()
|
|
.map(|category| {
|
|
crate::impls::client::convert::room_category_to_proto(
|
|
category,
|
|
&self.public_id_codec,
|
|
)
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
})
|
|
}
|
|
|
|
pub async fn list_room_labels(
|
|
&self,
|
|
req: synctv_proto::client::ListRoomLabelsRequest,
|
|
) -> Result<synctv_proto::client::ListRoomLabelsResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let category_id = parse_optional_room_category_id(&req.category_id, &self.public_id_codec)?;
|
|
let labels = self
|
|
.room_service
|
|
.list_room_labels_eventually_consistent(!req.include_disabled, category_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(synctv_proto::client::ListRoomLabelsResponse {
|
|
labels: labels
|
|
.iter()
|
|
.map(|label| {
|
|
crate::impls::client::convert::room_label_to_proto(label, &self.public_id_codec)
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
})
|
|
}
|
|
|
|
pub async fn get_room(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
) -> Result<synctv_proto::client::GetRoomResponse, ApiError> {
|
|
let actor = self.room_actor_for_user(user_id, room_id).await?;
|
|
self.get_room_for_actor(&actor).await
|
|
}
|
|
|
|
pub async fn get_room_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
) -> Result<synctv_proto::client::GetRoomResponse, ApiError> {
|
|
let rid = actor.room_id();
|
|
let room = self
|
|
.room_service
|
|
.get_room(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
let favorite = async {
|
|
match actor.user_id() {
|
|
Some(user_id) => self
|
|
.room_service
|
|
.is_room_favorited_by_user(&user_id, &rid)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
None => Ok(false),
|
|
}
|
|
};
|
|
let creator_blocked = async {
|
|
match actor.user_id() {
|
|
Some(user_id) => self
|
|
.user_service
|
|
.is_blocking(&user_id, &room.created_by)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
None => Ok(false),
|
|
}
|
|
};
|
|
let (playback_state, settings, presence, member_count, favorited, creator_blocked) = tokio::try_join!(
|
|
self.load_room_playback_state_proto(&rid),
|
|
async {
|
|
self.room_service
|
|
.get_room_settings(&rid)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
async {
|
|
self.presence_service
|
|
.room_stats(rid)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
self.load_room_member_count(&rid),
|
|
favorite,
|
|
creator_blocked,
|
|
)?;
|
|
let availability = self
|
|
.room_service
|
|
.room_availability(&room)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
let mut proto_room = self
|
|
.room_to_proto_with_availability_presence_and_loaded_cover(
|
|
&room,
|
|
Some(&settings),
|
|
member_count,
|
|
availability,
|
|
Some(&presence),
|
|
None,
|
|
)
|
|
.await?;
|
|
proto_room.creator_blocked = creator_blocked;
|
|
Ok(synctv_proto::client::GetRoomResponse {
|
|
room: Some(proto_room),
|
|
playback_state: Some(playback_state),
|
|
favorited,
|
|
})
|
|
}
|
|
|
|
async fn room_response_after_room_update(
|
|
&self,
|
|
room: &synctv_core::models::Room,
|
|
viewer_id: &UserId,
|
|
) -> Result<synctv_proto::client::GetRoomResponse, ApiError> {
|
|
let rid = room.id;
|
|
let (settings, member_count, playback_state, favorited) = tokio::try_join!(
|
|
async {
|
|
self.room_service
|
|
.get_room_settings(&rid)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
self.load_room_member_count(&rid),
|
|
self.load_room_playback_state_proto(&rid),
|
|
async {
|
|
self.room_service
|
|
.is_room_favorited_by_user(viewer_id, &rid)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
},
|
|
)?;
|
|
Ok(synctv_proto::client::GetRoomResponse {
|
|
room: Some(
|
|
self.room_to_proto_basic_with_loaded_cover(room, Some(&settings), member_count)
|
|
.await?,
|
|
),
|
|
playback_state: Some(playback_state),
|
|
favorited,
|
|
})
|
|
}
|
|
|
|
pub async fn create_room_cover_upload_session(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::CreateRoomCoverUploadSessionRequest,
|
|
) -> Result<synctv_proto::client::CreateRoomCoverUploadSessionResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let session = self
|
|
.room_service
|
|
.create_room_cover_upload_session(
|
|
rid,
|
|
*user_id,
|
|
synctv_core::service::CreateRoomCoverUploadSession {
|
|
client_cover_id: optional_trimmed_string(&req.client_cover_id),
|
|
mime_type: req.mime_type,
|
|
size_bytes: req.size_bytes,
|
|
width: (req.width > 0).then_some(req.width),
|
|
height: (req.height > 0).then_some(req.height),
|
|
duration_seconds: (req.duration_seconds > 0).then_some(req.duration_seconds),
|
|
bitrate_bps: (req.bitrate_bps > 0).then_some(req.bitrate_bps),
|
|
parts: proto_upload_manifest_parts(req.parts),
|
|
metadata: file_metadata_from_proto(req.metadata.as_ref())?,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
room_cover_upload_create_result_to_proto(session)
|
|
}
|
|
|
|
pub async fn upload_room_cover_object(
|
|
&self,
|
|
req: synctv_proto::client::UploadRoomCoverObjectRequest,
|
|
) -> Result<synctv_proto::client::UploadRoomCoverObjectResponse, ApiError> {
|
|
let blob = self
|
|
.room_service
|
|
.store_room_cover_upload_object(
|
|
&req.encoded_object_key,
|
|
&req.token,
|
|
req.content_type.as_deref(),
|
|
proto_file_upload_range(req.content_range),
|
|
req.data,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let (complete, uploaded_size_bytes, uploaded_parts) = uploaded_parts_response_fields(&blob);
|
|
Ok(synctv_proto::client::UploadRoomCoverObjectResponse {
|
|
object: match blob {
|
|
StoreFileUploadResult::Complete(blob) => Some(room_cover_object_to_proto(blob)),
|
|
StoreFileUploadResult::PartAccepted { .. } => None,
|
|
},
|
|
complete,
|
|
uploaded_size_bytes,
|
|
uploaded_parts,
|
|
})
|
|
}
|
|
|
|
pub async fn complete_room_cover_upload_session(
|
|
&self,
|
|
req: synctv_proto::client::CompleteRoomCoverUploadSessionRequest,
|
|
) -> Result<synctv_proto::client::CompleteRoomCoverUploadSessionResponse, ApiError> {
|
|
let result = self
|
|
.room_service
|
|
.complete_room_cover_upload_session(complete_upload_session_request(
|
|
&req.file_id,
|
|
req.encoded_object_key,
|
|
req.token,
|
|
req.upload_id,
|
|
&req.ownership_proof,
|
|
req.parts,
|
|
))
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let (complete, uploaded_size_bytes, uploaded_parts) =
|
|
complete_upload_response_fields(&result);
|
|
Ok(
|
|
synctv_proto::client::CompleteRoomCoverUploadSessionResponse {
|
|
object: result.object.map(room_cover_object_to_proto),
|
|
complete,
|
|
uploaded_size_bytes,
|
|
uploaded_parts,
|
|
},
|
|
)
|
|
}
|
|
|
|
pub async fn get_room_cover_object(
|
|
&self,
|
|
req: synctv_proto::client::GetRoomCoverObjectRequest,
|
|
) -> Result<synctv_core::models::FileObjectDownload, ApiError> {
|
|
self.room_service
|
|
.get_room_cover_object_stream(
|
|
&req.encoded_object_key,
|
|
&req.token,
|
|
proto_file_range_request(req.range),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
pub async fn update_room_cover(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::UpdateRoomCoverRequest,
|
|
) -> Result<synctv_proto::client::GetRoomResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let cover = required_file_upload_reference(req.cover_reference, "cover_reference")?;
|
|
let room = self
|
|
.room_service
|
|
.update_room_cover(rid, *user_id, cover)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_cache_fanout.publish_invalidation(&rid);
|
|
self.room_response_after_room_update(&room, user_id).await
|
|
}
|
|
|
|
pub async fn clear_room_cover(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::ClearRoomCoverRequest,
|
|
) -> Result<synctv_proto::client::GetRoomResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let room = self
|
|
.room_service
|
|
.clear_room_cover(rid, *user_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_cache_fanout.publish_invalidation(&rid);
|
|
self.room_response_after_room_update(&room, user_id).await
|
|
}
|
|
|
|
pub async fn join_room_with_control(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::JoinRoomRequest,
|
|
client_ip: Option<&str>,
|
|
request_control: Option<&ExecutionControl>,
|
|
) -> Result<synctv_proto::client::JoinRoomResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let remark_name = crate::impls::normalize_member_remark_name(&req.remark_name);
|
|
let display_tag = crate::impls::normalize_member_display_tag(&req.display_tag);
|
|
let password = if req.password.is_empty() {
|
|
None
|
|
} else {
|
|
validate_room_password_for_verify(&req.password)?;
|
|
Some(req.password)
|
|
};
|
|
|
|
let password_enabled = self
|
|
.room_service
|
|
.is_room_password_enabled(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
if password_enabled {
|
|
let password = password.as_ref().ok_or_else(|| {
|
|
ApiError::Authorization("Forbidden: Password required".to_string())
|
|
})?;
|
|
let parsed_client_ip = parse_optional_client_ip(client_ip)?;
|
|
if !self
|
|
.room_service
|
|
.check_room_password_with_rate_limit_with_control(
|
|
&rid,
|
|
password,
|
|
parsed_client_ip,
|
|
request_control,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?
|
|
{
|
|
return Err(ApiError::Authorization(
|
|
"Forbidden: Invalid password".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let room_settings = self
|
|
.room_service
|
|
.get_room_settings(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
let target_presence = self
|
|
.presence_service
|
|
.user_room_stats_fresh(uid, rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let prepared_membership_fanout = self
|
|
.membership_event_fanout
|
|
.prepare_permission_changed_outbox_fanout(
|
|
target_presence.is_online,
|
|
target_presence.connection_count,
|
|
);
|
|
let (_room, members) = self
|
|
.room_service
|
|
.join_room_with_outbox(
|
|
rid,
|
|
uid,
|
|
password,
|
|
remark_name,
|
|
display_tag,
|
|
Some(prepared_membership_fanout.outbox_factory()),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
prepared_membership_fanout.publish_after_outbox_commit();
|
|
|
|
// Get updated room and playback state
|
|
let room = self
|
|
.room_service
|
|
.get_room(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let playback_state = self.load_room_playback_state_proto(&rid).await?;
|
|
|
|
let proto_members = try_members_to_proto(
|
|
&members,
|
|
&room_settings,
|
|
self.room_service.permission_service(),
|
|
&self.public_id_codec,
|
|
)?;
|
|
|
|
let requires_approval = proto_members.is_empty();
|
|
Ok(synctv_proto::client::JoinRoomResponse {
|
|
room: Some(
|
|
self.room_to_proto_basic_with_loaded_cover(
|
|
&room,
|
|
Some(&room_settings),
|
|
self.load_room_member_count(&rid).await?,
|
|
)
|
|
.await?,
|
|
),
|
|
members: proto_members,
|
|
playback_state: Some(playback_state),
|
|
requires_approval,
|
|
})
|
|
}
|
|
|
|
pub async fn start_room_password_login_with_control(
|
|
&self,
|
|
user_id: &UserId,
|
|
req: synctv_proto::client::StartRoomPasswordLoginRequest,
|
|
client_ip: Option<&str>,
|
|
request_control: Option<&ExecutionControl>,
|
|
) -> Result<synctv_proto::client::StartRoomPasswordLoginResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(&req.room_id)?;
|
|
let parsed_client_ip = parse_optional_client_ip(client_ip)?;
|
|
let challenge = self
|
|
.room_service
|
|
.start_room_opaque_password_login_with_control(
|
|
&rid,
|
|
&uid,
|
|
req.credential_request,
|
|
parsed_client_ip,
|
|
request_control,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(synctv_proto::client::StartRoomPasswordLoginResponse {
|
|
session_id: challenge.session_id,
|
|
credential_response: challenge.credential_response,
|
|
})
|
|
}
|
|
|
|
pub async fn finish_room_password_login_with_control(
|
|
&self,
|
|
user_id: &UserId,
|
|
expected_room_id: Option<&str>,
|
|
req: synctv_proto::client::FinishRoomPasswordLoginRequest,
|
|
client_ip: Option<&str>,
|
|
) -> Result<synctv_proto::client::JoinRoomResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let expected_room_id = expected_room_id
|
|
.map(|room_id| self.parse_room_id(room_id))
|
|
.transpose()?;
|
|
let parsed_client_ip = parse_optional_client_ip(client_ip)?;
|
|
let target_presence = if let Some(room_id) = expected_room_id {
|
|
Some(
|
|
self.presence_service
|
|
.user_room_stats_fresh(uid, room_id)
|
|
.await
|
|
.map_err(ApiError::from)?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let prepared_membership_fanout = self
|
|
.membership_event_fanout
|
|
.prepare_permission_changed_outbox_fanout(
|
|
target_presence
|
|
.as_ref()
|
|
.is_some_and(|presence| presence.is_online),
|
|
target_presence
|
|
.as_ref()
|
|
.map_or(0, |presence| presence.connection_count),
|
|
);
|
|
let (room, members) = Box::pin(
|
|
self.room_service
|
|
.finish_room_opaque_password_login_with_outbox(
|
|
expected_room_id.as_ref(),
|
|
&req.session_id,
|
|
&uid,
|
|
req.credential_finalization,
|
|
parsed_client_ip,
|
|
Some(prepared_membership_fanout.outbox_factory()),
|
|
),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
prepared_membership_fanout.publish_after_outbox_commit();
|
|
|
|
let rid = room.id;
|
|
let room_settings = self
|
|
.room_service
|
|
.get_room_settings(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let playback_state = self.load_room_playback_state_proto(&rid).await?;
|
|
let proto_members = try_members_to_proto(
|
|
&members,
|
|
&room_settings,
|
|
self.room_service.permission_service(),
|
|
&self.public_id_codec,
|
|
)?;
|
|
let requires_approval = proto_members.is_empty();
|
|
Ok(synctv_proto::client::JoinRoomResponse {
|
|
room: Some(
|
|
self.room_to_proto_basic_with_loaded_cover(
|
|
&room,
|
|
Some(&room_settings),
|
|
self.load_room_member_count(&rid).await?,
|
|
)
|
|
.await?,
|
|
),
|
|
members: proto_members,
|
|
playback_state: Some(playback_state),
|
|
requires_approval,
|
|
})
|
|
}
|
|
|
|
pub async fn create_websocket_ticket_with_control(
|
|
&self,
|
|
user_id: &UserId,
|
|
password_version: i32,
|
|
req: synctv_proto::client::CreateWebSocketTicketRequest,
|
|
request_control: Option<&ExecutionControl>,
|
|
) -> Result<synctv_proto::client::CreateWebSocketTicketResponse, ApiError> {
|
|
let room_id = build_create_websocket_ticket_request(&req, &self.public_id_codec)?;
|
|
let requested_room_id = req.room_id;
|
|
let ws_ticket_service = &self.ws_ticket_service;
|
|
|
|
let room = self
|
|
.room_service
|
|
.get_room(&room_id)
|
|
.await
|
|
.map_err(|err| match err {
|
|
synctv_core::Error::NotFound(_) => {
|
|
ApiError::NotFound(format!("Room {requested_room_id} not found"))
|
|
}
|
|
other => ApiError::from(other),
|
|
})?;
|
|
|
|
if room.is_banned {
|
|
return Err(ApiError::Authorization("Room is banned".to_string()));
|
|
}
|
|
|
|
let is_member = self
|
|
.room_service
|
|
.member_service()
|
|
.is_member(&room_id, user_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
if !is_member {
|
|
return Err(ApiError::Authorization(
|
|
"Not a member of this room. Join the room first.".to_string(),
|
|
));
|
|
}
|
|
|
|
let ticket = ws_ticket_service
|
|
.create_ticket_with_control(user_id, &room_id, password_version, request_control)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_service
|
|
.record_room_visit(&room_id, user_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
let public_room_id = self
|
|
.public_id_codec
|
|
.encode_room_id(room_id)
|
|
.map_err(ApiError::from)?;
|
|
|
|
Ok(synctv_proto::client::CreateWebSocketTicketResponse {
|
|
ticket,
|
|
room_id: public_room_id.clone(),
|
|
expires_in_secs: ws_ticket_service.ticket_ttl_secs(),
|
|
usage: format!("Use in WebSocket URL: ws://host/ws/rooms/{public_room_id}?ticket=xxx"),
|
|
})
|
|
}
|
|
|
|
pub async fn create_websocket_ticket_for_actor_with_control(
|
|
&self,
|
|
actor: RoomActor,
|
|
req: synctv_proto::client::CreateWebSocketTicketRequest,
|
|
request_control: Option<&ExecutionControl>,
|
|
) -> Result<synctv_proto::client::CreateWebSocketTicketResponse, ApiError> {
|
|
let room_id = build_create_websocket_ticket_request(&req, &self.public_id_codec)?;
|
|
let requested_room_id = req.room_id;
|
|
if actor.room_id() != room_id {
|
|
return Err(ApiError::Authorization(
|
|
"Cannot create a WebSocket ticket for a different room".to_string(),
|
|
));
|
|
}
|
|
|
|
let ws_ticket_service = &self.ws_ticket_service;
|
|
|
|
let room = self
|
|
.room_service
|
|
.get_room(&room_id)
|
|
.await
|
|
.map_err(|err| match err {
|
|
synctv_core::Error::NotFound(_) => {
|
|
ApiError::NotFound(format!("Room {requested_room_id} not found"))
|
|
}
|
|
other => ApiError::from(other),
|
|
})?;
|
|
|
|
if room.is_banned {
|
|
return Err(ApiError::Authorization("Room is banned".to_string()));
|
|
}
|
|
|
|
let ticket = match actor {
|
|
RoomActor::User { user_id, .. } => {
|
|
let password_version = self
|
|
.user_service
|
|
.get_password_credential_state(&user_id)
|
|
.await
|
|
.map_err(ApiError::from)?
|
|
.version;
|
|
let ticket = ws_ticket_service
|
|
.create_ticket_with_control(
|
|
&user_id,
|
|
&room_id,
|
|
password_version,
|
|
request_control,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_service
|
|
.record_room_visit(&room_id, &user_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
ticket
|
|
}
|
|
RoomActor::Guest(access) => ws_ticket_service
|
|
.create_guest_ticket_with_control(
|
|
synctv_core::service::CreateGuestTicketRequest {
|
|
room_id,
|
|
guest_id: access.guest_id,
|
|
display_name: access.display_name,
|
|
session_id: access.session_id,
|
|
token_jti: access.token_jti,
|
|
room_guest_version: access.room_guest_version,
|
|
permissions: access.permissions,
|
|
},
|
|
request_control,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?,
|
|
};
|
|
|
|
let public_room_id = self
|
|
.public_id_codec
|
|
.encode_room_id(room_id)
|
|
.map_err(ApiError::Internal)?;
|
|
|
|
Ok(synctv_proto::client::CreateWebSocketTicketResponse {
|
|
ticket,
|
|
room_id: public_room_id.clone(),
|
|
expires_in_secs: ws_ticket_service.ticket_ttl_secs(),
|
|
usage: format!("Use in WebSocket URL: ws://host/ws/rooms/{public_room_id}?ticket=xxx"),
|
|
})
|
|
}
|
|
|
|
pub async fn leave_room(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
) -> Result<synctv_proto::client::LeaveRoomResponse, ApiError> {
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
|
|
let prepared_membership_fanout = self
|
|
.membership_event_fanout
|
|
.prepare_user_left_outbox_fanout();
|
|
let username = self.user_username_for_event(&uid).await?;
|
|
let prepared_cleanup_fanout =
|
|
prepare_delete_entries_outbox_fanout(PrepareDeleteEntriesOutboxFanout {
|
|
clock: self.clock.clone(),
|
|
media_fanout: self.media_fanout.clone(),
|
|
playlist_fanout: self.playlist_fanout.clone(),
|
|
playback_fanout: self.playback_fanout.clone(),
|
|
realtime_fanout: self.realtime_fanout.clone(),
|
|
room_id: rid,
|
|
user_id: uid,
|
|
username,
|
|
});
|
|
self.room_service
|
|
.leave_room_with_outbox(
|
|
rid,
|
|
uid,
|
|
Some(prepared_membership_fanout.outbox_factory()),
|
|
Some(prepared_cleanup_fanout.member_cleanup_outbox_factory()),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
// Force disconnect the user's room-scoped connections and any local
|
|
// publishers tied to the room they just left.
|
|
self.realtime_lifecycle
|
|
.disconnect_user_from_room(&rid, &uid)
|
|
.await;
|
|
|
|
prepared_membership_fanout.publish_after_outbox_commit();
|
|
prepared_cleanup_fanout.publish_after_outbox_commit();
|
|
|
|
Ok(synctv_proto::client::LeaveRoomResponse { success: true })
|
|
}
|
|
|
|
pub async fn delete_room(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
) -> Result<synctv_proto::client::DeleteRoomResponse, ApiError> {
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let prepared_outbox_fanout = self
|
|
.room_lifecycle_fanout
|
|
.prepare_room_deleted_outbox_fanout(&rid, &uid)?;
|
|
|
|
// 1. Delete the DB record first. If this fails, no realtime event is
|
|
// published and no connections are dropped -- the room remains intact.
|
|
self.room_service
|
|
.delete_room_with_outbox(rid, uid, Some(prepared_outbox_fanout.cloned_outbox_event()))
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
prepared_outbox_fanout.publish_after_outbox_commit();
|
|
|
|
// Force disconnect room members and any active publishers tied to this room.
|
|
self.realtime_lifecycle
|
|
.disconnect_room(&rid, synctv_realtime::sync::RoomDisconnectReason::Deleted)
|
|
.await;
|
|
|
|
Ok(synctv_proto::client::DeleteRoomResponse { success: true })
|
|
}
|
|
|
|
pub async fn update_room_settings(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::UpdateRoomSettingsRequest,
|
|
) -> Result<synctv_proto::client::Room, ApiError> {
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
|
|
let current_settings = self
|
|
.room_service
|
|
.get_room_settings(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let settings = validate_update_room_settings_request(&req, current_settings)?;
|
|
let username = self.user_username_for_event(&uid).await?;
|
|
let prepared_settings_fanout = self
|
|
.room_settings_fanout
|
|
.prepare_settings_changed(&rid, &uid, &username)?;
|
|
let snapshot = self
|
|
.room_service
|
|
.set_settings_with_outbox(
|
|
rid,
|
|
uid,
|
|
settings,
|
|
Some(prepared_settings_fanout.settings_outbox_factory()),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let prepared_settings_fanout = prepared_settings_fanout
|
|
.with_settings_and_version(&snapshot.settings, snapshot.version)
|
|
.map_err(ApiError::from)?;
|
|
self.room_settings_fanout
|
|
.publish_prepared_after_outbox_commit(prepared_settings_fanout);
|
|
self.room_cache_fanout.publish_invalidation(&rid);
|
|
|
|
// Get updated room
|
|
let room = self
|
|
.room_service
|
|
.get_room(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
self.room_to_proto_basic_with_loaded_cover(
|
|
&room,
|
|
Some(&snapshot.settings),
|
|
self.load_room_member_count(&rid).await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn update_room_visibility(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::UpdateRoomVisibilityRequest,
|
|
) -> Result<synctv_proto::client::Room, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let room = self
|
|
.room_service
|
|
.update_room_visibility(&rid, user_id, req.is_public)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_cache_fanout.publish_invalidation(&rid);
|
|
|
|
let settings = self
|
|
.room_service
|
|
.get_room_settings(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_to_proto_basic_with_loaded_cover(
|
|
&room,
|
|
Some(&settings),
|
|
self.load_room_member_count(&rid).await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn start_room_password_registration(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::StartRoomPasswordRegistrationRequest,
|
|
) -> Result<synctv_proto::client::StartRoomPasswordRegistrationResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let challenge = self
|
|
.room_service
|
|
.start_room_opaque_password_registration(&rid, &uid, req.registration_request)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(
|
|
synctv_proto::client::StartRoomPasswordRegistrationResponse {
|
|
session_id: challenge.session_id,
|
|
registration_response: challenge.registration_response,
|
|
},
|
|
)
|
|
}
|
|
|
|
pub async fn finish_room_password_registration(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::FinishRoomPasswordRegistrationRequest,
|
|
) -> Result<synctv_proto::client::SetRoomPasswordResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let username = self.user_username_for_event(&uid).await?;
|
|
let state = self
|
|
.room_service
|
|
.finish_room_opaque_password_registration(
|
|
&rid,
|
|
&req.session_id,
|
|
&uid,
|
|
req.registration_upload,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
self.room_cache_fanout.publish_invalidation(&state.room_id);
|
|
tracing::debug!(
|
|
room_id = %state.room_id,
|
|
user_id = %uid,
|
|
username = %username,
|
|
password_enabled = state.enabled,
|
|
password_version = state.version,
|
|
"Room password updated"
|
|
);
|
|
|
|
Ok(synctv_proto::client::SetRoomPasswordResponse { success: true })
|
|
}
|
|
|
|
pub async fn clear_room_password(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::ClearRoomPasswordRequest,
|
|
) -> Result<synctv_proto::client::SetRoomPasswordResponse, ApiError> {
|
|
crate::impls::validate_proto_request(&req)?;
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
self.room_service
|
|
.check_permission(
|
|
&rid,
|
|
&uid,
|
|
synctv_core::models::RoomPermission::MANAGE_ROOM_SETTINGS,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let state = self
|
|
.room_service
|
|
.update_room_password_as(&rid, Some(&uid), None)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_cache_fanout.publish_invalidation(&rid);
|
|
tracing::debug!(
|
|
room_id = %rid,
|
|
user_id = %uid,
|
|
password_enabled = state.enabled,
|
|
password_version = state.version,
|
|
"Room password cleared"
|
|
);
|
|
Ok(synctv_proto::client::SetRoomPasswordResponse { success: true })
|
|
}
|
|
|
|
/// Get room settings
|
|
///
|
|
/// Requires the caller to be a member of the room.
|
|
pub async fn get_room_settings(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
) -> Result<synctv_proto::client::GetRoomSettingsResponse, ApiError> {
|
|
let actor = self.room_actor_for_user(user_id, room_id).await?;
|
|
self.get_room_settings_for_actor(&actor).await
|
|
}
|
|
|
|
pub async fn get_room_settings_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
) -> Result<synctv_proto::client::GetRoomSettingsResponse, ApiError> {
|
|
let rid = actor.room_id();
|
|
let (settings, version) = self
|
|
.room_service
|
|
.get_room_settings_with_version(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
Ok(synctv_proto::client::GetRoomSettingsResponse {
|
|
settings: Some(room_settings_to_proto(&settings)),
|
|
version,
|
|
})
|
|
}
|
|
|
|
/// Reset room settings to defaults
|
|
pub async fn reset_room_settings(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
) -> Result<synctv_proto::client::RoomSettings, ApiError> {
|
|
let uid = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let username = self.user_username_for_event(&uid).await?;
|
|
let prepared_settings_fanout = self
|
|
.room_settings_fanout
|
|
.prepare_settings_changed(&rid, &uid, &username)?;
|
|
let snapshot = self
|
|
.room_service
|
|
.reset_room_settings_with_outbox(
|
|
&rid,
|
|
&uid,
|
|
Some(prepared_settings_fanout.settings_outbox_factory()),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
self.room_settings_fanout
|
|
.publish_prepared_after_outbox_commit(
|
|
prepared_settings_fanout
|
|
.with_settings_and_version(&snapshot.settings, snapshot.version)?,
|
|
);
|
|
self.room_cache_fanout.publish_invalidation(&rid);
|
|
|
|
Ok(room_settings_to_proto(&snapshot.settings))
|
|
}
|
|
|
|
pub async fn transfer_room_ownership(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::TransferRoomOwnershipRequest,
|
|
) -> Result<synctv_proto::client::Room, ApiError> {
|
|
let current_owner_id = *user_id;
|
|
let rid = self.parse_room_id(room_id)?;
|
|
let new_owner_id = build_transfer_room_ownership_request(req, &self.public_id_codec)?;
|
|
|
|
let target_presence = self
|
|
.presence_service
|
|
.user_room_stats_fresh(current_owner_id, rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let prepared_membership_fanout = self
|
|
.membership_event_fanout
|
|
.prepare_permission_changed_outbox_fanout(
|
|
target_presence.is_online,
|
|
target_presence.connection_count,
|
|
);
|
|
let room = self
|
|
.room_service
|
|
.transfer_room_ownership_with_outbox(
|
|
rid,
|
|
current_owner_id,
|
|
new_owner_id,
|
|
Some(prepared_membership_fanout.outbox_factory()),
|
|
)
|
|
.await
|
|
.map_err(Self::map_room_access_error)?;
|
|
prepared_membership_fanout.publish_after_outbox_commit();
|
|
self.room_cache_fanout.publish_invalidation(&rid);
|
|
let settings = self
|
|
.room_service
|
|
.get_room_settings(&rid)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
self.room_to_proto_basic_with_loaded_cover(
|
|
&room,
|
|
Some(&settings),
|
|
self.load_room_member_count(&rid).await?,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Get public settings
|
|
pub fn get_public_settings(
|
|
&self,
|
|
) -> Result<synctv_proto::client::GetPublicSettingsResponse, ApiError> {
|
|
let reg = self
|
|
.runtime_settings_store
|
|
.as_ref()
|
|
.ok_or_else(runtime_settings_store_unavailable_error)?;
|
|
|
|
let s = reg.to_public_settings().map_err(ApiError::from)?;
|
|
Ok(synctv_proto::client::GetPublicSettingsResponse {
|
|
server_name: s.server_name,
|
|
room_creation_enabled: s.room_creation_enabled,
|
|
max_rooms_per_user: s.max_rooms_per_user,
|
|
default_max_members: s.default_max_members,
|
|
max_pinned_chat_messages_per_room: s.max_pinned_chat_messages_per_room,
|
|
room_creation_approval_required: s.approval_required,
|
|
room_password_policy: match s.room_password_policy {
|
|
synctv_core::service::RoomPasswordPolicy::Optional => {
|
|
synctv_proto::common::RoomPasswordPolicy::Optional as i32
|
|
}
|
|
synctv_core::service::RoomPasswordPolicy::Required => {
|
|
synctv_proto::common::RoomPasswordPolicy::Required as i32
|
|
}
|
|
synctv_core::service::RoomPasswordPolicy::Forbidden => {
|
|
synctv_proto::common::RoomPasswordPolicy::Forbidden as i32
|
|
}
|
|
},
|
|
enable_password_signup: s.enable_password_signup,
|
|
password_signup_need_review: s.password_signup_need_review,
|
|
enable_email_signup: s.enable_email_signup,
|
|
email_signup_need_review: s.email_signup_need_review,
|
|
enable_email: s.enable_email && self.email_api.is_some(),
|
|
enable_webauthn: self.passkey_service.is_some(),
|
|
webauthn_rp_id: self
|
|
.passkey_service
|
|
.as_ref()
|
|
.map_or_else(String::new, |service| service.rp_id().to_string()),
|
|
enable_webauthn_signup: s.enable_webauthn_signup,
|
|
webauthn_signup_need_review: s.webauthn_signup_need_review,
|
|
enable_guest: s.enable_guest,
|
|
ts_disguised_as_png: s.ts_disguised_as_png,
|
|
advertise_address: s.advertise_address,
|
|
email_whitelist_enabled: s.email_whitelist_enabled,
|
|
email_whitelist_domains: s.email_whitelist_domains,
|
|
})
|
|
}
|
|
|
|
pub async fn get_server_info(
|
|
&self,
|
|
) -> Result<synctv_proto::client::GetServerInfoResponse, ApiError> {
|
|
let reg = self
|
|
.runtime_settings_store
|
|
.as_ref()
|
|
.ok_or_else(runtime_settings_store_unavailable_error)?;
|
|
let server_id = reg
|
|
.get_or_initialize_server_id()
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
Ok(synctv_proto::client::GetServerInfoResponse {
|
|
server_id,
|
|
server_name: reg.server.name.get().map_err(ApiError::from)?,
|
|
})
|
|
}
|
|
|
|
pub fn get_server_time(
|
|
&self,
|
|
req: synctv_proto::client::GetServerTimeRequest,
|
|
) -> synctv_proto::client::GetServerTimeResponse {
|
|
build_server_time_response(req, self.clock.as_ref())
|
|
}
|
|
|
|
pub async fn get_chat_history(
|
|
&self,
|
|
user_id: &UserId,
|
|
room_id: &str,
|
|
req: synctv_proto::client::GetChatHistoryRequest,
|
|
) -> Result<synctv_proto::client::GetChatHistoryResponse, ApiError> {
|
|
let actor = self.room_actor_for_user(user_id, room_id).await?;
|
|
self.get_chat_history_for_actor(&actor, req).await
|
|
}
|
|
|
|
async fn chat_messages_to_proto(
|
|
&self,
|
|
messages: Vec<ChatMessageWithAttachments>,
|
|
) -> Result<Vec<synctv_proto::client::ChatMessageReceive>, ApiError> {
|
|
let user_ids: Vec<synctv_core::models::UserId> = messages
|
|
.iter()
|
|
.filter_map(|message| message.message.user_id)
|
|
.collect::<std::collections::HashSet<_>>()
|
|
.into_iter()
|
|
.collect();
|
|
|
|
let username_map: std::collections::HashMap<synctv_core::models::UserId, String> = self
|
|
.user_service
|
|
.get_usernames(&user_ids)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
messages
|
|
.into_iter()
|
|
.map(|message| {
|
|
let (user_id_str, username) = match &message.message.user_id {
|
|
Some(uid) => {
|
|
let uid_str =
|
|
self.public_id_codec.encode_user_id(*uid).map_err(|error| {
|
|
ApiError::Internal(format!(
|
|
"Failed to encode chat message user id: {error}"
|
|
))
|
|
})?;
|
|
let name = username_map.get(uid).cloned();
|
|
(uid_str, name)
|
|
}
|
|
None => (String::new(), None),
|
|
};
|
|
|
|
let mut proto = chat_message_to_proto(self, &message, username)?;
|
|
proto.user_id = user_id_str;
|
|
Ok(proto)
|
|
})
|
|
.collect::<Result<Vec<_>, ApiError>>()
|
|
}
|
|
|
|
async fn get_chat_history_for_room_id(
|
|
&self,
|
|
rid: &synctv_core::models::RoomId,
|
|
viewer_user_id: Option<UserId>,
|
|
req: synctv_proto::client::GetChatHistoryRequest,
|
|
) -> Result<synctv_proto::client::GetChatHistoryResponse, ApiError> {
|
|
let (limit, cursor, selection) = build_get_chat_history_request(&req)?;
|
|
let cursor = cursor
|
|
.map(|(created_at, id)| synctv_core::models::ChatHistoryCursor { created_at, id });
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let page = chat_service
|
|
.get_history_page_with_attachments_for_viewer(
|
|
rid,
|
|
cursor,
|
|
limit,
|
|
true,
|
|
viewer_user_id.as_ref(),
|
|
&selection,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let next_cursor_str = page.next_cursor.map(|cursor| {
|
|
format!(
|
|
"{}|{}",
|
|
synctv_common::time::format_datetime_rfc3339(cursor.created_at),
|
|
cursor.id
|
|
)
|
|
});
|
|
|
|
let proto_messages = self.chat_messages_to_proto(page.messages).await?;
|
|
|
|
Ok(synctv_proto::client::GetChatHistoryResponse {
|
|
messages: proto_messages,
|
|
next_cursor: next_cursor_str.unwrap_or_default(),
|
|
event_cursor: Some(synctv_proto::client::EventCursor {
|
|
event_id: page.event_cursor.event_id,
|
|
sequence: page.event_cursor.sequence,
|
|
}),
|
|
})
|
|
}
|
|
|
|
async fn search_chat_messages_for_room_id(
|
|
&self,
|
|
rid: &synctv_core::models::RoomId,
|
|
viewer_user_id: Option<UserId>,
|
|
req: synctv_proto::client::SearchChatMessagesRequest,
|
|
) -> Result<synctv_proto::client::SearchChatMessagesResponse, ApiError> {
|
|
let query = build_search_chat_messages_query(*rid, &req, &self.public_id_codec)?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let page = chat_service
|
|
.search_messages_with_attachments_for_viewer(query, viewer_user_id.as_ref())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let next_cursor = page
|
|
.next_cursor
|
|
.map(|cursor| {
|
|
format!(
|
|
"{}|{}",
|
|
synctv_common::time::format_datetime_rfc3339(cursor.created_at),
|
|
cursor.id
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
let messages = self.chat_messages_to_proto(page.messages).await?;
|
|
|
|
Ok(synctv_proto::client::SearchChatMessagesResponse {
|
|
messages,
|
|
next_cursor,
|
|
event_cursor: Some(synctv_proto::client::EventCursor {
|
|
event_id: page.event_cursor.event_id,
|
|
sequence: page.event_cursor.sequence,
|
|
}),
|
|
})
|
|
}
|
|
|
|
pub async fn send_chat_message_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::SendChatMessageRequest,
|
|
) -> Result<synctv_proto::client::ChatMessageEventResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let room_id = actor.room_id();
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let attachments = parse_proto_chat_attachments(&req.attachments)?;
|
|
let playback_state = self
|
|
.room_service
|
|
.playback_service()
|
|
.get_state(&room_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let playback_metadata = self
|
|
.room_service
|
|
.playback_service()
|
|
.chat_playback_metadata_for_state(&playback_state, playback_state.computed_position())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let metadata = crate::impls::messaging::chat_metadata_for_send(
|
|
chat_metadata_from_proto(req.metadata.as_ref())?,
|
|
&req.display_position,
|
|
&req.display_color,
|
|
playback_metadata,
|
|
)
|
|
.map_err(ApiError::InvalidInput)?;
|
|
let outcome = chat_service
|
|
.send_message_event_outcome(SendChatMessage {
|
|
room_id,
|
|
user_id,
|
|
client_message_id: optional_trimmed_string(&req.client_message_id),
|
|
content: req.content,
|
|
message_type: ChatMessageType::User,
|
|
reply_to_message_id: if req.reply_to_message_id.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(parse_chat_message_id(&req.reply_to_message_id)?)
|
|
},
|
|
metadata,
|
|
attachments,
|
|
mentions: self.parse_proto_chat_mentions(req.mentions)?,
|
|
})
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if outcome.inserted {
|
|
self.broadcast_chat_event(&outcome.event);
|
|
if let Some(pin_event) = &outcome.pin_event {
|
|
self.broadcast_chat_pin_event(pin_event);
|
|
}
|
|
}
|
|
Ok(synctv_proto::client::ChatMessageEventResponse {
|
|
event: Some(chat_event_to_proto(self, outcome.event).await?),
|
|
})
|
|
}
|
|
|
|
pub async fn create_chat_attachment_upload_session_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::CreateChatAttachmentUploadSessionRequest,
|
|
) -> Result<synctv_proto::client::CreateChatAttachmentUploadSessionResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let session = chat_service
|
|
.create_attachment_upload_session(CreateChatAttachmentUploadSession {
|
|
room_id: actor.room_id(),
|
|
user_id,
|
|
client_attachment_id: optional_trimmed_string(&req.client_attachment_id),
|
|
filename: optional_trimmed_string(&req.filename),
|
|
mime_type: req.mime_type,
|
|
size_bytes: req.size_bytes,
|
|
width: (req.width > 0).then_some(req.width),
|
|
height: (req.height > 0).then_some(req.height),
|
|
duration_seconds: (req.duration_seconds > 0).then_some(req.duration_seconds),
|
|
bitrate_bps: (req.bitrate_bps > 0).then_some(req.bitrate_bps),
|
|
parts: proto_upload_manifest_parts(req.parts),
|
|
metadata: file_metadata_from_proto(req.metadata.as_ref())?,
|
|
})
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
chat_attachment_upload_create_result_to_proto(session)
|
|
}
|
|
|
|
pub async fn upload_chat_attachment_object(
|
|
&self,
|
|
req: synctv_proto::client::UploadChatAttachmentObjectRequest,
|
|
) -> Result<synctv_proto::client::UploadChatAttachmentObjectResponse, ApiError> {
|
|
if !req.room_id.trim().is_empty() {
|
|
let _room_id = self.parse_room_id(&req.room_id)?;
|
|
}
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let blob = chat_service
|
|
.store_attachment_upload_object(
|
|
&req.encoded_object_key,
|
|
&req.token,
|
|
req.content_type.as_deref(),
|
|
proto_file_upload_range(req.content_range),
|
|
req.data,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let (complete, uploaded_size_bytes, uploaded_parts) = uploaded_parts_response_fields(&blob);
|
|
Ok(synctv_proto::client::UploadChatAttachmentObjectResponse {
|
|
object: match blob {
|
|
StoreFileUploadResult::Complete(blob) => {
|
|
Some(chat_attachment_object_to_proto(&req.room_id, blob))
|
|
}
|
|
StoreFileUploadResult::PartAccepted { .. } => None,
|
|
},
|
|
complete,
|
|
uploaded_size_bytes,
|
|
uploaded_parts,
|
|
})
|
|
}
|
|
|
|
pub async fn complete_chat_attachment_upload_session(
|
|
&self,
|
|
req: synctv_proto::client::CompleteChatAttachmentUploadSessionRequest,
|
|
) -> Result<synctv_proto::client::CompleteChatAttachmentUploadSessionResponse, ApiError> {
|
|
let room_id = req.room_id.clone();
|
|
if !room_id.trim().is_empty() {
|
|
let _room_id = self.parse_room_id(&room_id)?;
|
|
}
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let result = chat_service
|
|
.complete_attachment_upload_session(complete_upload_session_request(
|
|
&req.file_id,
|
|
req.encoded_object_key,
|
|
req.token,
|
|
req.upload_id,
|
|
&req.ownership_proof,
|
|
req.parts,
|
|
))
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let (complete, uploaded_size_bytes, uploaded_parts) =
|
|
complete_upload_response_fields(&result);
|
|
Ok(
|
|
synctv_proto::client::CompleteChatAttachmentUploadSessionResponse {
|
|
object: result
|
|
.object
|
|
.map(|blob| chat_attachment_object_to_proto(&room_id, blob)),
|
|
complete,
|
|
uploaded_size_bytes,
|
|
uploaded_parts,
|
|
},
|
|
)
|
|
}
|
|
|
|
pub async fn get_chat_attachment_object(
|
|
&self,
|
|
req: synctv_proto::client::GetChatAttachmentObjectRequest,
|
|
) -> Result<synctv_core::models::FileObjectDownload, ApiError> {
|
|
if !req.room_id.trim().is_empty() {
|
|
let _room_id = self.parse_room_id(&req.room_id)?;
|
|
}
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
chat_service
|
|
.get_attachment_object_stream(
|
|
&req.encoded_object_key,
|
|
&req.token,
|
|
proto_file_range_request(req.range),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
pub async fn edit_chat_message_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::EditChatMessageRequest,
|
|
) -> Result<synctv_proto::client::ChatMessageEventResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let outcome = chat_service
|
|
.edit_message_outcome(edit_chat_message_request_to_core(
|
|
actor.room_id(),
|
|
user_id,
|
|
req,
|
|
)?)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if outcome.inserted {
|
|
self.broadcast_chat_event(&outcome.event);
|
|
if let Some(pin_event) = &outcome.pin_event {
|
|
self.broadcast_chat_pin_event(pin_event);
|
|
}
|
|
}
|
|
Ok(synctv_proto::client::ChatMessageEventResponse {
|
|
event: Some(chat_event_to_proto(self, outcome.event).await?),
|
|
})
|
|
}
|
|
|
|
pub async fn delete_chat_message_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::DeleteChatMessageRequest,
|
|
) -> Result<synctv_proto::client::ChatMessageEventResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let outcome = chat_service
|
|
.delete_message_event_outcome(delete_chat_message_request_to_core(
|
|
actor.room_id(),
|
|
user_id,
|
|
&req,
|
|
)?)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if outcome.inserted {
|
|
self.broadcast_chat_event(&outcome.event);
|
|
if let Some(pin_event) = &outcome.pin_event {
|
|
self.broadcast_chat_pin_event(pin_event);
|
|
}
|
|
}
|
|
Ok(synctv_proto::client::ChatMessageEventResponse {
|
|
event: Some(chat_event_to_proto(self, outcome.event).await?),
|
|
})
|
|
}
|
|
|
|
pub async fn list_pinned_chat_messages_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::ListPinnedChatMessagesRequest,
|
|
) -> Result<synctv_proto::client::ListPinnedChatMessagesResponse, ApiError> {
|
|
self.require_room_permission(
|
|
actor,
|
|
synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY,
|
|
)
|
|
.await?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let limit = if req.limit <= 0 {
|
|
20
|
|
} else {
|
|
req.limit.min(100)
|
|
};
|
|
let pinned = chat_service
|
|
.list_pinned_messages_for_authorized_viewer(
|
|
&actor.room_id(),
|
|
actor.user_id().as_ref(),
|
|
limit,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let mut messages = Vec::with_capacity(pinned.len());
|
|
for message in pinned {
|
|
messages.push(chat_pinned_message_to_proto(self, message).await?);
|
|
}
|
|
Ok(synctv_proto::client::ListPinnedChatMessagesResponse { messages })
|
|
}
|
|
|
|
pub async fn pin_chat_message_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::PinChatMessageRequest,
|
|
) -> Result<synctv_proto::client::ChatPinEventResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let outcome = chat_service
|
|
.pin_message_event_outcome(pin_chat_message_request_to_core(
|
|
actor.room_id(),
|
|
user_id,
|
|
&req,
|
|
)?)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if outcome.inserted {
|
|
self.broadcast_chat_pin_event(&outcome.event);
|
|
}
|
|
Ok(synctv_proto::client::ChatPinEventResponse {
|
|
event: Some(chat_pin_event_to_proto(self, outcome.event).await?),
|
|
})
|
|
}
|
|
|
|
pub async fn unpin_chat_message_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::UnpinChatMessageRequest,
|
|
) -> Result<synctv_proto::client::ChatPinEventResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let outcome = chat_service
|
|
.unpin_message_event_outcome(unpin_chat_message_request_to_core(
|
|
actor.room_id(),
|
|
user_id,
|
|
&req,
|
|
)?)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if outcome.inserted {
|
|
self.broadcast_chat_pin_event(&outcome.event);
|
|
}
|
|
Ok(synctv_proto::client::ChatPinEventResponse {
|
|
event: Some(chat_pin_event_to_proto(self, outcome.event).await?),
|
|
})
|
|
}
|
|
|
|
pub async fn set_chat_reaction_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::SetChatReactionRequest,
|
|
) -> Result<synctv_proto::client::ChatMessageEvent, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let outcome = chat_service
|
|
.set_reaction_event_outcome(SetChatReaction {
|
|
room_id: actor.room_id(),
|
|
message_id: parse_chat_message_id(&req.message_id)?,
|
|
user_id,
|
|
reaction_key: req.reaction_key,
|
|
enabled: req.enabled,
|
|
})
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if outcome.inserted {
|
|
self.broadcast_chat_event(&outcome.event);
|
|
if let Some(pin_event) = &outcome.pin_event {
|
|
self.broadcast_chat_pin_event(pin_event);
|
|
}
|
|
}
|
|
chat_event_to_proto(self, outcome.event).await
|
|
}
|
|
|
|
pub async fn list_chat_reaction_users_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::ListChatReactionUsersRequest,
|
|
) -> Result<synctv_proto::client::ListChatReactionUsersResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
self.require_room_permission(
|
|
actor,
|
|
synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY,
|
|
)
|
|
.await?;
|
|
let (limit, cursor) = build_list_chat_reaction_users_request(&req, &self.public_id_codec)?;
|
|
let cursor =
|
|
cursor.map(
|
|
|(reacted_at, user_id)| synctv_core::models::ChatReactionUsersCursor {
|
|
reacted_at,
|
|
user_id,
|
|
},
|
|
);
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let page = chat_service
|
|
.list_reaction_users(
|
|
&actor.room_id(),
|
|
parse_chat_message_id(&req.message_id)?,
|
|
&user_id,
|
|
&req.reaction_key,
|
|
cursor,
|
|
limit,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let user_ids = page
|
|
.users
|
|
.iter()
|
|
.map(|reaction_user| reaction_user.user_id)
|
|
.collect::<Vec<_>>();
|
|
let username_map = self
|
|
.user_service
|
|
.get_usernames(&user_ids)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let users = page
|
|
.users
|
|
.into_iter()
|
|
.map(|reaction_user| {
|
|
let user_id = self
|
|
.public_id_codec
|
|
.encode_user_id(reaction_user.user_id)
|
|
.map_err(|error| {
|
|
ApiError::Internal(format!(
|
|
"Failed to encode chat reaction user id: {error}"
|
|
))
|
|
})?;
|
|
let username = username_map
|
|
.get(&reaction_user.user_id)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
ApiError::NotFound("Chat reaction user not found".to_string())
|
|
})?;
|
|
Ok(synctv_proto::client::ChatReactionUser {
|
|
user_id,
|
|
username,
|
|
reacted_at: reaction_user.reacted_at.timestamp(),
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, ApiError>>()?;
|
|
let next_cursor = page
|
|
.next_cursor
|
|
.map(|cursor| {
|
|
let user_id = self
|
|
.public_id_codec
|
|
.encode_user_id(cursor.user_id)
|
|
.map_err(|error| {
|
|
ApiError::Internal(format!(
|
|
"Failed to encode chat reaction cursor user id: {error}"
|
|
))
|
|
})?;
|
|
Ok::<String, ApiError>(format!(
|
|
"{}|{}",
|
|
synctv_common::time::format_datetime_rfc3339(cursor.reacted_at),
|
|
user_id
|
|
))
|
|
})
|
|
.transpose()?;
|
|
|
|
Ok(synctv_proto::client::ListChatReactionUsersResponse {
|
|
users,
|
|
next_cursor: next_cursor.unwrap_or_default(),
|
|
total: page.total,
|
|
})
|
|
}
|
|
|
|
pub async fn mark_chat_read_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::MarkChatReadRequest,
|
|
) -> Result<synctv_proto::client::ChatReadStateResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let state = chat_service
|
|
.mark_read(MarkChatRead {
|
|
room_id: actor.room_id(),
|
|
user_id,
|
|
message_id: parse_chat_message_id(&req.message_id)?,
|
|
})
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
chat_read_state_to_proto(self, state)
|
|
}
|
|
|
|
pub async fn get_chat_read_state_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
_req: synctv_proto::client::GetChatReadStateRequest,
|
|
) -> Result<synctv_proto::client::ChatReadStateResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let state = chat_service
|
|
.get_read_state(&actor.room_id(), &user_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
chat_read_state_to_proto(self, state)
|
|
}
|
|
|
|
pub async fn get_chat_message_read_receipts_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::GetChatMessageReadReceiptsRequest,
|
|
) -> Result<synctv_proto::client::GetChatMessageReadReceiptsResponse, ApiError> {
|
|
let user_id = actor.require_user_id()?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let page = chat_service
|
|
.get_message_read_receipts(
|
|
&actor.room_id(),
|
|
&user_id,
|
|
parse_chat_message_id(&req.message_id)?,
|
|
req.page,
|
|
req.page_size,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
chat_message_read_receipts_to_proto(self, page).await
|
|
}
|
|
|
|
fn broadcast_chat_event(&self, event: &ChatMessageEvent) {
|
|
self.chat_event_dispatcher.dispatch(event);
|
|
}
|
|
|
|
fn broadcast_chat_pin_event(&self, event: &ChatPinEvent) {
|
|
self.chat_event_dispatcher.dispatch_pin(event);
|
|
}
|
|
|
|
pub async fn get_chat_history_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::GetChatHistoryRequest,
|
|
) -> Result<synctv_proto::client::GetChatHistoryResponse, ApiError> {
|
|
self.require_room_permission(
|
|
actor,
|
|
synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY,
|
|
)
|
|
.await?;
|
|
self.get_chat_history_for_room_id(&actor.room_id(), actor.user_id(), req)
|
|
.await
|
|
}
|
|
|
|
pub async fn search_chat_messages_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::SearchChatMessagesRequest,
|
|
) -> Result<synctv_proto::client::SearchChatMessagesResponse, ApiError> {
|
|
self.require_room_permission(
|
|
actor,
|
|
synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY,
|
|
)
|
|
.await?;
|
|
self.search_chat_messages_for_room_id(&actor.room_id(), actor.user_id(), req)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_chat_message_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::GetChatMessageRequest,
|
|
) -> Result<synctv_proto::client::ChatMessageReceive, ApiError> {
|
|
self.require_room_permission(
|
|
actor,
|
|
synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY,
|
|
)
|
|
.await?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let message = chat_service
|
|
.get_message_with_attachments_for_viewer(
|
|
&actor.room_id(),
|
|
parse_chat_message_id(&req.message_id)?,
|
|
req.include_deleted,
|
|
actor.user_id().as_ref(),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let username = username_for_chat_message(self, &message.message).await?;
|
|
chat_message_to_proto(self, &message, username)
|
|
}
|
|
|
|
pub async fn get_chat_message_context_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::GetChatMessageContextRequest,
|
|
) -> Result<synctv_proto::client::GetChatMessageContextResponse, ApiError> {
|
|
self.require_room_permission(
|
|
actor,
|
|
synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY,
|
|
)
|
|
.await?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let context = chat_service
|
|
.get_message_context_for_viewer(
|
|
&actor.room_id(),
|
|
parse_chat_message_id(&req.message_id)?,
|
|
positive_i32(req.before_limit, 20).min(50),
|
|
positive_i32(req.after_limit, 20).min(50),
|
|
req.include_deleted,
|
|
actor.user_id().as_ref(),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
let before_messages = context.before;
|
|
let anchor = context.anchor;
|
|
let after_messages = context.after;
|
|
let (before, username, after) = tokio::try_join!(
|
|
self.chat_messages_to_proto(before_messages),
|
|
username_for_chat_message(self, &anchor.message),
|
|
self.chat_messages_to_proto(after_messages),
|
|
)?;
|
|
let message = chat_message_to_proto(self, &anchor, username)?;
|
|
Ok(synctv_proto::client::GetChatMessageContextResponse {
|
|
before,
|
|
message: Some(message),
|
|
after,
|
|
})
|
|
}
|
|
|
|
pub async fn get_chat_playback_messages_for_actor(
|
|
&self,
|
|
actor: &RoomActor,
|
|
req: synctv_proto::client::GetChatPlaybackMessagesRequest,
|
|
) -> Result<synctv_proto::client::GetChatPlaybackMessagesResponse, ApiError> {
|
|
self.require_room_permission(
|
|
actor,
|
|
synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY,
|
|
)
|
|
.await?;
|
|
let chat_service = self
|
|
.chat_service
|
|
.as_ref()
|
|
.ok_or_else(chat_service_unavailable_error)?;
|
|
let media_id = optional_trimmed_string(&req.playback_media_id)
|
|
.map(|id| crate::impls::proto_validated_media_id(id, &self.public_id_codec))
|
|
.transpose()?;
|
|
let playlist_id = optional_trimmed_string(&req.playback_playlist_id)
|
|
.map(|id| crate::impls::proto_validated_playlist_id(id, &self.public_id_codec))
|
|
.transpose()?;
|
|
let target = provider_target_from_proto(req.playback_target)?;
|
|
let position_seconds = required_playback_position_seconds(req.position_seconds)?;
|
|
let before_seconds =
|
|
optional_positive_window_seconds(req.before_seconds, 0.0, "before_seconds")?;
|
|
let after_seconds =
|
|
optional_positive_window_seconds(req.after_seconds, 30.0, "after_seconds")?;
|
|
let limit = optional_positive_limit(req.limit, 200, 500, "limit")?;
|
|
let selection = chat_message_selection_from_proto_values(&req.include_message_types)?;
|
|
let target_hash = synctv_core::models::try_hash_playback_target(target.as_ref())
|
|
.map_err(ApiError::from)?;
|
|
let source_metadata = self
|
|
.room_service
|
|
.playback_service()
|
|
.get_playback_source_metadata(&PlaybackSourceIdentity {
|
|
room_id: actor.room_id(),
|
|
media_id,
|
|
playlist_id,
|
|
target_hash,
|
|
})
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
if source_metadata.is_some_and(|metadata| {
|
|
matches!(
|
|
metadata.playback_kind,
|
|
synctv_core::models::PlaybackKind::Live
|
|
)
|
|
}) {
|
|
return Ok(synctv_proto::client::GetChatPlaybackMessagesResponse {
|
|
messages: Vec::new(),
|
|
});
|
|
}
|
|
let messages = chat_service
|
|
.get_playback_messages_with_attachments_for_viewer(
|
|
ChatPlaybackMessagesQuery {
|
|
room_id: actor.room_id(),
|
|
media_id,
|
|
playlist_id,
|
|
target,
|
|
selection,
|
|
position_seconds,
|
|
before_seconds,
|
|
after_seconds,
|
|
limit,
|
|
include_deleted: req.include_deleted,
|
|
},
|
|
actor.user_id().as_ref(),
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
Ok(synctv_proto::client::GetChatPlaybackMessagesResponse {
|
|
messages: self.chat_messages_to_proto(messages).await?,
|
|
})
|
|
}
|
|
|
|
pub fn parse_proto_chat_mentions(
|
|
&self,
|
|
mentions: Vec<synctv_proto::client::ChatMentionInput>,
|
|
) -> Result<Vec<ChatMentionInput>, ApiError> {
|
|
mentions
|
|
.into_iter()
|
|
.map(|mention| {
|
|
let user_id = crate::impls::parse_user_id_param(
|
|
&mention.user_id,
|
|
"mention.user_id",
|
|
&self.public_id_codec,
|
|
)?;
|
|
Ok(ChatMentionInput {
|
|
user_id,
|
|
start: mention.start,
|
|
length: mention.length,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
pub fn validate_update_room_settings_request(
|
|
req: &synctv_proto::client::UpdateRoomSettingsRequest,
|
|
current: synctv_core::models::RoomSettings,
|
|
) -> Result<synctv_core::models::RoomSettings, ApiError> {
|
|
apply_room_settings_patch_from_proto(current, req.clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
build_create_websocket_ticket_request, build_get_chat_history_request,
|
|
build_my_room_list_query, build_room_discovery_query,
|
|
build_transfer_room_ownership_request, chat_pin_event_to_proto,
|
|
delete_chat_message_request_to_core, discovery_room_list_total,
|
|
edit_chat_message_request_to_core, optional_positive_limit,
|
|
optional_positive_window_seconds, optional_trimmed_string, parse_proto_chat_attachments,
|
|
public_room_discovery_access, required_playback_position_seconds,
|
|
required_room_availability, room_discovery_access,
|
|
runtime_settings_store_unavailable_error,
|
|
};
|
|
use crate::impls::ErrorKind;
|
|
use std::collections::HashMap;
|
|
use synctv_core::service::ClientResourceAvailability;
|
|
use synctv_core::{
|
|
models::{
|
|
ChatMessage, ChatMessagePin, ChatMessageWithAttachments, ChatPinEvent,
|
|
ChatPinEventKind, RoomId, RoomRole, RoomSettings, SignupMethod, User,
|
|
},
|
|
repository::{RoomDiscoveryViewerState, UserRepository},
|
|
service::RoomService,
|
|
};
|
|
|
|
type TestResult<T = ()> = anyhow::Result<T>;
|
|
|
|
#[test]
|
|
fn discovery_room_list_total_excludes_only_available_featured_rooms() {
|
|
assert_eq!(discovery_room_list_total(0, 5), 0);
|
|
assert_eq!(discovery_room_list_total(3, 5), 0);
|
|
assert_eq!(discovery_room_list_total(8, 5), 3);
|
|
}
|
|
|
|
fn test_error(message: impl Into<String>) -> anyhow::Error {
|
|
anyhow::anyhow!(message.into())
|
|
}
|
|
|
|
fn api_ok<T>(result: Result<T, crate::impls::ApiError>) -> TestResult<T> {
|
|
result.map_err(|error| test_error(format!("{error:?}")))
|
|
}
|
|
|
|
fn api_err<T>(result: Result<T, crate::impls::ApiError>) -> TestResult<crate::impls::ApiError> {
|
|
match result {
|
|
Ok(_) => Err(test_error("expected API error result")),
|
|
Err(error) => Ok(error),
|
|
}
|
|
}
|
|
|
|
fn assert_invalid_argument_contains(
|
|
error: &crate::impls::ApiError,
|
|
expected: &str,
|
|
) -> TestResult {
|
|
if !error.is_invalid_argument() {
|
|
return Err(test_error(format!("expected invalid input, got {error:?}")));
|
|
}
|
|
let message = error.message();
|
|
assert!(message.contains(expected), "{message}");
|
|
Ok(())
|
|
}
|
|
|
|
fn codec_ok<T>(result: Result<T, String>) -> TestResult<T> {
|
|
result.map_err(test_error)
|
|
}
|
|
|
|
fn test_client_api(
|
|
pool: sqlx::PgPool,
|
|
user_service: std::sync::Arc<synctv_core::service::UserService>,
|
|
) -> super::ClientApiImpl {
|
|
let room_service = std::sync::Arc::new(
|
|
RoomService::new_for_tests(pool, (*user_service).clone())
|
|
.expect("room service should build"),
|
|
);
|
|
super::ClientApiImpl::new_with_runtime(
|
|
crate::impls::ClientApiOptions {
|
|
read_pool: None,
|
|
user_service,
|
|
room_service,
|
|
chat_service: None,
|
|
connection_service: std::sync::Arc::new(
|
|
synctv_realtime::sync::ConnectionManager::default(),
|
|
),
|
|
runtime_settings: std::sync::Arc::new(crate::ApiRuntimeSettings::default()),
|
|
publish_key_service: None,
|
|
jwt_service: synctv_core_testing::create_test_jwt_service(),
|
|
live_streaming_infrastructure: None,
|
|
runtime_settings_store: None,
|
|
provider_stores: std::sync::Arc::new(
|
|
synctv_core::provider::ProviderStoreRegistry::local_only("test:chat-pin-api:"),
|
|
),
|
|
public_id_codec: std::sync::Arc::new(synctv_adapter::PublicIdCodec::plain()),
|
|
email_api: None,
|
|
passkey_service: None,
|
|
},
|
|
crate::test_support::client_api_runtime(),
|
|
)
|
|
}
|
|
|
|
async fn create_user(
|
|
pool: &sqlx::PgPool,
|
|
username: &str,
|
|
) -> TestResult<synctv_core::models::User> {
|
|
UserRepository::new(pool.clone())
|
|
.create(&User::new(username.to_string(), SignupMethod::Password))
|
|
.await
|
|
.map_err(|error| test_error(error.to_string()))
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn favorite_room_requires_current_membership() -> TestResult {
|
|
let (_postgres, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_service =
|
|
std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone()));
|
|
let owner = create_user(&pool, "favorite_requires_owner").await?;
|
|
let outsider = create_user(&pool, "favorite_requires_outsider").await?;
|
|
let api = test_client_api(pool, user_service);
|
|
let room = api_ok(
|
|
api.room_service
|
|
.create_room(
|
|
"Favorite membership room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?;
|
|
|
|
let error = api_err(
|
|
api.favorite_room(
|
|
&outsider.id,
|
|
synctv_proto::client::FavoriteRoomRequest { room_id },
|
|
)
|
|
.await,
|
|
)?;
|
|
|
|
assert!(
|
|
matches!(error, crate::impls::ApiError::Authorization(ref message) if message.contains(synctv_common::messages::NOT_A_MEMBER_OF_THIS_ROOM)),
|
|
"expected membership authorization error, got {error:?}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn room_update_response_preserves_favorite_state() -> TestResult {
|
|
let (_postgres, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_service =
|
|
std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone()));
|
|
let owner = create_user(&pool, "favorite_update_owner").await?;
|
|
let api = test_client_api(pool, user_service);
|
|
let room = api_ok(
|
|
api.room_service
|
|
.create_room(
|
|
"Favorite update response room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?;
|
|
api_ok(
|
|
api.favorite_room(
|
|
&owner.id,
|
|
synctv_proto::client::FavoriteRoomRequest { room_id },
|
|
)
|
|
.await,
|
|
)?;
|
|
|
|
let response = api_ok(api.room_response_after_room_update(&room, &owner.id).await)?;
|
|
|
|
assert!(response.favorited);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn leaving_room_removes_favorite_room() -> TestResult {
|
|
let (_postgres, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_service =
|
|
std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone()));
|
|
let owner = create_user(&pool, "favorite_leave_owner").await?;
|
|
let member = create_user(&pool, "favorite_leave_member").await?;
|
|
let api = test_client_api(pool, user_service);
|
|
let room = api_ok(
|
|
api.room_service
|
|
.create_room(
|
|
"Favorite leave room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
api_ok(
|
|
api.room_service
|
|
.add_member(room.id, owner.id, member.id, RoomRole::Member, false)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?;
|
|
|
|
api_ok(
|
|
api.favorite_room(
|
|
&member.id,
|
|
synctv_proto::client::FavoriteRoomRequest {
|
|
room_id: room_id.clone(),
|
|
},
|
|
)
|
|
.await,
|
|
)?;
|
|
api_ok(
|
|
api.user_service
|
|
.block_user(&member.id, &owner.id)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
|
|
let favorites_before_leave = api_ok(
|
|
api.list_favorite_rooms(
|
|
&member.id,
|
|
synctv_proto::client::ListFavoriteRoomsRequest {
|
|
page: 1,
|
|
page_size: 20,
|
|
search: String::new(),
|
|
},
|
|
)
|
|
.await,
|
|
)?;
|
|
assert_eq!(favorites_before_leave.total, 1);
|
|
assert_eq!(favorites_before_leave.rooms.len(), 1);
|
|
assert!(favorites_before_leave.rooms[0].creator_blocked);
|
|
|
|
let discovery = api_ok(
|
|
api.get_room_discovery(
|
|
&member.id,
|
|
synctv_proto::client::GetRoomDiscoveryRequest {
|
|
room_id: room_id.clone(),
|
|
},
|
|
)
|
|
.await,
|
|
)?;
|
|
assert!(discovery
|
|
.room
|
|
.as_ref()
|
|
.is_some_and(|room| room.creator_blocked));
|
|
|
|
api_ok(api.leave_room(&member.id, &room_id).await)?;
|
|
|
|
let favorites_after_leave = api_ok(
|
|
api.list_favorite_rooms(
|
|
&member.id,
|
|
synctv_proto::client::ListFavoriteRoomsRequest {
|
|
page: 1,
|
|
page_size: 20,
|
|
search: String::new(),
|
|
},
|
|
)
|
|
.await,
|
|
)?;
|
|
assert_eq!(favorites_after_leave.total, 0);
|
|
assert!(favorites_after_leave.rooms.is_empty());
|
|
assert!(!api
|
|
.room_service
|
|
.is_room_favorited_by_user(&member.id, &room.id)
|
|
.await
|
|
.map_err(|error| test_error(error.to_string()))?);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn banned_room_remains_visible_to_members_and_rejects_entry() -> TestResult {
|
|
let (_postgres, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_service =
|
|
std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone()));
|
|
let owner = create_user(&pool, "banned_room_visible_owner").await?;
|
|
let member = create_user(&pool, "banned_room_visible_member").await?;
|
|
let outsider = create_user(&pool, "banned_room_visible_outsider").await?;
|
|
let api = test_client_api(pool, user_service);
|
|
let room = api_ok(
|
|
api.room_service
|
|
.create_room(
|
|
"Banned room remains visible".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
api_ok(
|
|
api.room_service
|
|
.add_member(room.id, owner.id, member.id, RoomRole::Member, false)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
api_ok(
|
|
api.room_service
|
|
.ban_room(&room.id, &owner.id)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?;
|
|
|
|
let my_rooms = api_ok(
|
|
api.list_my_rooms(
|
|
&member.id,
|
|
synctv_proto::client::ListMyRoomsRequest::default(),
|
|
)
|
|
.await,
|
|
)?;
|
|
assert_eq!(my_rooms.total, 1);
|
|
let listed_room = my_rooms.rooms[0]
|
|
.room
|
|
.as_ref()
|
|
.ok_or_else(|| test_error("my room response should include the room"))?;
|
|
assert!(listed_room.is_banned);
|
|
assert_eq!(
|
|
listed_room.availability,
|
|
synctv_proto::client::ResourceAvailability::CreatorInactive as i32
|
|
);
|
|
|
|
let room_detail = api_ok(api.get_room(&member.id, &room_id).await)?;
|
|
let detail_room = room_detail
|
|
.room
|
|
.as_ref()
|
|
.ok_or_else(|| test_error("room detail should include the room"))?;
|
|
assert_eq!(
|
|
detail_room.availability,
|
|
synctv_proto::client::ResourceAvailability::CreatorInactive as i32
|
|
);
|
|
|
|
let discovery_request = synctv_proto::client::GetRoomDiscoveryRequest {
|
|
room_id: room_id.clone(),
|
|
};
|
|
let discovery = api_ok(
|
|
api.get_room_discovery(&member.id, discovery_request.clone())
|
|
.await,
|
|
)?;
|
|
assert!(discovery.joined);
|
|
assert!(!discovery.can_join);
|
|
assert_eq!(
|
|
discovery.access,
|
|
synctv_proto::client::RoomDiscoveryAccess::Unavailable as i32
|
|
);
|
|
|
|
let outsider_error = api_err(
|
|
api.get_room_discovery(&outsider.id, discovery_request.clone())
|
|
.await,
|
|
)?;
|
|
assert!(matches!(
|
|
outsider_error,
|
|
crate::impls::ApiError::NotFound(_)
|
|
));
|
|
let public_error = api_err(api.get_public_room_discovery(discovery_request).await)?;
|
|
assert!(matches!(public_error, crate::impls::ApiError::NotFound(_)));
|
|
|
|
let ticket_error = api_err(
|
|
api.create_websocket_ticket_with_control(
|
|
&member.id,
|
|
0,
|
|
synctv_proto::client::CreateWebSocketTicketRequest {
|
|
room_id: room_id.clone(),
|
|
},
|
|
None,
|
|
)
|
|
.await,
|
|
)?;
|
|
assert!(matches!(
|
|
ticket_error,
|
|
crate::impls::ApiError::Authorization(ref message) if message.contains("banned")
|
|
));
|
|
|
|
let leave = api_ok(api.leave_room(&member.id, &room_id).await)?;
|
|
assert!(leave.success);
|
|
let deleted = api_ok(api.delete_room(&owner.id, &room_id).await)?;
|
|
assert!(deleted.success);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn room_with_banned_creator_remains_in_member_list_as_unavailable() -> TestResult {
|
|
let (_postgres, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_service =
|
|
std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone()));
|
|
let owner = create_user(&pool, "banned_creator_visible_owner").await?;
|
|
let member = create_user(&pool, "banned_creator_visible_member").await?;
|
|
let api = test_client_api(pool, user_service);
|
|
let room = api_ok(
|
|
api.room_service
|
|
.create_room(
|
|
"Banned creator room remains visible".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
api_ok(
|
|
api.room_service
|
|
.add_member(room.id, owner.id, member.id, RoomRole::Member, false)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
api_ok(
|
|
api.user_service
|
|
.ban_user(
|
|
&owner.id,
|
|
Some(&member.id),
|
|
Some("creator lifecycle test".to_string()),
|
|
)
|
|
.await
|
|
.map_err(crate::impls::ApiError::from),
|
|
)?;
|
|
|
|
let my_rooms = api_ok(
|
|
api.list_my_rooms(
|
|
&member.id,
|
|
synctv_proto::client::ListMyRoomsRequest::default(),
|
|
)
|
|
.await,
|
|
)?;
|
|
assert_eq!(my_rooms.total, 1);
|
|
let listed_room = my_rooms.rooms[0]
|
|
.room
|
|
.as_ref()
|
|
.ok_or_else(|| test_error("my room response should include the room"))?;
|
|
assert!(!listed_room.is_banned);
|
|
assert_eq!(
|
|
listed_room.availability,
|
|
synctv_proto::client::ResourceAvailability::CreatorInactive as i32
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_room_discovery_query_uses_popularity_inputs() -> TestResult {
|
|
let public_id_codec = synctv_adapter::PublicIdCodec::plain();
|
|
let query = api_ok(build_room_discovery_query(
|
|
synctv_proto::client::DiscoverRoomsRequest {
|
|
page: 0,
|
|
page_size: 0,
|
|
search: "alpha".to_string(),
|
|
category_id: String::new(),
|
|
label_ids: Vec::new(),
|
|
},
|
|
&public_id_codec,
|
|
))?;
|
|
|
|
assert_eq!(query.pagination.page, 1);
|
|
assert_eq!(query.pagination.page_size, 20);
|
|
assert_eq!(query.search.as_deref(), Some("alpha"));
|
|
assert_eq!(query.status, Some(synctv_core::models::RoomStatus::Active));
|
|
assert_eq!(query.is_banned, Some(false));
|
|
assert_eq!(
|
|
query.sort_by,
|
|
synctv_core::models::RoomListSortBy::LastActivityAt
|
|
);
|
|
assert_eq!(
|
|
query.sort_direction,
|
|
synctv_core::models::SortDirection::Desc
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn room_discovery_marks_pending_join_request_as_non_joinable() {
|
|
let (can_join, access) = room_discovery_access(
|
|
RoomDiscoveryViewerState {
|
|
pending_join_request: true,
|
|
..RoomDiscoveryViewerState::default()
|
|
},
|
|
&RoomSettings::default(),
|
|
1,
|
|
ClientResourceAvailability::Available,
|
|
false,
|
|
);
|
|
|
|
assert!(!can_join);
|
|
assert_eq!(
|
|
access,
|
|
synctv_proto::client::RoomDiscoveryAccess::PendingApproval as i32
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn room_discovery_prioritizes_resource_unavailability_for_joined_viewer() {
|
|
let (can_join, access) = room_discovery_access(
|
|
RoomDiscoveryViewerState {
|
|
joined: true,
|
|
..RoomDiscoveryViewerState::default()
|
|
},
|
|
&RoomSettings::default(),
|
|
1,
|
|
ClientResourceAvailability::CreatorInactive,
|
|
false,
|
|
);
|
|
|
|
assert!(!can_join);
|
|
assert_eq!(
|
|
access,
|
|
synctv_proto::client::RoomDiscoveryAccess::Unavailable as i32
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn public_room_discovery_guest_access_requires_all_guest_conditions() {
|
|
let mut settings = RoomSettings::default();
|
|
settings.allow_guest_join = synctv_core::models::room_settings::AllowGuestJoin(true);
|
|
|
|
assert!(public_room_discovery_access(
|
|
true,
|
|
&settings,
|
|
ClientResourceAvailability::Available,
|
|
false,
|
|
));
|
|
assert!(!public_room_discovery_access(
|
|
false,
|
|
&settings,
|
|
ClientResourceAvailability::Available,
|
|
false,
|
|
));
|
|
assert!(!public_room_discovery_access(
|
|
true,
|
|
&settings,
|
|
ClientResourceAvailability::Available,
|
|
true,
|
|
));
|
|
|
|
settings.allow_guest_join = synctv_core::models::room_settings::AllowGuestJoin(false);
|
|
assert!(!public_room_discovery_access(
|
|
true,
|
|
&settings,
|
|
ClientResourceAvailability::Available,
|
|
false,
|
|
));
|
|
assert!(!public_room_discovery_access(
|
|
true,
|
|
&RoomSettings::default(),
|
|
ClientResourceAvailability::CreatorInactive,
|
|
false,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_my_room_list_query_maps_filters_sorting_and_defaults() -> TestResult {
|
|
let query = api_ok(build_my_room_list_query(
|
|
synctv_proto::client::ListMyRoomsRequest {
|
|
page: 0,
|
|
page_size: 0,
|
|
search: "alpha".to_string(),
|
|
status: synctv_proto::common::RoomStatus::Closed as i32,
|
|
is_banned: Some(false),
|
|
relation: synctv_proto::client::MyRoomRelation::Participating as i32,
|
|
sort_by: synctv_proto::client::MyRoomListSortBy::Name as i32,
|
|
sort_direction: synctv_proto::client::SortDirection::Asc as i32,
|
|
},
|
|
))?;
|
|
|
|
assert_eq!(query.pagination.page, 1);
|
|
assert_eq!(query.pagination.page_size, 20);
|
|
assert_eq!(query.search.as_deref(), Some("alpha"));
|
|
assert_eq!(query.status, Some(synctv_core::models::RoomStatus::Closed));
|
|
assert_eq!(query.is_banned, Some(false));
|
|
assert_eq!(
|
|
query.relation,
|
|
synctv_core::models::MyRoomRelation::Participating
|
|
);
|
|
assert_eq!(query.sort_by, synctv_core::models::MyRoomListSortBy::Name);
|
|
assert_eq!(
|
|
query.sort_direction,
|
|
synctv_core::models::SortDirection::Asc
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_my_room_list_query_defaults_relation_to_all() -> TestResult {
|
|
let query = api_ok(build_my_room_list_query(
|
|
synctv_proto::client::ListMyRoomsRequest {
|
|
page: 1,
|
|
page_size: 20,
|
|
search: String::new(),
|
|
status: synctv_proto::common::RoomStatus::Unspecified as i32,
|
|
is_banned: None,
|
|
relation: synctv_proto::client::MyRoomRelation::Unspecified as i32,
|
|
sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32,
|
|
sort_direction: synctv_proto::client::SortDirection::Unspecified as i32,
|
|
},
|
|
))?;
|
|
|
|
assert_eq!(query.relation, synctv_core::models::MyRoomRelation::All);
|
|
assert_eq!(
|
|
query.sort_by,
|
|
synctv_core::models::MyRoomListSortBy::Frequent
|
|
);
|
|
assert_eq!(
|
|
query.sort_direction,
|
|
synctv_core::models::SortDirection::Desc
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_my_room_list_query_rejects_unknown_room_status() -> TestResult {
|
|
let error = api_err(build_my_room_list_query(
|
|
synctv_proto::client::ListMyRoomsRequest {
|
|
page: 1,
|
|
page_size: 20,
|
|
search: String::new(),
|
|
status: 99,
|
|
is_banned: None,
|
|
relation: synctv_proto::client::MyRoomRelation::Unspecified as i32,
|
|
sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32,
|
|
sort_direction: synctv_proto::client::SortDirection::Unspecified as i32,
|
|
},
|
|
))?;
|
|
|
|
assert_invalid_argument_contains(&error, "status")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn my_room_query_rejects_unknown_sort_and_relation_enums() -> TestResult {
|
|
let my_room_relation_error = api_err(build_my_room_list_query(
|
|
synctv_proto::client::ListMyRoomsRequest {
|
|
page: 1,
|
|
page_size: 20,
|
|
search: String::new(),
|
|
status: synctv_proto::common::RoomStatus::Unspecified as i32,
|
|
is_banned: None,
|
|
relation: 99,
|
|
sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32,
|
|
sort_direction: synctv_proto::client::SortDirection::Unspecified as i32,
|
|
},
|
|
))?;
|
|
assert_invalid_argument_contains(&my_room_relation_error, "relation")?;
|
|
|
|
let my_room_sort_error = api_err(build_my_room_list_query(
|
|
synctv_proto::client::ListMyRoomsRequest {
|
|
page: 1,
|
|
page_size: 20,
|
|
search: String::new(),
|
|
status: synctv_proto::common::RoomStatus::Unspecified as i32,
|
|
is_banned: None,
|
|
relation: synctv_proto::client::MyRoomRelation::Unspecified as i32,
|
|
sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32,
|
|
sort_direction: 99,
|
|
},
|
|
))?;
|
|
assert_invalid_argument_contains(&my_room_sort_error, "sort_direction")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_my_room_list_query_rejects_too_long_search() -> TestResult {
|
|
let error = api_err(build_my_room_list_query(
|
|
synctv_proto::client::ListMyRoomsRequest {
|
|
page: 1,
|
|
page_size: 20,
|
|
search: "a".repeat(101),
|
|
status: synctv_proto::common::RoomStatus::Unspecified as i32,
|
|
is_banned: None,
|
|
relation: synctv_proto::client::MyRoomRelation::Unspecified as i32,
|
|
sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32,
|
|
sort_direction: synctv_proto::client::SortDirection::Unspecified as i32,
|
|
},
|
|
))?;
|
|
|
|
assert_invalid_argument_contains(&error, "search")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_room_discovery_query_rejects_invalid_proto_request() -> TestResult {
|
|
let public_id_codec = synctv_adapter::PublicIdCodec::plain();
|
|
let error = api_err(build_room_discovery_query(
|
|
synctv_proto::client::DiscoverRoomsRequest {
|
|
page: -1,
|
|
page_size: 101,
|
|
search: "a".repeat(101),
|
|
category_id: String::new(),
|
|
label_ids: Vec::new(),
|
|
},
|
|
&public_id_codec,
|
|
))?;
|
|
|
|
assert!(error.is_invalid_argument(), "{error:?}");
|
|
let message = error.message();
|
|
assert!(message.contains("page"), "{message}");
|
|
assert!(message.contains("page_size"), "{message}");
|
|
assert!(message.contains("search"), "{message}");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_transfer_room_ownership_request_rejects_invalid_new_owner_user_id() -> TestResult {
|
|
let codec = synctv_adapter::PublicIdCodec::plain();
|
|
let error = api_err(build_transfer_room_ownership_request(
|
|
synctv_proto::client::TransferRoomOwnershipRequest {
|
|
new_owner_user_id: "bad-id".to_string(),
|
|
},
|
|
&codec,
|
|
))?;
|
|
|
|
assert_invalid_argument_contains(&error, "new_owner_user_id")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_create_websocket_ticket_request_rejects_invalid_room_id() -> TestResult {
|
|
let codec = synctv_adapter::PublicIdCodec::plain();
|
|
let error = api_err(build_create_websocket_ticket_request(
|
|
&synctv_proto::client::CreateWebSocketTicketRequest {
|
|
room_id: "bad-room".to_string(),
|
|
},
|
|
&codec,
|
|
))?;
|
|
|
|
assert_invalid_argument_contains(&error, "room_id")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_create_websocket_ticket_request_parses_proto_validated_room_id() -> TestResult {
|
|
let codec = synctv_adapter::PublicIdCodec::plain();
|
|
let room_id = synctv_core::models::RoomId::expect_positive(123);
|
|
let room_public_id = codec_ok(codec.encode_room_id(room_id))?;
|
|
let parsed = api_ok(build_create_websocket_ticket_request(
|
|
&synctv_proto::client::CreateWebSocketTicketRequest {
|
|
room_id: room_public_id,
|
|
},
|
|
&codec,
|
|
))?;
|
|
|
|
assert_eq!(parsed, room_id);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_create_websocket_ticket_request_rejects_proto_valid_but_undecodable_room_id(
|
|
) -> TestResult {
|
|
let codec = synctv_adapter::PublicIdCodec::plain();
|
|
let error = api_err(build_create_websocket_ticket_request(
|
|
&synctv_proto::client::CreateWebSocketTicketRequest {
|
|
room_id: "room_abc".to_string(),
|
|
},
|
|
&codec,
|
|
))?;
|
|
|
|
assert_invalid_argument_contains(&error, "RoomId")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_get_chat_history_request_rejects_invalid_limit() -> TestResult {
|
|
let error = api_err(build_get_chat_history_request(
|
|
&synctv_proto::client::GetChatHistoryRequest {
|
|
limit: 101,
|
|
cursor: String::new(),
|
|
include_message_types: Vec::new(),
|
|
},
|
|
))?;
|
|
|
|
assert_invalid_argument_contains(&error, "limit")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn required_room_availability_rejects_missing_batch_entry() -> TestResult {
|
|
let room_id = synctv_core::models::RoomId::expect_positive(456);
|
|
let map = HashMap::from([(room_id, ClientResourceAvailability::CreatorInactive)]);
|
|
|
|
assert_eq!(
|
|
api_ok(required_room_availability(&map, &room_id))?,
|
|
ClientResourceAvailability::CreatorInactive
|
|
);
|
|
|
|
let missing_room_id = synctv_core::models::RoomId::expect_positive(457);
|
|
let error = api_err(required_room_availability(&map, &missing_room_id))?;
|
|
assert!(matches!(
|
|
error,
|
|
crate::impls::ApiError::Internal(message)
|
|
if message.contains("Missing client availability for room")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn build_get_chat_history_request_rejects_invalid_cursor() -> TestResult {
|
|
let error = api_err(build_get_chat_history_request(
|
|
&synctv_proto::client::GetChatHistoryRequest {
|
|
limit: 50,
|
|
cursor: "not-a-cursor".to_string(),
|
|
include_message_types: Vec::new(),
|
|
},
|
|
))?;
|
|
|
|
match error {
|
|
crate::impls::ApiError::InvalidInput(message) => {
|
|
assert!(message.contains("Invalid cursor format"), "{message}");
|
|
}
|
|
other => return Err(test_error(format!("expected invalid input, got {other:?}"))),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn optional_trimmed_string_normalizes_idempotency_keys() {
|
|
assert_eq!(
|
|
optional_trimmed_string(" client-key ").as_deref(),
|
|
Some("client-key")
|
|
);
|
|
assert!(optional_trimmed_string(" \n\t ").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn chat_playback_window_seconds_validate_explicit_values() -> TestResult {
|
|
assert_eq!(
|
|
api_ok(optional_positive_window_seconds(0.0, 30.0, "after_seconds"))?,
|
|
30.0
|
|
);
|
|
assert_eq!(
|
|
api_ok(optional_positive_window_seconds(
|
|
12.5,
|
|
30.0,
|
|
"after_seconds"
|
|
))?,
|
|
12.5
|
|
);
|
|
assert!(matches!(
|
|
optional_positive_window_seconds(-1.0, 30.0, "after_seconds"),
|
|
Err(crate::impls::ApiError::InvalidInput(message))
|
|
if message.contains("after_seconds")
|
|
));
|
|
assert!(matches!(
|
|
optional_positive_window_seconds(f64::NAN, 30.0, "after_seconds"),
|
|
Err(crate::impls::ApiError::InvalidInput(message))
|
|
if message.contains("after_seconds")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_playback_limit_validates_explicit_values() -> TestResult {
|
|
assert_eq!(api_ok(optional_positive_limit(0, 200, 500, "limit"))?, 200);
|
|
assert_eq!(api_ok(optional_positive_limit(50, 200, 500, "limit"))?, 50);
|
|
assert!(matches!(
|
|
optional_positive_limit(-1, 200, 500, "limit"),
|
|
Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("limit")
|
|
));
|
|
assert!(matches!(
|
|
optional_positive_limit(501, 200, 500, "limit"),
|
|
Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("limit")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_playback_position_seconds_is_required_valid_value() -> TestResult {
|
|
assert_eq!(api_ok(required_playback_position_seconds(42.5))?, 42.5);
|
|
assert!(matches!(
|
|
required_playback_position_seconds(-0.1),
|
|
Err(crate::impls::ApiError::InvalidInput(message))
|
|
if message.contains("position_seconds")
|
|
));
|
|
assert!(matches!(
|
|
required_playback_position_seconds(f64::INFINITY),
|
|
Err(crate::impls::ApiError::InvalidInput(message))
|
|
if message.contains("position_seconds")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn chat_pin_event_response_preserves_optional_message_username() -> TestResult {
|
|
let (_postgres, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_service =
|
|
std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone()));
|
|
let user = UserRepository::new(pool.clone())
|
|
.create(&User::new(
|
|
"pin_response_author".to_string(),
|
|
SignupMethod::Password,
|
|
))
|
|
.await
|
|
.map_err(|error| test_error(error.to_string()))?;
|
|
let api = test_client_api(pool, user_service);
|
|
let room_id = RoomId::expect_positive(7);
|
|
let occurred_at = synctv_core::SystemClock.now();
|
|
let mut message = ChatMessage::new(room_id, user.id, "pinned body".to_string());
|
|
message.id = 42;
|
|
message.created_at = occurred_at;
|
|
let pin = ChatMessagePin {
|
|
room_id,
|
|
message_id: message.id,
|
|
message_created_at: message.created_at,
|
|
pinned_by: Some(user.id),
|
|
pinned_by_username: Some(user.username.clone()),
|
|
note: None,
|
|
pinned_at: occurred_at,
|
|
};
|
|
let event = ChatPinEvent {
|
|
event_id: "pin-response-event".to_string(),
|
|
sequence: 5,
|
|
room_id,
|
|
actor_user_id: user.id,
|
|
kind: ChatPinEventKind::Pinned,
|
|
message: ChatMessageWithAttachments {
|
|
message,
|
|
attachments: Vec::new(),
|
|
reactions: Vec::new(),
|
|
mentions: Vec::new(),
|
|
pin: Some(pin.clone()),
|
|
},
|
|
pin: Some(pin),
|
|
occurred_at,
|
|
};
|
|
|
|
let proto = api_ok(chat_pin_event_to_proto(&api, event).await)?;
|
|
let message = proto
|
|
.message
|
|
.ok_or_else(|| test_error("pin event response should contain message"))?;
|
|
assert_eq!(message.username.as_deref(), Some("pin_response_author"));
|
|
|
|
let mut missing_author = ChatMessage::new(room_id, user.id, "system body".to_string());
|
|
missing_author.id = 43;
|
|
missing_author.user_id = None;
|
|
missing_author.created_at = occurred_at;
|
|
let event = ChatPinEvent {
|
|
event_id: "pin-response-without-author".to_string(),
|
|
sequence: 6,
|
|
room_id,
|
|
actor_user_id: user.id,
|
|
kind: ChatPinEventKind::MessageDeleted,
|
|
message: ChatMessageWithAttachments {
|
|
message: missing_author,
|
|
attachments: Vec::new(),
|
|
reactions: Vec::new(),
|
|
mentions: Vec::new(),
|
|
pin: None,
|
|
},
|
|
pin: None,
|
|
occurred_at,
|
|
};
|
|
let proto = api_ok(chat_pin_event_to_proto(&api, event).await)?;
|
|
let message = proto
|
|
.message
|
|
.ok_or_else(|| test_error("pin event response should contain message"))?;
|
|
assert_eq!(message.username, None);
|
|
|
|
let mut system_message = ChatMessage::new(room_id, user.id, "playback changed".to_string());
|
|
system_message.id = 44;
|
|
system_message.message_type = synctv_core::models::ChatMessageType::SystemPlaybackChanged;
|
|
system_message.created_at = occurred_at;
|
|
let event = ChatPinEvent {
|
|
event_id: "pin-response-system-message".to_string(),
|
|
sequence: 7,
|
|
room_id,
|
|
actor_user_id: user.id,
|
|
kind: ChatPinEventKind::Pinned,
|
|
message: ChatMessageWithAttachments {
|
|
message: system_message,
|
|
attachments: Vec::new(),
|
|
reactions: Vec::new(),
|
|
mentions: Vec::new(),
|
|
pin: None,
|
|
},
|
|
pin: None,
|
|
occurred_at,
|
|
};
|
|
let proto = api_ok(chat_pin_event_to_proto(&api, event).await)?;
|
|
let message = proto
|
|
.message
|
|
.ok_or_else(|| test_error("pin event response should contain message"))?;
|
|
assert_eq!(message.username, None);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_attachment_display_proto_hides_upload_token_metadata() -> TestResult {
|
|
let metadata = synctv_core::models::FileMetadata {
|
|
width: Some(640),
|
|
height: Some(480),
|
|
blurhash: Some("abc".to_string()),
|
|
..Default::default()
|
|
};
|
|
let attachment = synctv_core::models::NewStoredFile {
|
|
filename: None,
|
|
id: "attachment-1".to_string(),
|
|
storage_backend: "database".to_string(),
|
|
object_key: "rooms/1/chat/2/attachment-1".to_string(),
|
|
object_access: None,
|
|
url: Some("https://cdn.example.test/rooms/1/chat/2/attachment-1.webp".to_string()),
|
|
mime_type: Some("image/webp".to_string()),
|
|
size_bytes: Some(1024),
|
|
width: Some(640),
|
|
height: Some(480),
|
|
metadata: metadata.clone(),
|
|
};
|
|
|
|
let proto = api_ok(super::new_chat_attachment_to_proto(&attachment))?;
|
|
|
|
assert_eq!(proto.id, "attachment-1");
|
|
assert_eq!(
|
|
proto.url,
|
|
"https://cdn.example.test/rooms/1/chat/2/attachment-1.webp"
|
|
);
|
|
let proto_metadata = proto.metadata.expect("metadata should be present");
|
|
assert_eq!(proto_metadata.width, Some(640));
|
|
assert_eq!(proto_metadata.height, Some(480));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_attachment_upload_session_returns_submit_reference() -> TestResult {
|
|
let attachment = synctv_core::models::NewStoredFile {
|
|
filename: None,
|
|
id: "attachment-1".to_string(),
|
|
storage_backend: "database".to_string(),
|
|
object_key: "rooms/1/chat/2/attachment-1".to_string(),
|
|
object_access: None,
|
|
url: None,
|
|
mime_type: Some("image/webp".to_string()),
|
|
size_bytes: Some(1024),
|
|
width: Some(640),
|
|
height: Some(480),
|
|
metadata: synctv_core::models::FileMetadata {
|
|
upload_token: Some("v1.payload.signature".to_string()),
|
|
..Default::default()
|
|
},
|
|
};
|
|
|
|
let proto = api_ok(super::upload_session_chat_attachment_to_proto(&attachment))?;
|
|
let parsed = api_ok(parse_proto_chat_attachments(std::slice::from_ref(&proto)))?;
|
|
|
|
assert_eq!(proto.id, "attachment-1");
|
|
assert_eq!(
|
|
proto.kind,
|
|
synctv_proto::client::ChatAttachmentReferenceKind::Upload as i32
|
|
);
|
|
assert_eq!(parsed.len(), 1);
|
|
assert_eq!(parsed[0].id, "attachment-1");
|
|
assert!(matches!(
|
|
parsed[0].kind,
|
|
synctv_core::models::SubmittedFileReferenceKind::Upload
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_attachment_reuse_reference_parses_to_core() -> TestResult {
|
|
let parsed = api_ok(parse_proto_chat_attachments(&[
|
|
synctv_proto::client::ChatAttachmentReference {
|
|
id: "reuse-token".to_string(),
|
|
kind: synctv_proto::client::ChatAttachmentReferenceKind::Reuse as i32,
|
|
},
|
|
]))?;
|
|
|
|
assert_eq!(parsed.len(), 1);
|
|
assert_eq!(parsed[0].id, "reuse-token");
|
|
assert!(matches!(
|
|
parsed[0].kind,
|
|
synctv_core::models::SubmittedFileReferenceKind::Reuse
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_attachment_upload_session_requires_upload_metadata_when_upload_required() {
|
|
let session = synctv_core::models::FileUploadSession {
|
|
file: synctv_core::models::NewStoredFile {
|
|
filename: None,
|
|
id: "attachment-1".to_string(),
|
|
storage_backend: "database".to_string(),
|
|
object_key: "rooms/1/chat/2/attachment-1".to_string(),
|
|
object_access: None,
|
|
url: None,
|
|
mime_type: Some("image/webp".to_string()),
|
|
size_bytes: Some(1024),
|
|
width: Some(640),
|
|
height: Some(480),
|
|
metadata: synctv_core::models::FileMetadata {
|
|
upload_token: Some("v1.payload.signature".to_string()),
|
|
..Default::default()
|
|
},
|
|
},
|
|
encoded_object_key: "encoded-attachment-1".to_string(),
|
|
upload_required: true,
|
|
ownership_proof_required: false,
|
|
ownership_proof_nonce: None,
|
|
ownership_proof_ranges: Vec::new(),
|
|
upload_object_access: None,
|
|
upload_url: Some("https://upload.example.test/attachment-1".to_string()),
|
|
upload_method: None,
|
|
upload_headers: Default::default(),
|
|
expires_at: Some(synctv_core::SystemClock.now()),
|
|
max_size_bytes: 1024 * 1024,
|
|
resumable: true,
|
|
part_size_bytes: 4 * 1024 * 1024,
|
|
uploaded_size_bytes: 0,
|
|
uploaded_parts: Vec::new(),
|
|
upload_id: None,
|
|
part_urls: Vec::new(),
|
|
};
|
|
|
|
assert!(matches!(
|
|
super::upload_session_to_proto(session),
|
|
Err(crate::impls::ApiError::Internal(message)) if message.contains("upload_method")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn chat_attachment_upload_session_accepts_multipart_upload_targets() {
|
|
let session = synctv_core::models::FileUploadSession {
|
|
file: synctv_core::models::NewStoredFile {
|
|
filename: None,
|
|
id: "attachment-1".to_string(),
|
|
storage_backend: "s3".to_string(),
|
|
object_key: "rooms/1/chat/2/attachment-1".to_string(),
|
|
object_access: None,
|
|
url: None,
|
|
mime_type: Some("image/webp".to_string()),
|
|
size_bytes: Some(1024),
|
|
width: Some(640),
|
|
height: Some(480),
|
|
metadata: synctv_core::models::FileMetadata {
|
|
upload_token: Some("v1.payload.signature".to_string()),
|
|
..Default::default()
|
|
},
|
|
},
|
|
encoded_object_key: "encoded-attachment-1".to_string(),
|
|
upload_required: true,
|
|
ownership_proof_required: false,
|
|
ownership_proof_nonce: None,
|
|
ownership_proof_ranges: Vec::new(),
|
|
upload_object_access: None,
|
|
upload_url: Some("https://upload.example.test/attachment-1".to_string()),
|
|
upload_method: Some("PUT".to_string()),
|
|
upload_headers: Default::default(),
|
|
expires_at: Some(synctv_core::SystemClock.now()),
|
|
max_size_bytes: 1024 * 1024,
|
|
resumable: true,
|
|
part_size_bytes: 4 * 1024 * 1024,
|
|
uploaded_size_bytes: 0,
|
|
uploaded_parts: Vec::new(),
|
|
upload_id: Some("upload-id".to_string()),
|
|
part_urls: vec![synctv_core::models::FileUploadPartUrl {
|
|
part_number: 1,
|
|
offset_bytes: 0,
|
|
size_bytes: 1024,
|
|
upload_url: "https://upload.example.test/part-1".to_string(),
|
|
upload_method: "PUT".to_string(),
|
|
upload_headers: Default::default(),
|
|
expires_at: Some(synctv_core::SystemClock.now()),
|
|
}],
|
|
};
|
|
|
|
let proto = api_ok(super::upload_session_to_proto(session))
|
|
.expect("multipart upload session should convert");
|
|
|
|
assert_eq!(
|
|
proto.upload_url.as_deref(),
|
|
Some("https://upload.example.test/attachment-1")
|
|
);
|
|
assert_eq!(proto.upload_method.as_deref(), Some("PUT"));
|
|
assert_eq!(proto.part_urls.len(), 1);
|
|
assert_eq!(proto.part_urls[0].upload_method, "PUT");
|
|
}
|
|
|
|
#[test]
|
|
fn edit_chat_message_request_maps_client_operation_id() -> TestResult {
|
|
let request = synctv_proto::client::EditChatMessageRequest {
|
|
message_id: "42".to_string(),
|
|
content: "hello".to_string(),
|
|
expected_version: 7,
|
|
metadata: None,
|
|
client_operation_id: " edit-op-42 ".to_string(),
|
|
};
|
|
let core = api_ok(edit_chat_message_request_to_core(
|
|
synctv_core::models::RoomId::expect_positive(9),
|
|
synctv_core::models::UserId::expect_positive(11),
|
|
request,
|
|
))?;
|
|
|
|
assert_eq!(
|
|
core.room_id,
|
|
synctv_core::models::RoomId::expect_positive(9)
|
|
);
|
|
assert_eq!(
|
|
core.user_id,
|
|
synctv_core::models::UserId::expect_positive(11)
|
|
);
|
|
assert_eq!(core.message_id, 42);
|
|
assert_eq!(core.client_operation_id.as_deref(), Some("edit-op-42"));
|
|
assert_eq!(core.expected_version, Some(7));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn delete_chat_message_request_maps_client_operation_id() -> TestResult {
|
|
let request = synctv_proto::client::DeleteChatMessageRequest {
|
|
message_id: "42".to_string(),
|
|
expected_version: 7,
|
|
reason: " cleanup ".to_string(),
|
|
client_operation_id: " delete-op-42 ".to_string(),
|
|
};
|
|
let core = api_ok(delete_chat_message_request_to_core(
|
|
synctv_core::models::RoomId::expect_positive(9),
|
|
synctv_core::models::UserId::expect_positive(11),
|
|
&request,
|
|
))?;
|
|
|
|
assert_eq!(
|
|
core.room_id,
|
|
synctv_core::models::RoomId::expect_positive(9)
|
|
);
|
|
assert_eq!(
|
|
core.user_id,
|
|
synctv_core::models::UserId::expect_positive(11)
|
|
);
|
|
assert_eq!(core.message_id, 42);
|
|
assert_eq!(core.client_operation_id.as_deref(), Some("delete-op-42"));
|
|
assert_eq!(core.reason.as_deref(), Some("cleanup"));
|
|
assert_eq!(core.expected_version, Some(7));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn edit_chat_message_request_accepts_absent_expected_version() -> TestResult {
|
|
let core = api_ok(edit_chat_message_request_to_core(
|
|
synctv_core::models::RoomId::expect_positive(9),
|
|
synctv_core::models::UserId::expect_positive(11),
|
|
synctv_proto::client::EditChatMessageRequest {
|
|
message_id: "42".to_string(),
|
|
content: "hello".to_string(),
|
|
expected_version: 0,
|
|
metadata: None,
|
|
client_operation_id: String::new(),
|
|
},
|
|
))?;
|
|
|
|
assert_eq!(core.expected_version, None);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn delete_chat_message_request_accepts_absent_expected_version() -> TestResult {
|
|
let request = synctv_proto::client::DeleteChatMessageRequest {
|
|
message_id: "42".to_string(),
|
|
expected_version: 0,
|
|
reason: String::new(),
|
|
client_operation_id: String::new(),
|
|
};
|
|
let core = api_ok(delete_chat_message_request_to_core(
|
|
synctv_core::models::RoomId::expect_positive(9),
|
|
synctv_core::models::UserId::expect_positive(11),
|
|
&request,
|
|
))?;
|
|
|
|
assert_eq!(core.expected_version, None);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_message_request_rejects_negative_expected_version() -> TestResult {
|
|
let edit_error = api_err(edit_chat_message_request_to_core(
|
|
synctv_core::models::RoomId::expect_positive(9),
|
|
synctv_core::models::UserId::expect_positive(11),
|
|
synctv_proto::client::EditChatMessageRequest {
|
|
message_id: "42".to_string(),
|
|
content: "hello".to_string(),
|
|
expected_version: -1,
|
|
metadata: None,
|
|
client_operation_id: String::new(),
|
|
},
|
|
))?;
|
|
assert!(matches!(
|
|
edit_error,
|
|
crate::impls::ApiError::InvalidInput(message)
|
|
if message.contains("expected_version")
|
|
));
|
|
|
|
let delete_request = synctv_proto::client::DeleteChatMessageRequest {
|
|
message_id: "42".to_string(),
|
|
expected_version: -1,
|
|
reason: String::new(),
|
|
client_operation_id: String::new(),
|
|
};
|
|
let delete_error = api_err(delete_chat_message_request_to_core(
|
|
synctv_core::models::RoomId::expect_positive(9),
|
|
synctv_core::models::UserId::expect_positive(11),
|
|
&delete_request,
|
|
))?;
|
|
assert!(matches!(
|
|
delete_error,
|
|
crate::impls::ApiError::InvalidInput(message)
|
|
if message.contains("expected_version")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_reaction_summary_rejects_empty_key() -> TestResult {
|
|
let reaction = synctv_core::models::ChatReactionSummary {
|
|
key: " ".to_string(),
|
|
count: 1,
|
|
reacted_by_me: false,
|
|
};
|
|
|
|
let error = api_err(super::chat_reaction_summary_to_proto(&reaction))?;
|
|
|
|
assert!(matches!(
|
|
error,
|
|
crate::impls::ApiError::Internal(message)
|
|
if message.contains("reaction summary key is empty")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_reaction_summary_rejects_negative_count() -> TestResult {
|
|
let reaction = synctv_core::models::ChatReactionSummary {
|
|
key: "like".to_string(),
|
|
count: -1,
|
|
reacted_by_me: true,
|
|
};
|
|
|
|
let error = api_err(super::chat_reaction_summary_to_proto(&reaction))?;
|
|
|
|
assert!(matches!(
|
|
error,
|
|
crate::impls::ApiError::Internal(message)
|
|
if message.contains("negative count")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn chat_reaction_count_rejects_overflow() -> TestResult {
|
|
let reactions = vec![
|
|
synctv_proto::client::ChatReactionSummary {
|
|
key: "a".to_string(),
|
|
count: i64::MAX,
|
|
reacted_by_me: false,
|
|
},
|
|
synctv_proto::client::ChatReactionSummary {
|
|
key: "b".to_string(),
|
|
count: 1,
|
|
reacted_by_me: false,
|
|
},
|
|
];
|
|
|
|
let error = api_err(super::chat_reaction_count(&reactions))?;
|
|
|
|
assert!(matches!(
|
|
error,
|
|
crate::impls::ApiError::Internal(message)
|
|
if message.contains("reaction count exceeds")
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn get_public_settings_missing_registry_is_service_unavailable() {
|
|
let err = runtime_settings_store_unavailable_error();
|
|
assert!(matches!(err.classify(), ErrorKind::ServiceUnavailable));
|
|
assert_eq!(
|
|
err.message(),
|
|
"Public settings are not available on this server."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_server_time_echoes_client_timestamp_and_sets_server_window() {
|
|
let clock = synctv_core::SyncedClock::system();
|
|
let before = clock.now_nanos();
|
|
let response = super::build_server_time_response(
|
|
synctv_proto::client::GetServerTimeRequest {
|
|
client_sent_at_nanos: 1_700_000_000_123_456_789,
|
|
},
|
|
&clock,
|
|
);
|
|
let after = clock.now_nanos();
|
|
|
|
assert_eq!(response.client_sent_at_nanos, 1_700_000_000_123_456_789);
|
|
assert!(response.server_received_at_nanos >= before);
|
|
assert!(response.server_received_at_nanos <= after);
|
|
assert!(response.server_sent_at_nanos >= response.server_received_at_nanos);
|
|
assert!(response.server_sent_at_nanos <= after);
|
|
}
|
|
|
|
#[test]
|
|
fn room_password_set_validation_rejects_whitespace_only_password() -> TestResult {
|
|
let err = api_err(super::validate_room_password_for_set(" "))?;
|
|
assert!(matches!(err, crate::impls::ApiError::InvalidInput(_)));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn room_password_set_validation_counts_trimmed_password_length() -> TestResult {
|
|
let err = api_err(super::validate_room_password_for_set(" abc "))?;
|
|
assert!(matches!(err, crate::impls::ApiError::InvalidInput(_)));
|
|
api_ok(super::validate_room_password_for_set(" abcd "))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn parse_optional_client_ip_accepts_valid_ip() -> TestResult {
|
|
let parsed = api_ok(super::parse_optional_client_ip(Some("203.0.113.42")))?;
|
|
let expected = "203.0.113.42".parse()?;
|
|
assert_eq!(parsed, Some(expected));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn parse_optional_client_ip_rejects_invalid_ip() -> TestResult {
|
|
let error = api_err(super::parse_optional_client_ip(Some("not-an-ip")))?;
|
|
assert!(matches!(
|
|
error,
|
|
crate::impls::ApiError::InvalidInput(message) if message.contains("Invalid client IP address")
|
|
));
|
|
Ok(())
|
|
}
|
|
}
|