//! A throwaway in-memory host, implementing all four traits. //! //! This is not a test of behavior. It is a test that the trait shapes can //! actually be satisfied: that the signatures compose, that the lifetimes work //! through `async_trait`, and that a host can carry its own state. The design //! calls for `ChatRooms` to be provably general rather than shaped around one //! consumer, and the only way to know that is to have a second implementor that //! is nothing like a community or a stream. //! //! When Multithreaded's real impl lands, this stays. If a signature change breaks //! it, that is the signal that the change leaked host-specific assumptions into //! the crate. use std::collections::HashMap; use std::sync::Mutex; use std::time::Duration; use async_trait::async_trait; use livechat::{ ChatAuthz, ChatError, ChatIdentity, ChatModeration, ChatRooms, DenyReason, Identity, MessageId, Retention, Room, RoomId, RoomState, UserId, WriteAccess, }; use uuid::Uuid; #[derive(Default)] struct Host { banned: Mutex>, purged: Mutex>, log: Mutex>, } fn room(state: RoomState) -> Room { Room { id: RoomId(Uuid::nil()), state, retention: Retention::forum_default(), } } #[async_trait] impl ChatRooms for Host { async fn resolve(&self, key: &str) -> Result, ChatError> { Ok(match key { "open" => Some(room(RoomState::Open)), "frozen" => Some(room(RoomState::ReadOnly)), "disabled" => Some(room(RoomState::Closed)), _ => None, }) } } #[async_trait] impl ChatAuthz for Host { async fn can_read(&self, _viewer: Option, room: &Room) -> Result { Ok(room.state.allows_reads()) } async fn can_write(&self, user: UserId, room: &Room) -> Result { if !room.state.allows_writes() { return Ok(WriteAccess::Deny(DenyReason::NotAMember)); } if self.banned.lock().unwrap().contains(&user) { return Ok(WriteAccess::Deny(DenyReason::Banned)); } Ok(WriteAccess::Allow) } async fn is_moderator(&self, _user: UserId, _room: &Room) -> Result { Ok(true) } } #[async_trait] impl ChatIdentity for Host { async fn identify(&self, users: &[UserId]) -> Result, ChatError> { Ok(users .iter() .map(|u| { ( *u, Identity { display_name: u.to_string(), avatar_url: None, flair: None, }, ) }) .collect()) } } #[async_trait] impl ChatModeration for Host { async fn delete_message( &self, _actor: UserId, _room: &Room, _message: MessageId, ) -> Result<(), ChatError> { Ok(()) } async fn purge_user( &self, _actor: UserId, room: &Room, target: UserId, ) -> Result { self.purged.lock().unwrap().push((room.id, target)); Ok(3) } async fn timeout_user( &self, _actor: UserId, _room: &Room, _target: UserId, _duration: Duration, ) -> Result<(), ChatError> { Ok(()) } async fn ban_user( &self, _actor: UserId, _room: &Room, target: UserId, _reason: Option<&str>, ) -> Result<(), ChatError> { self.banned.lock().unwrap().push(target); Ok(()) } async fn log_action( &self, _actor: UserId, _room_id: RoomId, action: &str, _detail: Option<&str>, ) -> Result<(), ChatError> { self.log.lock().unwrap().push(action.to_owned()); Ok(()) } } /// The traits must be usable behind a trait object, not only as generics. Both /// hosts will hang these off application state that is cloned per request. fn as_dyn(host: &Host) -> (&dyn ChatRooms, &dyn ChatAuthz, &dyn ChatModeration) { (host, host, host) } #[tokio::test] async fn a_host_unlike_either_consumer_can_satisfy_every_trait() { let host = Host::default(); let (rooms, authz, moderation) = as_dyn(&host); let user = UserId(Uuid::new_v4()); assert!(rooms.resolve("nope").await.unwrap().is_none()); let open = rooms.resolve("open").await.unwrap().unwrap(); assert!(authz.can_write(user, &open).await.unwrap().is_allowed()); // A disabled room resolves as Closed rather than absent, so the host can tell // "chat is off here" apart from "no such room". let closed = rooms.resolve("disabled").await.unwrap().unwrap(); assert_eq!(closed.state, RoomState::Closed); assert!(!authz.can_read(Some(user), &closed).await.unwrap()); // Read-only rooms still serve their backlog. let frozen = rooms.resolve("frozen").await.unwrap().unwrap(); assert!(authz.can_read(Some(user), &frozen).await.unwrap()); assert!(!authz.can_write(user, &frozen).await.unwrap().is_allowed()); // Ban then purge, which is the pairing the design calls for. moderation.ban_user(user, &open, user, None).await.unwrap(); let removed = moderation.purge_user(user, &open, user).await.unwrap(); assert_eq!(removed, 3); assert_eq!( authz.can_write(user, &open).await.unwrap(), WriteAccess::Deny(DenyReason::Banned) ); } #[tokio::test] async fn identify_is_batched() { let host = Host::default(); let users: Vec = (0..3).map(|_| UserId(Uuid::new_v4())).collect(); let resolved = host.identify(&users).await.unwrap(); assert_eq!(resolved.len(), 3); }