Skip to main content

max / makenotwork

livechat: add Message.author and a moderation front door Two gaps between the crate and its design note, both settled decisions rather than open questions. Message gains an optional author: Option<Identity>, host-populated on the way out and never persisted (settled 2026-08-06, GoingsOn 0ee2b2d3). The SSE frame carried author_id and nothing else, so a client had no name or avatar to render and the only route to one was a second endpoint plus a cache. Skipped when absent, following the nonce field beside it, so no frame a client already parses changed shape. ChatIdentity::attach is a provided method that fills the field across a batch off one identify call: writing that loop per host is exactly where the N+1 identify is batched to avoid would come back. Moderation gets the front door send already had. Chat::{delete_message, purge_user, ban_user, timeout_user} mutate durably, then publish, which is the deletion mirror of the phantom-message ordering in send. A ban pairs the ban with the purge and emits one Purge event regardless of how many messages went: as N deletes it would overflow the room buffer past a few hundred, gap every listener, and stampede the backlog query. A timeout publishes nothing: it removes no content, and announcing it would make a quiet moderation action public. 77 tests pass, clippy --all-targets and fmt clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 18:16 UTC
Signed with PGP, not checked
Commit: f3025d7a4af922d756603ba5e55c7977fe7335a9
Parent: 5b6ff0b
10 files changed, +647 insertions, -1 deletion
@@ -783,3 +783,27 @@
783 783 version = "1.0.23"
784 784 source = "registry+https://github.com/rust-lang/crates.io-index"
785 785 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
786 +
787 + [[patch.unused]]
788 + name = "synckit-client"
789 + version = "0.8.0"
790 +
791 + [[patch.unused]]
792 + name = "synckit-config"
793 + version = "0.2.0"
794 +
795 + [[patch.unused]]
796 + name = "docengine"
797 + version = "0.4.0"
798 +
799 + [[patch.unused]]
800 + name = "kberg"
801 + version = "0.1.0"
802 +
803 + [[patch.unused]]
804 + name = "painhours"
805 + version = "0.1.0"
806 +
807 + [[patch.unused]]
808 + name = "tagtree"
809 + version = "0.4.0"
@@ -90,6 +90,75 @@
90 90 ChatStream::open(&self.hub, room, user, after, fetch).await
91 91 }
92 92
93 + /// Remove one message and tell the room.
94 + ///
95 + /// `actor` is the author for a self-delete or a moderator otherwise; the
96 + /// host's impl proves the entitlement, since only it can read the row.
97 + pub async fn delete_message<M>(
98 + &self,
99 + moderation: &M,
100 + actor: UserId,
101 + room: &Room,
102 + message: MessageId,
103 + ) -> Result<(), ChatError>
104 + where
105 + M: crate::traits::ChatModeration + ?Sized,
106 + {
107 + crate::moderate::delete_message(&self.hub, moderation, actor, room, message).await
108 + }
109 +
110 + /// Remove every message by `target` in the room, announced as one event.
111 + ///
112 + /// Returns how many rows went. See [`crate::moderate`] for why this is not
113 + /// N deletes.
114 + pub async fn purge_user<M>(
115 + &self,
116 + moderation: &M,
117 + actor: UserId,
118 + room: &Room,
119 + target: UserId,
120 + ) -> Result<u64, ChatError>
121 + where
122 + M: crate::traits::ChatModeration + ?Sized,
123 + {
124 + crate::moderate::purge_user(&self.hub, moderation, actor, room, target).await
125 + }
126 +
127 + /// Ban `target` and remove their backlog, returning how many messages went.
128 + ///
129 + /// The pairing is here rather than left to the caller because a ban that
130 + /// forgets the purge leaves the damage up, and that is the whole reason the
131 + /// two go together.
132 + pub async fn ban_user<M>(
133 + &self,
134 + moderation: &M,
135 + actor: UserId,
136 + room: &Room,
137 + target: UserId,
138 + reason: Option<&str>,
139 + ) -> Result<u64, ChatError>
140 + where
141 + M: crate::traits::ChatModeration + ?Sized,
142 + {
143 + crate::moderate::ban_user(&self.hub, moderation, actor, room, target, reason).await
144 + }
145 +
146 + /// Stop `target` from sending for `duration`. Reads are unaffected and the
147 + /// room is told nothing.
148 + pub async fn timeout_user<M>(
149 + &self,
150 + moderation: &M,
151 + actor: UserId,
152 + room: &Room,
153 + target: UserId,
154 + duration: Duration,
155 + ) -> Result<(), ChatError>
156 + where
157 + M: crate::traits::ChatModeration + ?Sized,
158 + {
159 + crate::moderate::timeout_user(moderation, actor, room, target, duration).await
160 + }
161 +
93 162 /// Drop rate-limit buckets nobody has touched recently.
94 163 ///
95 164 /// The host must call this on a timer. Bucket state is keyed by (user, room),
@@ -169,6 +238,7 @@
169 238 body_html: body.as_str().to_owned(),
170 239 created_at: 0,
171 240 nonce: None,
241 + author: None,
172 242 })
173 243 },
174 244 )
@@ -74,6 +74,7 @@
74 74 body_html: "hi".into(),
75 75 created_at: 0,
76 76 nonce: None,
77 + author: None,
77 78 }
78 79 }
79 80
@@ -43,6 +43,7 @@
43 43 mod hub;
44 44 mod ids;
45 45 mod message;
46 + mod moderate;
46 47 mod rate_limit;
47 48 mod retention;
48 49 mod room;
@@ -4,6 +4,7 @@
4 4
5 5 use crate::error::ChatError;
6 6 use crate::ids::{MessageId, Nonce, RoomId, UserId};
7 + use crate::traits::Identity;
7 8
8 9 /// Longest message a room accepts.
9 10 ///
@@ -65,6 +66,20 @@
65 66 /// Absent for every other recipient and for backlog replay.
66 67 #[serde(skip_serializing_if = "Option::is_none")]
67 68 pub nonce: Option<Nonce>,
69 + /// Who wrote it, as the room should display them.
70 + ///
71 + /// Populated by the host on the way out and **never persisted**: a display
72 + /// name, avatar, or flair that changes must change everywhere it appears,
73 + /// and a copy frozen into the message row at insert would not. The row keeps
74 + /// `author_id`; everything shown next to it is resolved at send and replay
75 + /// time through [`ChatIdentity::attach`](crate::ChatIdentity::attach).
76 + ///
77 + /// Optional and skipped when absent, following `nonce` above, so adding it
78 + /// did not change a single frame a client already knew how to parse. A
79 + /// client that receives a message without one falls back to whatever it
80 + /// already shows for `author_id`.
81 + #[serde(skip_serializing_if = "Option::is_none")]
82 + pub author: Option<Identity>,
68 83 }
69 84
70 85 #[cfg(test)]
@@ -165,6 +165,7 @@
165 165 body_html: format!("<p>{}</p>", body.as_str()),
166 166 created_at: 0,
167 167 nonce: None,
168 + author: None,
168 169 }
169 170 }
170 171
@@ -76,6 +76,7 @@
76 76 body_html: "hi".into(),
77 77 created_at: 0,
78 78 nonce: None,
79 + author: None,
79 80 }
80 81 }
81 82
@@ -124,6 +124,7 @@
124 124 body_html: format!("m{id}"),
125 125 created_at: 0,
126 126 nonce: None,
127 + author: None,
127 128 }
128 129 }
129 130
@@ -12,9 +12,11 @@
12 12 use std::time::Duration;
13 13
14 14 use async_trait::async_trait;
15 + use serde::{Deserialize, Serialize};
15 16
16 17 use crate::error::ChatError;
17 18 use crate::ids::{MessageId, RoomId, UserId};
19 + use crate::message::Message;
18 20 use crate::room::Room;
19 21
20 22 /// Resolves an app-specific key to a room.
@@ -79,11 +81,16 @@
79 81 }
80 82
81 83 /// How a user is displayed.
82 - #[derive(Debug, Clone, PartialEq, Eq)]
84 + ///
85 + /// Serializable because it rides out on [`Message::author`](crate::Message).
86 + /// It is never stored: see that field for why a frozen copy would be wrong.
87 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83 88 pub struct Identity {
84 89 pub display_name: String,
90 + #[serde(skip_serializing_if = "Option::is_none")]
85 91 pub avatar_url: Option<String>,
86 92 /// Role or badge text, if the host shows one.
93 + #[serde(skip_serializing_if = "Option::is_none")]
87 94 pub flair: Option<String>,
88 95 }
89 96
@@ -100,6 +107,33 @@
100 107 /// Users the host cannot resolve are omitted from the map rather than
101 108 /// erroring. A deleted account should not take down the room.
102 109 async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError>;
110 +
111 + /// Fill in [`Message::author`](crate::Message) across a batch.
112 + ///
113 + /// Provided rather than left to each host, because the host writing this
114 + /// loop by hand is exactly where the N+1 that [`ChatIdentity::identify`] is
115 + /// batched to avoid gets reintroduced. Distinct authors are collected first,
116 + /// so a room where one person is talking costs one lookup rather than one
117 + /// per message.
118 + ///
119 + /// Authors the host could not resolve are left as `None`, matching
120 + /// `identify`'s contract: a deleted account leaves its messages readable
121 + /// rather than emptying the room.
122 + async fn attach(&self, messages: &mut [Message]) -> Result<(), ChatError> {
123 + if messages.is_empty() {
124 + return Ok(());
125 + }
126 +
127 + let mut distinct: Vec<UserId> = messages.iter().map(|m| m.author_id).collect();
128 + distinct.sort_unstable_by_key(|u| u.0);
129 + distinct.dedup();
130 +
131 + let identities = self.identify(&distinct).await?;
132 + for message in messages {
133 + message.author = identities.get(&message.author_id).cloned();
134 + }
135 + Ok(())
136 + }
103 137 }
104 138
105 139 /// Moderation actions.
@@ -173,6 +207,109 @@
173 207 #[cfg(test)]
174 208 mod tests {
175 209 use super::*;
210 + use crate::ids::MessageId;
211 + use std::sync::Mutex;
212 + use uuid::Uuid;
213 +
214 + /// Resolves everyone to their id as a display name, and records each batch
215 + /// it was handed so the tests can assert on the call shape.
216 + #[derive(Default)]
217 + struct Directory {
218 + batches: Mutex<Vec<Vec<UserId>>>,
219 + unknown: Option<UserId>,
220 + }
221 +
222 + #[async_trait]
223 + impl ChatIdentity for Directory {
224 + async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError> {
225 + self.batches.lock().unwrap().push(users.to_vec());
226 + Ok(users
227 + .iter()
228 + .filter(|u| Some(**u) != self.unknown)
229 + .map(|u| {
230 + (
231 + *u,
232 + Identity {
233 + display_name: u.to_string(),
234 + avatar_url: None,
235 + flair: None,
236 + },
237 + )
238 + })
239 + .collect())
240 + }
241 + }
242 +
243 + fn message(id: i64, author: UserId) -> Message {
244 + Message {
245 + id: MessageId(id),
246 + room_id: crate::ids::RoomId(Uuid::nil()),
247 + author_id: author,
248 + body_html: "hi".into(),
249 + created_at: 0,
250 + nonce: None,
251 + author: None,
252 + }
253 + }
254 +
255 + #[tokio::test]
256 + async fn attach_resolves_every_author() {
257 + let (a, b) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4()));
258 + let mut messages = vec![message(1, a), message(2, b)];
259 +
260 + Directory::default().attach(&mut messages).await.unwrap();
261 +
262 + assert_eq!(
263 + messages[0].author.as_ref().unwrap().display_name,
264 + a.to_string()
265 + );
266 + assert_eq!(
267 + messages[1].author.as_ref().unwrap().display_name,
268 + b.to_string()
269 + );
270 + }
271 +
272 + #[tokio::test]
273 + async fn attach_asks_once_per_distinct_author() {
274 + // The N+1 this exists to prevent: a room where one person is talking
275 + // must cost one lookup, not one per message.
276 + let loud = UserId(Uuid::new_v4());
277 + let mut messages: Vec<_> = (1..=50).map(|i| message(i, loud)).collect();
278 +
279 + let directory = Directory::default();
280 + directory.attach(&mut messages).await.unwrap();
281 +
282 + let batches = directory.batches.lock().unwrap();
283 + assert_eq!(batches.len(), 1, "one call for the whole backlog");
284 + assert_eq!(batches[0], vec![loud], "deduplicated to one id");
285 + assert!(messages.iter().all(|m| m.author.is_some()));
286 + }
287 +
288 + #[tokio::test]
289 + async fn an_unresolvable_author_leaves_the_message_readable() {
290 + // A deleted account must not empty the room.
291 + let (gone, present) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4()));
292 + let mut messages = vec![message(1, gone), message(2, present)];
293 +
294 + Directory {
295 + unknown: Some(gone),
296 + ..Default::default()
297 + }
298 + .attach(&mut messages)
299 + .await
300 + .unwrap();
301 +
302 + assert!(messages[0].author.is_none());
303 + assert_eq!(messages[0].body_html, "hi", "the message itself survives");
304 + assert!(messages[1].author.is_some());
305 + }
306 +
307 + #[tokio::test]
308 + async fn attach_on_an_empty_batch_queries_nothing() {
309 + let directory = Directory::default();
310 + directory.attach(&mut []).await.unwrap();
311 + assert!(directory.batches.lock().unwrap().is_empty());
312 + }
176 313
177 314 #[test]
178 315 fn only_allow_is_allowed() {
@@ -1,0 +1,395 @@
1 + //! The moderation path.
2 + //!
3 + //! Same discipline as [`crate::send`], for the same reason: the order is a
4 + //! correctness property, so it lives here once rather than in each host.
5 + //! **Mutate durably, then publish.** Broadcasting a removal the database
6 + //! refused produces a room where a message is gone for everyone present and
7 + //! back for anyone who reloads, which is the deletion mirror of the phantom
8 + //! message the send path is ordered to avoid.
9 + //!
10 + //! # Why a ban is one event
11 + //!
12 + //! A ban removes every message its target wrote in the retention window, and
13 + //! that is a burst with no useful upper bound: the whole point of banning
14 + //! someone is usually that they said a great many things. Fanning it out as one
15 + //! [`ChatEvent::Delete`] per message would push that burst through every open
16 + //! connection in the room at once, and the room buffer is finite, so past a few
17 + //! hundred messages the deletes would overflow it and every listener would take
18 + //! a [`ChatEvent::Gap`] and re-fetch the backlog. A moderation action that
19 + //! reliably triggers a stampede is one moderators learn not to use.
20 + //!
21 + //! So the removal is a single statement keyed by (room, author) and a single
22 + //! [`ChatEvent::Purge`], which the client applies by dropping everything it
23 + //! holds from that author. Cost is constant in the number of messages.
24 +
25 + use crate::error::ChatError;
26 + use crate::event::ChatEvent;
27 + use crate::hub::Hub;
28 + use crate::ids::{MessageId, UserId};
29 + use crate::room::Room;
30 + use crate::traits::ChatModeration;
31 +
32 + /// Remove one message, then tell the room.
33 + ///
34 + /// The host's [`ChatModeration::delete_message`] is responsible for proving
35 + /// `actor` is entitled to this message, which is why the entitlement check is
36 + /// not duplicated here: it needs the row, and only the host can read it.
37 + pub(crate) async fn delete_message<M>(
38 + hub: &Hub,
39 + moderation: &M,
40 + actor: UserId,
41 + room: &Room,
42 + message: MessageId,
43 + ) -> Result<(), ChatError>
44 + where
45 + M: ChatModeration + ?Sized,
46 + {
47 + moderation.delete_message(actor, room, message).await?;
48 + hub.publish(room.id, ChatEvent::Delete { id: message });
49 + Ok(())
50 + }
51 +
52 + /// Remove every message by `target` in the room, then tell the room once.
53 + ///
54 + /// Returns how many rows went.
55 + pub(crate) async fn purge_user<M>(
56 + hub: &Hub,
57 + moderation: &M,
58 + actor: UserId,
59 + room: &Room,
60 + target: UserId,
61 + ) -> Result<u64, ChatError>
62 + where
63 + M: ChatModeration + ?Sized,
64 + {
65 + let removed = moderation.purge_user(actor, room, target).await?;
66 +
67 + // Broadcast even when nothing was removed. A connected client can be
68 + // holding messages the retention sweep already deleted server-side, so
69 + // "zero rows" does not mean "nothing on screen", and the event is one frame
70 + // that clients apply idempotently.
71 + hub.publish(room.id, ChatEvent::Purge { author_id: target });
72 +
73 + Ok(removed)
74 + }
75 +
76 + /// Ban `target` and remove their backlog in one action.
77 + ///
78 + /// Returns how many messages the ban removed.
79 + ///
80 + /// The ban lands first. If the purge then fails, the caller gets the error and
81 + /// the ban still stands, which is the right way round: a banned user with
82 + /// messages still up is a cleanup job, while a purged user who can still type
83 + /// is the incident continuing.
84 + pub(crate) async fn ban_user<M>(
85 + hub: &Hub,
86 + moderation: &M,
87 + actor: UserId,
88 + room: &Room,
89 + target: UserId,
90 + reason: Option<&str>,
91 + ) -> Result<u64, ChatError>
92 + where
93 + M: ChatModeration + ?Sized,
94 + {
95 + moderation.ban_user(actor, room, target, reason).await?;
96 + purge_user(hub, moderation, actor, room, target).await
97 + }
98 +
99 + /// Stop `target` from sending for `duration`.
100 + ///
101 + /// Publishes nothing, deliberately. A timeout removes no content, so there is
102 + /// nothing for a connected client to re-render, and reads are explicitly
103 + /// unaffected. Announcing it to the room would turn a quiet moderation action
104 + /// into a public one and hand every listener an event whose only content is
105 + /// somebody else's punishment. The person timed out finds out when their next
106 + /// send is refused with [`DenyReason::Muted`](crate::DenyReason::Muted).
107 + pub(crate) async fn timeout_user<M>(
108 + moderation: &M,
109 + actor: UserId,
110 + room: &Room,
111 + target: UserId,
112 + duration: std::time::Duration,
113 + ) -> Result<(), ChatError>
114 + where
115 + M: ChatModeration + ?Sized,
116 + {
117 + moderation.timeout_user(actor, room, target, duration).await
118 + }
119 +
120 + #[cfg(test)]
121 + mod tests {
122 + use super::*;
123 + use crate::hub::HubLimits;
124 + use crate::ids::RoomId;
125 + use crate::retention::Retention;
126 + use crate::room::RoomState;
127 + use async_trait::async_trait;
128 + use std::sync::Mutex;
129 + use std::sync::atomic::{AtomicBool, Ordering};
130 + use std::time::Duration;
131 + use uuid::Uuid;
132 +
133 + /// Records what the host was asked to do, and can be told to fail.
134 + #[derive(Default)]
135 + struct Recorder {
136 + calls: Mutex<Vec<String>>,
137 + fail_ban: AtomicBool,
138 + fail_purge: AtomicBool,
139 + fail_delete: AtomicBool,
140 + purged_rows: u64,
141 + }
142 +
143 + impl Recorder {
144 + fn calls(&self) -> Vec<String> {
145 + self.calls.lock().unwrap().clone()
146 + }
147 + fn note(&self, what: &str) {
148 + self.calls.lock().unwrap().push(what.to_owned());
149 + }
150 + }
151 +
152 + fn boom() -> ChatError {
153 + ChatError::host(std::io::Error::other("db down"))
154 + }
155 +
156 + #[async_trait]
157 + impl ChatModeration for Recorder {
158 + async fn delete_message(
159 + &self,
160 + _actor: UserId,
161 + _room: &Room,
162 + message: MessageId,
163 + ) -> Result<(), ChatError> {
164 + self.note(&format!("delete {message}"));
165 + if self.fail_delete.load(Ordering::SeqCst) {
166 + return Err(boom());
167 + }
168 + Ok(())
169 + }
170 +
171 + async fn purge_user(
172 + &self,
173 + _actor: UserId,
174 + _room: &Room,
175 + _target: UserId,
176 + ) -> Result<u64, ChatError> {
177 + self.note("purge");
178 + if self.fail_purge.load(Ordering::SeqCst) {
179 + return Err(boom());
180 + }
181 + Ok(self.purged_rows)
182 + }
183 +
184 + async fn timeout_user(
185 + &self,
186 + _actor: UserId,
187 + _room: &Room,
188 + _target: UserId,
189 + _duration: Duration,
190 + ) -> Result<(), ChatError> {
191 + self.note("timeout");
192 + Ok(())
193 + }
194 +
195 + async fn ban_user(
196 + &self,
197 + _actor: UserId,
198 + _room: &Room,
199 + _target: UserId,
200 + _reason: Option<&str>,
201 + ) -> Result<(), ChatError> {
202 + self.note("ban");
203 + if self.fail_ban.load(Ordering::SeqCst) {
204 + return Err(boom());
205 + }
206 + Ok(())
207 + }
208 +
209 + async fn log_action(
210 + &self,
211 + _actor: UserId,
212 + _room_id: RoomId,
213 + _action: &str,
214 + _detail: Option<&str>,
215 + ) -> Result<(), ChatError> {
216 + Ok(())
217 + }
218 + }
219 +
220 + fn room() -> Room {
221 + Room {
222 + id: RoomId(Uuid::new_v4()),
223 + state: RoomState::Open,
224 + retention: Retention::forum_default(),
225 + }
226 + }
227 +
228 + fn user() -> UserId {
229 + UserId(Uuid::new_v4())
230 + }
231 +
232 + /// Everything already queued on the room, without blocking.
233 + ///
234 + /// Polled rather than awaited because half these tests assert that *no*
235 + /// event was published, and awaiting for that would mean picking a timeout
236 + /// and trading a fast suite against a flaky one. Every event under test is
237 + /// published before the assertion runs, so anything not queued by now is
238 + /// genuinely absent.
239 + fn drained(sub: &mut crate::hub::Subscription) -> Vec<ChatEvent> {
240 + use std::task::{Context, Poll, Waker};
241 +
242 + let mut out = Vec::new();
243 + let mut cx = Context::from_waker(Waker::noop());
244 + loop {
245 + let mut recv = Box::pin(sub.recv());
246 + match recv.as_mut().poll(&mut cx) {
247 + Poll::Ready(Some(event)) => out.push(event),
248 + Poll::Ready(None) | Poll::Pending => return out,
249 + }
250 + }
251 + }
252 +
253 + #[tokio::test]
254 + async fn a_delete_reaches_the_room() {
255 + let hub = Hub::new(HubLimits::default());
256 + let r = room();
257 + let mut listener = hub.subscribe(r.id, user()).unwrap();
258 +
259 + delete_message(&hub, &Recorder::default(), user(), &r, MessageId(7))
260 + .await
261 + .unwrap();
262 +
263 + assert_eq!(
264 + drained(&mut listener),
265 + vec![ChatEvent::Delete { id: MessageId(7) }]
266 + );
267 + }
268 +
269 + #[tokio::test]
270 + async fn a_failed_delete_publishes_nothing() {
271 + // The mirror of the phantom-message failure: a message gone for
272 + // everyone present and back on reload.
273 + let hub = Hub::new(HubLimits::default());
274 + let r = room();
275 + let mut listener = hub.subscribe(r.id, user()).unwrap();
276 +
277 + let host = Recorder::default();
278 + host.fail_delete.store(true, Ordering::SeqCst);
279 +
280 + assert!(
281 + delete_message(&hub, &host, user(), &r, MessageId(7))
282 + .await
283 + .is_err()
284 + );
285 + assert!(
286 + drained(&mut listener).is_empty(),
287 + "a refused delete must not be broadcast"
288 + );
289 + }
290 +
291 + #[tokio::test]
292 + async fn a_ban_purges_and_emits_exactly_one_event() {
293 + // The property the whole module exists for: constant event cost
294 + // regardless of how much the banned user wrote.
295 + let hub = Hub::new(HubLimits::default());
296 + let r = room();
297 + let target = user();
298 + let mut listener = hub.subscribe(r.id, user()).unwrap();
299 +
300 + let host = Recorder {
301 + purged_rows: 4_000,
302 + ..Default::default()
303 + };
304 +
305 + let removed = ban_user(&hub, &host, user(), &r, target, Some("spam"))
306 + .await
307 + .unwrap();
308 +
309 + assert_eq!(removed, 4_000);
310 + assert_eq!(host.calls(), vec!["ban", "purge"], "ban lands before purge");
311 + assert_eq!(
312 + drained(&mut listener),
313 + vec![ChatEvent::Purge { author_id: target }],
314 + "4000 messages removed, one event"
315 + );
316 + }
317 +
318 + #[tokio::test]
319 + async fn a_failed_ban_never_purges() {
320 + let hub = Hub::new(HubLimits::default());
321 + let r = room();
322 + let mut listener = hub.subscribe(r.id, user()).unwrap();
323 +
324 + let host = Recorder::default();
325 + host.fail_ban.store(true, Ordering::SeqCst);
326 +
327 + assert!(
328 + ban_user(&hub, &host, user(), &r, user(), None)
329 + .await
330 + .is_err()
331 + );
332 + assert_eq!(host.calls(), vec!["ban"], "the purge must not have run");
333 + assert!(drained(&mut listener).is_empty());
334 + }
335 +
336 + #[tokio::test]
337 + async fn a_ban_whose_purge_fails_still_reports_the_error() {
338 + let hub = Hub::new(HubLimits::default());
339 + let r = room();
340 + let mut listener = hub.subscribe(r.id, user()).unwrap();
341 +
342 + let host = Recorder::default();
343 + host.fail_purge.store(true, Ordering::SeqCst);
344 +
345 + assert!(
346 + ban_user(&hub, &host, user(), &r, user(), None)
347 + .await
348 + .is_err(),
349 + "the caller must learn the backlog is still up"
350 + );
351 + assert_eq!(host.calls(), vec!["ban", "purge"]);
352 + assert!(
353 + drained(&mut listener).is_empty(),
354 + "nothing was removed, so nothing is announced"
355 + );
356 + }
357 +
358 + #[tokio::test]
359 + async fn a_purge_that_removed_nothing_is_still_announced() {
360 + // A client can hold messages the retention sweep already deleted, so
361 + // zero rows does not mean nothing on screen.
362 + let hub = Hub::new(HubLimits::default());
363 + let r = room();
364 + let target = user();
365 + let mut listener = hub.subscribe(r.id, user()).unwrap();
366 +
367 + let removed = purge_user(&hub, &Recorder::default(), user(), &r, target)
368 + .await
369 + .unwrap();
370 +
371 + assert_eq!(removed, 0);
372 + assert_eq!(
373 + drained(&mut listener),
374 + vec![ChatEvent::Purge { author_id: target }]
375 + );
376 + }
377 +
378 + #[tokio::test]
379 + async fn a_timeout_is_not_announced_to_the_room() {
380 + let hub = Hub::new(HubLimits::default());
381 + let r = room();
382 + let mut listener = hub.subscribe(r.id, user()).unwrap();
383 +
384 + let host = Recorder::default();
385 + timeout_user(&host, user(), &r, user(), Duration::from_mins(5))
386 + .await
387 + .unwrap();
388 +
389 + assert_eq!(host.calls(), vec!["timeout"]);
390 + assert!(
391 + drained(&mut listener).is_empty(),
392 + "a timeout removes no content, so there is nothing to re-render"
393 + );
394 + }
395 + }