From 5f569b4bb7a2f5bd2efbfc64ed59db79fa9e1397 Mon Sep 17 00:00:00 2001 From: Marcel Pfeiffer Date: Tue, 23 Dec 2025 16:25:46 +0100 Subject: [PATCH 1/3] Add user read chat functionality --- src/broadcast/notification.rs | 8 +++++- src/rooms/handler.rs | 10 ++++++++ src/rooms/room_service.rs | 46 ++++++++++++++++++++++++++++++++++- src/rooms/routes.rs | 3 ++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/broadcast/notification.rs b/src/broadcast/notification.rs index 6043732..f5663fe 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -50,7 +50,13 @@ pub enum NotificationEvent { * Sending this event to all users in a room where a member has left */ #[serde(rename_all = "camelCase")] - RoomChangeEvent {message: MessageDTO, room_preview_text: LastMessagePreviewText} + RoomChangeEvent {message: MessageDTO, room_preview_text: LastMessagePreviewText}, + + /** + * Sending this event to all users in a room when a user has read the latest message + */ + #[serde(rename_all = "camelCase")] + UserReadChat {user_id: Uuid, room_id: Uuid} } diff --git a/src/rooms/handler.rs b/src/rooms/handler.rs index fbab962..d22a8ed 100644 --- a/src/rooms/handler.rs +++ b/src/rooms/handler.rs @@ -200,4 +200,14 @@ pub async fn handle_save_room_image( } else { Err(AppError::ValidationError("Required field 'image' not found in the upload.".to_string())) } +} + +pub async fn handle_get_read_states( + Extension(token): Extension>, + State(state): State>, + Path(room_id): Path +) -> Result>, AppError> { + check_user_in_room(&state, &token.subject, &room_id).await?; + let read_states = RoomService::get_read_states(state, room_id).await?; + Ok(Json(read_states)) } \ No newline at end of file diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index eb98a3e..447fa36 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -4,7 +4,7 @@ use chrono::Utc; use log::{error}; use uuid::Uuid; use crate::broadcast::{BroadcastChannel, Notification}; -use crate::broadcast::NotificationEvent::{LeaveRoom, RoomChangeEvent}; +use crate::broadcast::NotificationEvent::{LeaveRoom, RoomChangeEvent, UserReadChat}; use crate::core::AppState; use crate::errors::{AppError}; use crate::messaging::model::{Message, MessageBody, RoomChangeBody}; @@ -44,9 +44,53 @@ impl RoomService { pub async fn mark_room_as_read(state: Arc, client_id: Uuid, room_id: Uuid) -> Result<(), AppError> { let pl = state.room_repository.get_connection(); state.room_repository.update_user_read_status(pl, &room_id, &client_id).await?; + + let room = state.room_repository.select_room(&room_id).await?; + if let Some(latest_msg_time) = room.latest_message { + let user = state.room_repository.select_joined_user_by_id(&room_id, &client_id).await?; + if let Some(read_time) = user.last_message_read_at { + if read_time >= latest_msg_time { + let users_in_room = state.room_repository.select_room_participants_ids(&room_id).await?; + BroadcastChannel::get().send_event_to_all( + users_in_room, + Notification { + body: UserReadChat { user_id: client_id, room_id }, + created_at: Utc::now() + } + ).await; + } + } + } else { + let users_in_room = state.room_repository.select_room_participants_ids(&room_id).await?; + BroadcastChannel::get().send_event_to_all( + users_in_room, + Notification { + body: UserReadChat { user_id: client_id, room_id }, + created_at: Utc::now() + } + ).await; + } + Ok(()) } + pub async fn get_read_states(state: Arc, room_id: Uuid) -> Result, AppError> { + let users = state.room_repository.select_joined_user_in_room(&room_id).await?; + let room = state.room_repository.select_room(&room_id).await?; + let read_users: Vec = users.into_iter().filter(|user| { + if let Some(latest_msg_time) = room.latest_message { + if let Some(read_time) = user.last_message_read_at { + read_time >= latest_msg_time + } else { + false + } + } else { + true + } + }).collect(); + Ok(read_users) + } + pub async fn create_room(state: Arc, client_id: Uuid, new_room: NewRoom) -> Result { let room_entity = state.room_repository.insert_room(new_room.clone()).await?; let users = new_room.invited_users; diff --git a/src/rooms/routes.rs b/src/rooms/routes.rs index c31b572..1f16367 100644 --- a/src/rooms/routes.rs +++ b/src/rooms/routes.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::Router; use axum::routing::{get, post}; use crate::core::AppState; -use crate::rooms::handler::{handle_create_room, handle_get_joined_rooms, handle_get_room_list_item_by_id, handle_get_room_with_details, handle_get_users_in_room, handle_invite_to_room, handle_leave_room, handle_save_room_image, handle_scroll_chat_timeline, handle_search_existing_single_room, mark_room_as_read}; +use crate::rooms::handler::{handle_create_room, handle_get_joined_rooms, handle_get_room_list_item_by_id, handle_get_room_with_details, handle_get_users_in_room, handle_invite_to_room, handle_leave_room, handle_save_room_image, handle_scroll_chat_timeline, handle_search_existing_single_room, mark_room_as_read, handle_get_read_states}; pub fn create_room_routes() -> Router> { @@ -18,4 +18,5 @@ pub fn create_room_routes() -> Router> { .route("/api/rooms/{room_id}/invite/{user_id}", post(handle_invite_to_room)) .route("/api/rooms/{room_id}/upload-img", post(handle_save_room_image)) .route("/api/rooms", get(handle_get_joined_rooms)) + .route("/api/rooms/{room_id}/read-states", get(handle_get_read_states)) } From 83271efb4ae99eb4ff10fa5dcef4d20fd18719d1 Mon Sep 17 00:00:00 2001 From: Marcel Pfeiffer Date: Sun, 28 Dec 2025 11:24:19 +0100 Subject: [PATCH 2/3] Add some tests --- src/broadcast/event_broadcast.rs | 41 ++++++++++++++++++ src/rooms/room_service.rs | 72 ++++++++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index 960d61a..e1716f4 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -35,6 +35,47 @@ pub struct BroadcastChannel { push_notification_producer: PushNotificationProducer } +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::redis_cache::NoOpCache; + use crate::kafka::PushNotificationProducer; + use crate::core::KafkaConfig; + use crate::broadcast::Notification; + use crate::broadcast::NotificationEvent::UserReadChat; + use serde_json; + use std::sync::Arc; + + #[tokio::test] + async fn send_event_to_subscribed_user_delivers_notification() { + // initialize broadcast channel singleton with NoOpCache and logger producer + let cache: Arc = Arc::new(NoOpCache); + let kafka_cfg = KafkaConfig { bootstrap_host: String::from(""), bootstrap_port: 0, topic: String::from(""), client_id: String::from(""), partition: vec![], consumer_group: String::from("") }; + BroadcastChannel::init(cache, PushNotificationProducer::new(false, kafka_cfg)).await; + + let bc = BroadcastChannel::get(); + + let user_id = uuid::Uuid::new_v4(); + // subscribe + let mut rx = bc.subscribe_to_user_events(user_id).await; + + let notification = Notification { + body: UserReadChat { user_id, room_id: uuid::Uuid::new_v4() }, + created_at: chrono::Utc::now() + }; + + // send to all (only this user) + bc.send_event_to_all(vec![user_id], notification.clone()).await; + + // receive + let received = rx.recv().await.expect("Should receive notification"); + + let sent_json = serde_json::to_string(¬ification).expect("serialize sent"); + let recv_json = serde_json::to_string(&received).expect("serialize recv"); + assert_eq!(sent_json, recv_json); + } +} + type UserConnectionMap = RwLock>>; diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index 447fa36..cacc4cb 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -78,15 +78,7 @@ impl RoomService { let users = state.room_repository.select_joined_user_in_room(&room_id).await?; let room = state.room_repository.select_room(&room_id).await?; let read_users: Vec = users.into_iter().filter(|user| { - if let Some(latest_msg_time) = room.latest_message { - if let Some(read_time) = user.last_message_read_at { - read_time >= latest_msg_time - } else { - false - } - } else { - true - } + user_has_read(user, room.latest_message) }).collect(); Ok(read_users) } @@ -248,6 +240,68 @@ impl RoomService { } +// Helper used by `get_read_states` — extracted for easier unit testing of the read logic. +fn user_has_read(user: &RoomMember, room_latest: Option>) -> bool { + if let Some(latest_msg_time) = room_latest { + if let Some(read_time) = user.last_message_read_at { + read_time >= latest_msg_time + } else { + false + } + } else { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Utc, Duration}; + use uuid::Uuid; + use crate::model::room_member::{RoomMember, MembershipStatus}; + + fn make_member(read_at: Option>) -> RoomMember { + RoomMember { + id: Uuid::new_v4(), + display_name: "test".to_string(), + profile_picture: None, + joined_at: Utc::now(), + last_message_read_at: read_at, + membership_status: MembershipStatus::Joined + } + } + + #[test] + fn user_has_read_when_no_latest_message() { + let user = make_member(None); + let result = user_has_read(&user, None); + assert!(result, "When room has no latest message, every user should be considered read"); + } + + #[test] + fn user_has_read_when_read_time_ge_latest() { + let latest = Utc::now(); + let read_time = latest + Duration::seconds(1); + let user = make_member(Some(read_time)); + assert!(user_has_read(&user, Some(latest))); + } + + #[test] + fn user_has_not_read_when_read_time_before_latest() { + let latest = Utc::now(); + let read_time = latest - Duration::seconds(10); + let user = make_member(Some(read_time)); + assert!(!user_has_read(&user, Some(latest))); + } + + #[test] + fn user_has_not_read_when_no_read_time_and_latest_present() { + let latest = Utc::now(); + let user = make_member(None); + assert!(!user_has_read(&user, Some(latest))); + } +} + async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Result<(), AppError> { let mut tx = state.room_repository.start_transaction().await?; state.room_repository.delete_room(&mut *tx, &room.id).await?; From 2666eb13fcf731f06be399f6607a7b42ec9b274e Mon Sep 17 00:00:00 2001 From: timvosskuehler Date: Fri, 16 Jan 2026 23:54:55 +0100 Subject: [PATCH 3/3] reorganize tests, and streamline utility functions - Bumps versions for `axum`, `tracing`, `tracing-subscriber`, `redis`, and other dependencies to include the latest updates and fixes. - Restores and reorganizes previously removed test modules to maintain code coverage. - Refactors `user_has_read` utility with concise logic for better readability. - Fixes route reordering for `mark-read` API to improve definition clarity. --- Cargo.lock | 43 +++++++----- Cargo.toml | 10 +-- src/broadcast/event_broadcast.rs | 85 ++++++++++++------------ src/keycloak/action.rs | 1 + src/main.rs | 2 +- src/rooms/room_service.rs | 110 +++++++++++++++---------------- src/rooms/routes.rs | 2 +- 7 files changed, 134 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbfd5b6..f082939 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,9 +251,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "bytes", @@ -3184,9 +3184,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.0.0-rc.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e3c1983f96fe1aa42d3e75d6eedc0374ba45f784fb86f130e2c8dac95817471" +checksum = "5dfe20977fe93830c0e9817a16fbf1ed1cfd8d4bba366087a1841d2c6033c251" dependencies = [ "arc-swap", "arcstr", @@ -3206,6 +3206,7 @@ dependencies = [ "tokio", "tokio-util", "url", + "xxhash-rust", ] [[package]] @@ -3642,15 +3643,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -4594,9 +4595,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -4606,9 +4607,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -4617,9 +4618,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -4638,9 +4639,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -5388,6 +5389,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -5537,6 +5544,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 725d3e8..6ef3ed9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] log = "0.4.28" -axum = { version = "0.8.6", features = ["multipart"] } +axum = { version = "0.8.8", features = ["multipart"] } tokio = {version = "1.48.0", features = ["full"]} tower = "0.5.2" config = "0.15.18" @@ -15,11 +15,11 @@ futures = "0.3.31" uuid = { version = "1.18.1", features = ["v4", "serde", "v7"] } chrono = { version = "0.4.42", features = ["serde"] } tower-http = { version = "0.6.6", features = ["cors", "trace"] } -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } sqlx = {version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "uuid", "macros"]} dotenv = "0.15.0" -serde_json = "1.0.145" +serde_json = "1.0.149" tokio-stream = { version = "0.1.17", features = ["sync"] } rdkafka = { version = "0.38.0", features = ["cmake-build", "tokio"] } minio = { version = "0.3.0", features = ["default"] } @@ -27,7 +27,7 @@ image = { version = "0.25.8"} bytes = "1.10.1" base64 = "0.22.1" validator = { version = "0.20.0", features = ["derive"] } -redis = { version = "1.0.0-rc.3", features = ["tokio-comp", "connection-manager"] } +redis = { version = "1.0.2", features = ["tokio-comp", "connection-manager"] } #keycloak: diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index e1716f4..0efae62 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -35,46 +35,6 @@ pub struct BroadcastChannel { push_notification_producer: PushNotificationProducer } -#[cfg(test)] -mod tests { - use super::*; - use crate::cache::redis_cache::NoOpCache; - use crate::kafka::PushNotificationProducer; - use crate::core::KafkaConfig; - use crate::broadcast::Notification; - use crate::broadcast::NotificationEvent::UserReadChat; - use serde_json; - use std::sync::Arc; - - #[tokio::test] - async fn send_event_to_subscribed_user_delivers_notification() { - // initialize broadcast channel singleton with NoOpCache and logger producer - let cache: Arc = Arc::new(NoOpCache); - let kafka_cfg = KafkaConfig { bootstrap_host: String::from(""), bootstrap_port: 0, topic: String::from(""), client_id: String::from(""), partition: vec![], consumer_group: String::from("") }; - BroadcastChannel::init(cache, PushNotificationProducer::new(false, kafka_cfg)).await; - - let bc = BroadcastChannel::get(); - - let user_id = uuid::Uuid::new_v4(); - // subscribe - let mut rx = bc.subscribe_to_user_events(user_id).await; - - let notification = Notification { - body: UserReadChat { user_id, room_id: uuid::Uuid::new_v4() }, - created_at: chrono::Utc::now() - }; - - // send to all (only this user) - bc.send_event_to_all(vec![user_id], notification.clone()).await; - - // receive - let received = rx.recv().await.expect("Should receive notification"); - - let sent_json = serde_json::to_string(¬ification).expect("serialize sent"); - let recv_json = serde_json::to_string(&received).expect("serialize recv"); - assert_eq!(sent_json, recv_json); - } -} type UserConnectionMap = RwLock>>; @@ -185,5 +145,48 @@ impl BroadcastChannel { } } - } + + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::redis_cache::NoOpCache; + use crate::kafka::PushNotificationProducer; + use crate::core::KafkaConfig; + use crate::broadcast::Notification; + use crate::broadcast::NotificationEvent::UserReadChat; + use serde_json; + use std::sync::Arc; + + #[tokio::test] + async fn send_event_to_subscribed_user_delivers_notification() { + // initialize broadcast channel singleton with NoOpCache and logger producer + let cache: Arc = Arc::new(NoOpCache); + let kafka_cfg = KafkaConfig { bootstrap_host: String::from(""), bootstrap_port: 0, topic: String::from(""), client_id: String::from(""), partition: vec![], consumer_group: String::from("") }; + BroadcastChannel::init(cache, PushNotificationProducer::new(false, kafka_cfg)).await; + + let bc = BroadcastChannel::get(); + + let user_id = uuid::Uuid::new_v4(); + // subscribe + let mut rx = bc.subscribe_to_user_events(user_id).await; + + let notification = Notification { + body: UserReadChat { user_id, room_id: uuid::Uuid::new_v4() }, + created_at: chrono::Utc::now() + }; + + // send to all (only this user) + bc.send_event_to_all(vec![user_id], notification.clone()).await; + + // receive + let received = rx.recv().await.expect("Should receive notification"); + + let sent_json = serde_json::to_string(¬ification).expect("serialize sent"); + let recv_json = serde_json::to_string(&received).expect("serialize recv"); + println!("Sent: {}", sent_json); + println!("Received: {}", recv_json); + assert_eq!(sent_json, recv_json); + } +} \ No newline at end of file diff --git a/src/keycloak/action.rs b/src/keycloak/action.rs index 602e0b6..9f6de26 100644 --- a/src/keycloak/action.rs +++ b/src/keycloak/action.rs @@ -144,6 +144,7 @@ impl Action { } #[cfg(test)] +#[allow(unused)] mod test { use assertr::prelude::*; diff --git a/src/main.rs b/src/main.rs index 5d67592..7037836 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use ism::welcome::welcome; //learn to code rust axum here: //https://gitlab.com/famedly/conduit/-/tree/next?ref_type=heads //https://github.com/AarambhDevHub/rust-backend-axum -//https://github.com/rust-lang/crates.io/ +//https://github.com/rust-lang/crates.io/ <---- THE BEST! #[tokio::main(flavor = "multi_thread")] async fn main() { diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index cacc4cb..ad117f1 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -242,65 +242,13 @@ impl RoomService { // Helper used by `get_read_states` — extracted for easier unit testing of the read logic. fn user_has_read(user: &RoomMember, room_latest: Option>) -> bool { - if let Some(latest_msg_time) = room_latest { - if let Some(read_time) = user.last_message_read_at { - read_time >= latest_msg_time - } else { - false - } - } else { - true + match (room_latest, user.last_message_read_at) { + (Some(latest_msg_time), Some(read_time)) => read_time >= latest_msg_time, + (Some(_), None) => false, + (None, _) => true, } } -#[cfg(test)] -mod tests { - use super::*; - use chrono::{Utc, Duration}; - use uuid::Uuid; - use crate::model::room_member::{RoomMember, MembershipStatus}; - - fn make_member(read_at: Option>) -> RoomMember { - RoomMember { - id: Uuid::new_v4(), - display_name: "test".to_string(), - profile_picture: None, - joined_at: Utc::now(), - last_message_read_at: read_at, - membership_status: MembershipStatus::Joined - } - } - - #[test] - fn user_has_read_when_no_latest_message() { - let user = make_member(None); - let result = user_has_read(&user, None); - assert!(result, "When room has no latest message, every user should be considered read"); - } - - #[test] - fn user_has_read_when_read_time_ge_latest() { - let latest = Utc::now(); - let read_time = latest + Duration::seconds(1); - let user = make_member(Some(read_time)); - assert!(user_has_read(&user, Some(latest))); - } - - #[test] - fn user_has_not_read_when_read_time_before_latest() { - let latest = Utc::now(); - let read_time = latest - Duration::seconds(10); - let user = make_member(Some(read_time)); - assert!(!user_has_read(&user, Some(latest))); - } - - #[test] - fn user_has_not_read_when_no_read_time_and_latest_present() { - let latest = Utc::now(); - let user = make_member(None); - assert!(!user_has_read(&user, Some(latest))); - } -} async fn handle_leave_private_room(state: Arc, room: ChatRoomEntity, users: Vec) -> Result<(), AppError> { let mut tx = state.room_repository.start_transaction().await?; @@ -392,4 +340,54 @@ async fn save_room_change_message_and_broadcast(message: Message, state: &Arc>) -> RoomMember { + RoomMember { + id: Uuid::new_v4(), + display_name: "test".to_string(), + profile_picture: None, + joined_at: Utc::now(), + last_message_read_at: read_at, + membership_status: MembershipStatus::Joined + } + } + + #[test] + fn user_has_read_when_no_latest_message() { + let user = make_member(None); + let result = user_has_read(&user, None); + assert!(result, "When room has no latest message, every user should be considered read"); + } + + #[test] + fn user_has_read_when_read_time_ge_latest() { + let latest = Utc::now(); + let read_time = latest + Duration::seconds(1); + let user = make_member(Some(read_time)); + assert!(user_has_read(&user, Some(latest))); + } + + #[test] + fn user_has_not_read_when_read_time_before_latest() { + let latest = Utc::now(); + let read_time = latest - Duration::seconds(10); + let user = make_member(Some(read_time)); + assert!(!user_has_read(&user, Some(latest))); + } + + #[test] + fn user_has_not_read_when_no_read_time_and_latest_present() { + let latest = Utc::now(); + let user = make_member(None); + assert!(!user_has_read(&user, Some(latest))); + } } \ No newline at end of file diff --git a/src/rooms/routes.rs b/src/rooms/routes.rs index 1f16367..3846b18 100644 --- a/src/rooms/routes.rs +++ b/src/rooms/routes.rs @@ -11,12 +11,12 @@ pub fn create_room_routes() -> Router> { .route("/api/rooms/{room_id}/users", get(handle_get_users_in_room)) .route("/api/rooms/{room_id}/detailed", get(handle_get_room_with_details)) .route("/api/rooms/{room_id}/timeline", get(handle_scroll_chat_timeline)) - .route("/api/rooms/{room_id}/mark-read", post(mark_room_as_read)) .route("/api/rooms/{room_id}", get(handle_get_room_list_item_by_id)) .route("/api/rooms/{room_id}/leave", post(handle_leave_room)) .route("/api/rooms/search", get(handle_search_existing_single_room)) .route("/api/rooms/{room_id}/invite/{user_id}", post(handle_invite_to_room)) .route("/api/rooms/{room_id}/upload-img", post(handle_save_room_image)) .route("/api/rooms", get(handle_get_joined_rooms)) + .route("/api/rooms/{room_id}/mark-read", post(mark_room_as_read)) .route("/api/rooms/{room_id}/read-states", get(handle_get_read_states)) }