fix: hydrate image chat events and expose room password state (#472)

Fix failures found during the frontend integration sweep:

- Hydrate image attachment views in chat edit/reaction/pin/unpin
responses, pinned-message lists, and durable pin replay. These mutations
could commit successfully and then fail serialization or disconnect
observers.
- Expose room password presence in client/admin projections, using batch
lookups for room lists.
- Preserve nullable category IDs in room-creation review queries so
uncategorized submissions can be read and listed; refresh SQLx metadata.

Validation: Rust formatting, workspace Clippy with warnings denied, five
Docker-backed chat regression tests, eight secondary-read repository
integration tests, eight CLI human-output tests, protobuf freshness, and
a dynamic-Web backend build passed. Remote CI results are tracked on
this PR.

Frontend integration: https://github.com/synctv-org/synctv-app/pull/70
main
zijiren 2 weeks ago committed by GitHub
parent 355b7790b6
commit 7f3e625b4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.id = $1\n ",
"query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id?: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.id = $1\n ",
"describe": {
"columns": [
{
@ -55,7 +55,7 @@
},
{
"ordinal": 5,
"name": "category_id: RoomCategoryId",
"name": "category_id?: RoomCategoryId",
"type_info": "Int8",
"origin": {
"Table": {
@ -223,5 +223,5 @@
true
]
},
"hash": "168ed07152ae8b4b5e056d410f3439d2d93dbcf66f32b7d9d57558d47ec53c77"
"hash": "a8031dfaced5de61f8e815c639941ef8ed89fcf20d616e29257774bae28ba54c"
}

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.status = $1\n AND ($2::bigint IS NULL OR rcr.requested_by = $2)\n AND ($3 = '' OR rcr.name ILIKE $3 ESCAPE '\\' OR rcr.description ILIKE $3 ESCAPE '\\')\n ORDER BY rcr.requested_at DESC, rcr.id DESC\n LIMIT $4 OFFSET $5\n ",
"query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id?: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.status = $1\n AND ($2::bigint IS NULL OR rcr.requested_by = $2)\n AND ($3 = '' OR rcr.name ILIKE $3 ESCAPE '\\' OR rcr.description ILIKE $3 ESCAPE '\\')\n ORDER BY rcr.requested_at DESC, rcr.id DESC\n LIMIT $4 OFFSET $5\n ",
"describe": {
"columns": [
{
@ -55,7 +55,7 @@
},
{
"ordinal": 5,
"name": "category_id: RoomCategoryId",
"name": "category_id?: RoomCategoryId",
"type_info": "Int8",
"origin": {
"Table": {
@ -227,5 +227,5 @@
true
]
},
"hash": "fda96c60016bed7c6b88109c5c7e87169b44d2e3828c10ccb46a01f722e5081a"
"hash": "fe641c4aef2331599d2ed24bd9ffbc138a81822000178721c841fd898ebdba98"
}

@ -438,6 +438,7 @@ pub(in crate::impls::admin) fn try_managed_room_to_proto(
cover: Option<&synctv_core::models::StoredFileReference>,
cover_access: Option<&crate::impls::stored_files::StoredFileObjectAccess>,
presence: Option<&synctv_core::service::OnlineRoomStats>,
password_enabled: bool,
public_id_codec: &synctv_adapter::PublicIdCodec,
) -> Result<synctv_proto::admin::Room, ApiError> {
let room_settings = settings.ok_or_else(|| {
@ -498,6 +499,7 @@ pub(in crate::impls::admin) fn try_managed_room_to_proto(
.map(|label| room_label_to_proto(label, public_id_codec))
.collect::<Result<Vec<_>, _>>()?,
is_public: Some(room.is_public),
password_enabled,
})
}

@ -258,6 +258,10 @@ impl AdminApiImpl {
cover.as_ref().map(|(reference, _)| reference),
cover.as_ref().map(|(_, access)| access),
Some(&presence),
self.room_service
.is_room_password_enabled(&room.id)
.await
.map_err(ApiError::from)?,
&self.public_id_codec,
)
}

@ -116,16 +116,18 @@ impl AdminApiImpl {
let room_ids: Vec<synctv_core::models::RoomId> = rooms.iter().map(|room| room.id).collect();
let room_id_refs: Vec<&synctv_core::models::RoomId> = room_ids.iter().collect();
let (member_counts, room_settings_map, presence_stats, room_assets) = tokio::join!(
let (member_counts, room_settings_map, presence_stats, room_assets, password_room_ids) = tokio::join!(
self.room_service.get_member_count_batch(&room_id_refs),
self.room_service.get_room_settings_batch(&room_ids),
self.presence_service.room_stats_batch(&room_ids),
self.load_admin_room_list_assets(&rooms, &creator_user_map),
self.room_service.password_enabled_room_ids(&room_ids),
);
let member_counts = member_counts.map_err(ApiError::from)?;
let room_settings_map = room_settings_map.map_err(ApiError::from)?;
let presence_stats = presence_stats.map_err(ApiError::from)?;
let (creator_avatar_urls, room_covers) = room_assets?;
let password_room_ids = password_room_ids.map_err(ApiError::from)?;
let presence_by_room: std::collections::HashMap<synctv_core::models::RoomId, _> =
presence_stats
.iter()
@ -150,6 +152,7 @@ impl AdminApiImpl {
cover.map(|(reference, _)| reference),
cover.map(|(_, access)| access),
presence_by_room.get(&r.id).copied(),
password_room_ids.contains(&r.id),
&self.public_id_codec,
)?);
}
@ -1035,16 +1038,18 @@ impl AdminApiImpl {
let room_ids: Vec<synctv_core::models::RoomId> = rooms.iter().map(|room| room.id).collect();
let room_id_refs: Vec<&synctv_core::models::RoomId> = room_ids.iter().collect();
let (member_counts, room_settings_map, presence_stats, room_assets) = tokio::join!(
let (member_counts, room_settings_map, presence_stats, room_assets, password_room_ids) = tokio::join!(
self.room_service.get_member_count_batch(&room_id_refs),
self.room_service.get_room_settings_batch(&room_ids),
self.presence_service.room_stats_batch(&room_ids),
self.load_admin_room_list_assets(&rooms, &creator_user_map),
self.room_service.password_enabled_room_ids(&room_ids),
);
let member_counts = member_counts.map_err(ApiError::from)?;
let room_settings_map = room_settings_map.map_err(ApiError::from)?;
let presence_stats = presence_stats.map_err(ApiError::from)?;
let (creator_avatar_urls, room_covers) = room_assets?;
let password_room_ids = password_room_ids.map_err(ApiError::from)?;
let presence_by_room: std::collections::HashMap<synctv_core::models::RoomId, _> =
presence_stats
.iter()
@ -1069,6 +1074,7 @@ impl AdminApiImpl {
cover.map(|(reference, _)| reference),
cover.map(|(_, access)| access),
presence_by_room.get(&room.id).copied(),
password_room_ids.contains(&room.id),
&self.public_id_codec,
)?);
}

@ -2890,6 +2890,7 @@ pub fn try_room_to_proto_with_availability_and_presence(
.collect::<Result<Vec<_>, _>>()?,
is_public: Some(room.is_public),
creator_blocked: false,
password_enabled: false,
})
}

@ -688,7 +688,7 @@ impl ClientApiImpl {
})
.transpose()?
.flatten();
convert::try_room_to_proto_basic_with_cover(
let mut proto = convert::try_room_to_proto_basic_with_cover(
room,
settings,
member_count,
@ -696,9 +696,16 @@ impl ClientApiImpl {
cover.as_ref(),
cover_access.as_ref(),
&self.public_id_codec,
)
)?;
proto.password_enabled = self
.room_service
.is_room_password_enabled(&room.id)
.await
.map_err(ApiError::from)?;
Ok(proto)
}
#[allow(clippy::too_many_arguments)] // Keep preloaded projection inputs explicit, as in convert.
pub async fn room_to_proto_with_availability_presence_and_loaded_cover(
&self,
room: &synctv_core::models::Room,
@ -707,6 +714,7 @@ impl ClientApiImpl {
availability: synctv_core::service::ClientResourceAvailability,
presence: Option<&synctv_core::service::OnlineRoomStats>,
creator: Option<synctv_proto::client::UserPublicView>,
password_enabled: Option<bool>,
) -> Result<synctv_proto::client::Room, ApiError> {
let creator = async {
match creator {
@ -730,7 +738,7 @@ impl ClientApiImpl {
})
.transpose()?
.flatten();
convert::try_room_to_proto_with_availability_presence_and_cover(
let mut proto = convert::try_room_to_proto_with_availability_presence_and_cover(
room,
settings,
member_count,
@ -740,7 +748,16 @@ impl ClientApiImpl {
cover.as_ref(),
cover_access.as_ref(),
&self.public_id_codec,
)
)?;
proto.password_enabled = match password_enabled {
Some(enabled) => enabled,
None => self
.room_service
.is_room_password_enabled(&room.id)
.await
.map_err(ApiError::from)?,
};
Ok(proto)
}
pub async fn media_to_proto_for_viewer_with_loaded_cover(

@ -243,6 +243,7 @@ impl ClientApiImpl {
availability,
Some(&presence),
None,
None,
)
.await?;
proto_room.creator_blocked = creator_blocked;
@ -425,6 +426,7 @@ impl ClientApiImpl {
availability,
presence_by_room.get(&room.id).copied(),
Some(creator),
Some(password_room_ids.contains(&room.id)),
)
.await?;
Ok::<_, ApiError>(DiscoveryRoomProjection {
@ -794,6 +796,7 @@ impl ClientApiImpl {
creator_views,
favorite_room_ids,
blocked_creator_ids,
password_room_ids,
) = tokio::try_join!(
async {
self.room_service
@ -825,6 +828,12 @@ impl ClientApiImpl {
.map(|ids| ids.into_iter().collect::<HashSet<_>>())
.map_err(ApiError::from)
},
async {
self.room_service
.password_enabled_room_ids_eventually_consistent(&room_ids)
.await
.map_err(ApiError::from)
},
)?;
let presence_by_room: HashMap<synctv_core::models::RoomId, _> = presence_stats
.iter()
@ -846,6 +855,7 @@ impl ClientApiImpl {
let availability_map = &availability_map;
let favorite_room_ids = &favorite_room_ids;
let blocked_creator_ids = &blocked_creator_ids;
let password_room_ids = &password_room_ids;
async move {
let settings = required_room_settings(room_settings_map, &room.id)?;
let availability = required_room_availability(availability_map, &room.id)?;
@ -867,6 +877,7 @@ impl ClientApiImpl {
availability,
presence_by_room.get(&room.id).copied(),
Some(creator),
Some(password_room_ids.contains(&room.id)),
)
.await?;
proto_room.creator_blocked = blocked_creator_ids.contains(&room.created_by);
@ -1167,6 +1178,7 @@ impl ClientApiImpl {
availability,
Some(&presence),
None,
None,
)
.await?;
proto_room.creator_blocked = creator_blocked;

@ -2810,6 +2810,17 @@ impl ResourceObserver {
));
};
event.sequence = logged.sequence;
let chat_service = self
.chat_service
.as_ref()
.ok_or_else(|| "Chat service is unavailable".to_string())?;
chat_service
.attach_pin_event_view_metadata_for_authorized_viewer(
&mut event,
self.actor.user_id().as_ref(),
)
.await
.map_err(|error| error.to_string())?;
if !Self::apply_event_cursor_to_observation(&mut observation, &cursor) {
continue;
}

@ -540,7 +540,7 @@ impl ReviewRepository {
COALESCE(u.username, '') AS "requested_by_username!",
rcr.name AS "name!",
rcr.description AS "description!",
rc.id AS "category_id: RoomCategoryId",
rc.id AS "category_id?: RoomCategoryId",
rc.key AS "category_key?",
rc.name AS "category_name?",
rc.description AS "category_description?",
@ -607,7 +607,7 @@ impl ReviewRepository {
COALESCE(u.username, '') AS "requested_by_username!",
rcr.name AS "name!",
rcr.description AS "description!",
rc.id AS "category_id: RoomCategoryId",
rc.id AS "category_id?: RoomCategoryId",
rc.key AS "category_key?",
rc.name AS "category_name?",
rc.description AS "category_description?",

@ -902,6 +902,24 @@ impl ChatService {
pub async fn edit_message_outcome(
&self,
request: EditChatMessage,
) -> Result<ChatMessageEventOutcome> {
let viewer_user_id = request.user_id;
let mut outcome = self.edit_message_outcome_stored(request).await?;
self.attach_event_attachment_view_metadata(&mut outcome.event, Some(&viewer_user_id))
.await?;
if let Some(pin_event) = &mut outcome.pin_event {
self.attach_attachment_view_metadata(
std::slice::from_mut(&mut pin_event.message),
Some(&viewer_user_id),
)
.await?;
}
Ok(outcome)
}
async fn edit_message_outcome_stored(
&self,
request: EditChatMessage,
) -> Result<ChatMessageEventOutcome> {
validate_client_operation_id(request.client_operation_id.as_deref())?;
validate_chat_metadata(request.metadata.as_ref())?;
@ -1574,8 +1592,7 @@ impl ChatService {
)
.await?;
self.chat_repository
.list_pinned_messages_for_viewer(room_id, limit.clamp(1, 100), Some(user_id))
self.list_pinned_messages_for_authorized_viewer(room_id, Some(user_id), limit)
.await
}
@ -1585,14 +1602,37 @@ impl ChatService {
viewer_user_id: Option<&UserId>,
limit: i32,
) -> Result<Vec<ChatPinnedMessage>> {
self.chat_repository
let mut pinned = self
.chat_repository
.list_pinned_messages_for_viewer(room_id, limit.clamp(1, 100), viewer_user_id)
.await
.await?;
for entry in &mut pinned {
self.attach_attachment_view_metadata(
std::slice::from_mut(&mut entry.message),
viewer_user_id,
)
.await?;
}
Ok(pinned)
}
pub async fn pin_message_event_outcome(
&self,
request: PinChatMessage,
) -> Result<ChatPinEventOutcome> {
let viewer_user_id = request.user_id;
let mut outcome = self.pin_message_event_outcome_stored(request).await?;
self.attach_attachment_view_metadata(
std::slice::from_mut(&mut outcome.event.message),
Some(&viewer_user_id),
)
.await?;
Ok(outcome)
}
async fn pin_message_event_outcome_stored(
&self,
request: PinChatMessage,
) -> Result<ChatPinEventOutcome> {
validate_client_operation_id(request.client_operation_id.as_deref())?;
if let Some(note) = request.note.as_deref() {
@ -1670,6 +1710,20 @@ impl ChatService {
pub async fn unpin_message_event_outcome(
&self,
request: UnpinChatMessage,
) -> Result<ChatPinEventOutcome> {
let viewer_user_id = request.user_id;
let mut outcome = self.unpin_message_event_outcome_stored(request).await?;
self.attach_attachment_view_metadata(
std::slice::from_mut(&mut outcome.event.message),
Some(&viewer_user_id),
)
.await?;
Ok(outcome)
}
async fn unpin_message_event_outcome_stored(
&self,
request: UnpinChatMessage,
) -> Result<ChatPinEventOutcome> {
validate_client_operation_id(request.client_operation_id.as_deref())?;
self.permission_service
@ -1745,7 +1799,7 @@ impl ChatService {
.await?;
validate_chat_reaction_key(&request.reaction_key)?;
let inserted = self
let mut inserted = self
.chat_repository
.set_reaction_with_event(&request, &synctv_common::snanoid!(16), self.clock.now())
.await?;
@ -1756,6 +1810,19 @@ impl ChatService {
)
.await;
self.attach_event_attachment_view_metadata(
&mut inserted.event.event,
Some(&request.user_id),
)
.await?;
if let Some(pin_event) = &mut inserted.pin_event {
self.attach_attachment_view_metadata(
std::slice::from_mut(&mut pin_event.event.message),
Some(&request.user_id),
)
.await?;
}
info!(
room_id = %request.room_id,
user_id = %request.user_id,
@ -2159,6 +2226,19 @@ impl ChatService {
Ok(())
}
/// Resolves attachment views for an already-authorized pin event, including durable replay.
pub async fn attach_pin_event_view_metadata_for_authorized_viewer(
&self,
event: &mut ChatPinEvent,
viewer_user_id: Option<&UserId>,
) -> Result<()> {
self.attach_attachment_view_metadata(
std::slice::from_mut(&mut event.message),
viewer_user_id,
)
.await
}
async fn attach_event_attachment_view_metadata(
&self,
event: &mut ChatMessageEvent,

@ -186,6 +186,14 @@ struct PrefixingFileStorageService;
#[async_trait::async_trait]
impl FileStorageService for PrefixingFileStorageService {
fn public_object_url(
&self,
_storage_backend: &str,
object_key: &str,
) -> Result<Option<String>> {
Ok(Some(format!("https://cdn.invalid/{object_key}")))
}
fn backend_name(&self) -> &'static str {
"test-storage"
}
@ -3069,7 +3077,11 @@ async fn concurrent_same_delete_returns_existing_delete_event() {
async fn chat_reactions_update_history_and_emit_reaction_events() {
let (_postgres, pool) = synctv_core_testing::create_test_pool().await;
let username_cache = UsernameCache::local_only("test:chat:reactions:".to_string(), 100, 60);
let service = test_chat_service(&pool, username_cache.clone());
let service = test_chat_service_with_file_storage(
&pool,
username_cache.clone(),
Arc::new(PrefixingFileStorageService),
);
let user_repository = Arc::new(UserRepository::new(pool.clone()));
let owner = ok(
user_repository
@ -3124,6 +3136,19 @@ async fn chat_reactions_update_history_and_emit_reaction_events() {
room_service.join_room(room.id, member.id, None).await,
"member should join room",
);
ok(
FileStorageRepository::new(pool.clone())
.upsert_object(UpsertFileObject {
storage_backend: "test-storage",
object_key: "submitted/reaction-image",
mime_type: "image/webp",
size_bytes: 128,
content_manifest_sha256: &hex::encode(Sha256::digest(b"reaction-image")),
metadata: &crate::models::FileMetadata::default(),
})
.await,
"reaction attachment should be registered",
);
let message = ok(
service
.send_message_event(SendChatMessage {
@ -3134,13 +3159,76 @@ async fn chat_reactions_update_history_and_emit_reaction_events() {
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
attachments: vec![SubmittedFileReference {
id: "reaction-image".to_string(),
kind: crate::models::SubmittedFileReferenceKind::Upload,
}],
mentions: Vec::new(),
})
.await,
"message should be stored",
);
ok(
sqlx::query("UPDATE chat_message_attachments SET url = NULL WHERE message_id = $1")
.bind(message.message.message.id)
.execute(&pool)
.await,
"stored attachment should require URL resolution",
);
let pin_request = PinChatMessage {
room_id: room.id,
message_id: message.message.message.id,
user_id: owner.id,
client_operation_id: Some("image-pin".to_string()),
note: None,
};
for expected_inserted in [true, false] {
let outcome = ok(
service.pin_message_event_outcome(pin_request.clone()).await,
"image pin and replay should succeed",
);
assert_eq!(outcome.inserted, expected_inserted);
assert_reaction_image_url(&outcome.event.message);
}
let pins = ok(
service.list_pinned_messages(&room.id, &owner.id, 10).await,
"image pins should load",
);
assert_reaction_image_url(&pins[0].message);
let logged_pins = ok(
RoomResourceEventRepository::new(pool.clone())
.list_room_events_after_sequence_for_resource_types(
&room.id,
&[crate::repository::RoomResourceKind::ChatPins],
0,
10,
)
.await,
"image pin should have a durable event",
);
let mut replayed_pin = some(
logged_pins
.into_iter()
.find_map(|logged| match logged.payload {
Some(crate::repository::RoomResourceEventPayload::ChatPin { event }) => Some(event),
_ => None,
}),
"image pin event should be replayable",
);
assert!(replayed_pin.message.attachments[0].url.is_none());
ok(
service
.attach_pin_event_view_metadata_for_authorized_viewer(
&mut replayed_pin,
Some(&owner.id),
)
.await,
"durable image pin should resolve attachment metadata",
);
assert_reaction_image_url(&replayed_pin.message);
let owner_reaction = ok(
service
.set_reaction_event_outcome(SetChatReaction {
@ -3153,6 +3241,14 @@ async fn chat_reactions_update_history_and_emit_reaction_events() {
.await,
"owner reaction should be stored",
);
assert_reaction_image_url(&owner_reaction.event.message);
assert_reaction_image_url(
&some(
owner_reaction.pin_event.as_ref(),
"reaction should update the pin",
)
.message,
);
assert_eq!(owner_reaction.event.kind, ChatEventKind::ReactionsChanged);
assert_eq!(owner_reaction.event.message.reactions.len(), 1);
assert_eq!(owner_reaction.event.message.reactions[0].key, "like");
@ -3311,6 +3407,45 @@ async fn chat_reactions_update_history_and_emit_reaction_events() {
assert_eq!(next.users.len(), 1);
assert_ne!(page.users[0].user_id, next.users[0].user_id);
let edit_request = EditChatMessage {
room_id: room.id,
message_id: message.message.message.id,
user_id: owner.id,
client_operation_id: Some("image-edit".to_string()),
content: "edited image caption".to_string(),
metadata: None,
expected_version: None,
};
for expected_inserted in [true, false] {
let outcome = ok(
service.edit_message_outcome(edit_request.clone()).await,
"image edit and replay should succeed",
);
assert_eq!(outcome.inserted, expected_inserted);
assert_reaction_image_url(&outcome.event.message);
if expected_inserted {
assert_reaction_image_url(
&some(outcome.pin_event.as_ref(), "edit should update the pin").message,
);
}
}
let unpin_request = UnpinChatMessage {
room_id: room.id,
message_id: message.message.message.id,
user_id: owner.id,
client_operation_id: Some("image-unpin".to_string()),
};
for expected_inserted in [true, false] {
let outcome = ok(
service
.unpin_message_event_outcome(unpin_request.clone())
.await,
"image unpin and replay should succeed",
);
assert_eq!(outcome.inserted, expected_inserted);
assert_reaction_image_url(&outcome.event.message);
}
ok(
service.user_service.block_user(&member.id, &owner.id).await,
"member should block message owner",
@ -3332,6 +3467,14 @@ async fn chat_reactions_update_history_and_emit_reaction_events() {
));
}
fn assert_reaction_image_url(message: &ChatMessageWithAttachments) {
assert_eq!(message.attachments.len(), 1);
assert_eq!(
message.attachments[0].url.as_deref(),
Some("https://cdn.invalid/submitted/reaction-image")
);
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn pinned_chat_messages_list_and_emit_state_events() {

@ -11,8 +11,9 @@ use synctv_core::{
repository::{
AuditLogQuery, AuditLogRepository, BanRecordListQuery, BanRecordRepository, ChatRepository,
ContentReportListQuery, ContentReportListScope, ContentReportRepository, ReviewRepository,
RoomMemberRepository, RoomPasswordRepository, RoomRepository, RoomSettingsRepository,
RoomTaxonomyRepository, UserRegistrationReviewListQuery, UserRepository,
RoomCreationReviewListQuery, RoomMemberRepository, RoomPasswordRepository, RoomRepository,
RoomSettingsRepository, RoomTaxonomyRepository, UserRegistrationReviewListQuery,
UserRepository,
},
};
use synctv_core_testing::{create_test_pool_with_db_and_label, ok, some};
@ -725,3 +726,47 @@ async fn audit_log_list_reads_from_read_pool_while_detail_uses_primary() {
Some("primary_audit_actor")
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn room_creation_review_lists_and_loads_uncategorized_requests() {
let (_container, pool) =
create_test_pool_with_db_and_label("synctv_test", "uncategorized-creation-review").await;
let owner = create_user(&pool, "uncategorized_review_owner").await;
let request_id: i64 = ok(
sqlx::query_scalar(
"INSERT INTO room_creation_requests (requested_by, name, status) VALUES ($1, $2, $3) RETURNING id",
)
.bind(owner.id.as_i64())
.bind("Uncategorized review")
.bind(i16::from(ReviewStatus::Pending))
.fetch_one(&pool)
.await,
"uncategorized request should be inserted",
);
let repo = ReviewRepository::new(pool);
let page = ok(
repo.list_room_creations(&RoomCreationReviewListQuery {
status: ReviewStatus::Pending,
requested_by: Some(owner.id),
search: None,
limit: 10,
offset: 0,
})
.await,
"review list should decode an absent category",
);
assert_eq!(page.total, 1);
assert_eq!(page.rows[0].name, "Uncategorized review");
assert!(page.rows[0].category.is_none());
let request_id = ok(RoomId::try_from(request_id), "request id should be valid");
let detail = some(
ok(
repo.load_room_creation(request_id).await,
"detail should load",
),
"request should exist",
);
assert_eq!(detail.name, page.rows[0].name);
assert!(detail.category.is_none());
}

@ -1178,6 +1178,7 @@ pub(crate) fn created_room_to_client_proto(
settings: &synctv_core::models::RoomSettings,
member_count: i32,
creator: &synctv_core::models::User,
password_enabled: bool,
public_id_codec: &synctv_adapter::PublicIdCodec,
) -> Result<client_proto::Room, Status> {
Ok(client_proto::Room {
@ -1197,6 +1198,7 @@ pub(crate) fn created_room_to_client_proto(
presence: None,
creator: Some(user_public_view_to_client_proto(creator, public_id_codec)?),
creator_blocked: false,
password_enabled,
category: room
.category
.as_ref()

@ -758,6 +758,10 @@ impl ManagementServiceImpl {
&settings,
member_count,
&creator,
self.room_service
.is_room_password_enabled(&room.id)
.await
.map_err(map_core_error)?,
&self.public_id_codec,
)
}
@ -776,6 +780,10 @@ impl ManagementServiceImpl {
&settings,
member_count,
&creator,
self.room_service
.is_room_password_enabled(&room.id)
.await
.map_err(map_core_error)?,
&self.public_id_codec,
)
}
@ -2430,6 +2438,10 @@ impl ManagementService for ManagementServiceImpl {
&response_settings,
member_count,
&creator,
self.room_service
.is_room_password_enabled(&room.id)
.await
.map_err(map_core_error)?,
&self.public_id_codec,
)?;
Ok(Response::new(response))

@ -174,6 +174,8 @@ message Room {
synctv.client.RoomCategory category = 17;
repeated synctv.client.RoomLabel labels = 18;
optional bool is_public = 19;
// Current OPAQUE credential state, independent of room policy settings.
bool password_enabled = 20;
}
message RuntimeSettings {

@ -625,6 +625,8 @@ message Room {
repeated RoomLabel labels = 17;
optional bool is_public = 18;
bool creator_blocked = 19;
// Current OPAQUE credential state, independent of room policy settings.
bool password_enabled = 20;
}
message RoomCategory {

@ -5869,6 +5869,7 @@ fn render_human_output_uses_room_and_member_enums_by_context() {
labels: Vec::new(),
is_public: Some(true),
creator_blocked: false,
password_enabled: false,
}),
playback_state: None,
requires_approval: false,
@ -5970,6 +5971,7 @@ fn render_human_output_converts_room_listing_without_context_inference() {
category: None,
labels: Vec::new(),
is_public: Some(false),
password_enabled: true,
}],
total: 1,
})

Loading…
Cancel
Save