Skip to main content

max / makenotwork

22.1 KB · 674 lines History Blame Raw
1 //! Tests for Multithreaded's four `livechat` trait impls.
2 //!
3 //! The authz matrix is the bulk of it. Every `chat_policy` mode has to be
4 //! covered for both reads and writes, and the cases that matter are the
5 //! crossings: a logged-out visitor against each mode, a non-member against
6 //! each mode, a banned user, a muted one, and Fan+ with and without the perk.
7
8 use livechat::{
9 ChatAuthz, ChatIdentity, ChatModeration, ChatRooms, DenyReason, MessageId, Room, RoomState,
10 UserId, WriteAccess,
11 };
12 use mt_core::types::ChatPolicy;
13 use multithreaded::chat::{
14 IdentityCache, authz::MtChatAuthz, identity::MtChatIdentity, moderation::MtChatModeration,
15 rooms::MtChatRooms,
16 };
17 use uuid::Uuid;
18
19 use crate::harness::TestHarness;
20
21 /// A `UserPerks` with the Fan+ perk on or off.
22 ///
23 /// `fan_plus` rather than `is_creator`, because `effective_plus()` is true for
24 /// either and the subscriber case is the one the policy is named for.
25 fn perks(plus: bool) -> multithreaded::auth::UserPerks {
26 multithreaded::auth::UserPerks {
27 fan_plus: plus,
28 ..Default::default()
29 }
30 }
31
32 async fn set_policy(h: &TestHarness, community: Uuid, policy: ChatPolicy) {
33 sqlx::query("UPDATE communities SET chat_policy = $1 WHERE id = $2")
34 .bind(policy.as_str())
35 .bind(community)
36 .execute(&h.db)
37 .await
38 .expect("set policy");
39 }
40
41 fn gate(h: &TestHarness, policy: ChatPolicy, viewer: Option<Uuid>, plus: bool) -> MtChatAuthz {
42 MtChatAuthz::new(h.db.clone(), policy, viewer, perks(plus))
43 }
44
45 async fn resolve(h: &TestHarness, slug: &str) -> Option<Room> {
46 MtChatRooms::new(h.db.clone())
47 .resolve(slug)
48 .await
49 .expect("resolve")
50 }
51
52 // Room resolution
53
54 #[sqlx::test]
55 async fn an_unknown_slug_resolves_to_nothing(_pool: sqlx::PgPool) {
56 let h = TestHarness::new().await;
57 assert!(resolve(&h, "no-such-community").await.is_none());
58 }
59
60 #[sqlx::test]
61 async fn chat_is_off_by_default_so_a_new_community_has_a_closed_room(_pool: sqlx::PgPool) {
62 // Migration 037 defaults every community, existing ones included, to off.
63 // Chat is a live moderation burden and an owner opts into carrying it.
64 let mut h = TestHarness::new().await;
65 let owner = h.login_as("owner").await;
66 let id = h.create_community("Test", "test").await;
67 h.add_membership(owner, id, "owner").await;
68
69 let room = resolve(&h, "test").await.expect("community exists");
70 assert_eq!(room.state, RoomState::Closed);
71 assert_eq!(room.id.0, id, "the room id is the community id");
72 }
73
74 #[sqlx::test]
75 async fn enabling_chat_opens_the_room_with_the_default_retention(_pool: sqlx::PgPool) {
76 let mut h = TestHarness::new().await;
77 let owner = h.login_as("owner").await;
78 let id = h.create_community("Test", "test").await;
79 h.add_membership(owner, id, "owner").await;
80 set_policy(&h, id, ChatPolicy::Members).await;
81
82 let room = resolve(&h, "test").await.unwrap();
83 assert_eq!(room.state, RoomState::Open);
84 assert_eq!(room.retention, livechat::Retention::forum_default());
85 }
86
87 // Reads, per policy
88
89 #[sqlx::test]
90 async fn only_public_read_shows_the_room_to_a_logged_out_visitor(_pool: sqlx::PgPool) {
91 let mut h = TestHarness::new().await;
92 let owner = h.login_as("owner").await;
93 let id = h.create_community("Test", "test").await;
94 h.add_membership(owner, id, "owner").await;
95
96 for (policy, expected) in [
97 (ChatPolicy::Off, false),
98 (ChatPolicy::Members, false),
99 (ChatPolicy::PublicRead, true),
100 (ChatPolicy::FanPlus, false),
101 ] {
102 set_policy(&h, id, policy).await;
103 let room = resolve(&h, "test").await.unwrap();
104 let can = gate(&h, policy, None, false)
105 .can_read(None, &room)
106 .await
107 .unwrap();
108 assert_eq!(can, expected, "logged-out read under {policy}");
109 }
110 }
111
112 #[sqlx::test]
113 async fn a_signed_in_stranger_reads_every_enabled_mode(_pool: sqlx::PgPool) {
114 // "Anyone who can view the forum" is the read rule for members and
115 // fan_plus alike; the modes differ on writing, not on reading.
116 let mut h = TestHarness::new().await;
117 let owner = h.login_as("owner").await;
118 let id = h.create_community("Test", "test").await;
119 h.add_membership(owner, id, "owner").await;
120 let stranger = h.login_as("stranger").await;
121
122 for policy in [
123 ChatPolicy::Members,
124 ChatPolicy::PublicRead,
125 ChatPolicy::FanPlus,
126 ] {
127 set_policy(&h, id, policy).await;
128 let room = resolve(&h, "test").await.unwrap();
129 assert!(
130 gate(&h, policy, Some(stranger), false)
131 .can_read(Some(UserId(stranger)), &room)
132 .await
133 .unwrap(),
134 "signed-in read under {policy}"
135 );
136 }
137 }
138
139 #[sqlx::test]
140 async fn off_refuses_a_read_from_anybody(_pool: sqlx::PgPool) {
141 // `off` must be total. The room is Closed too, so this is the second of
142 // two independent refusals.
143 let mut h = TestHarness::new().await;
144 let owner = h.login_as("owner").await;
145 let id = h.create_community("Test", "test").await;
146 h.add_membership(owner, id, "owner").await;
147
148 let room = resolve(&h, "test").await.unwrap();
149 let g = gate(&h, ChatPolicy::Off, Some(owner), true);
150
151 assert!(!g.can_read(Some(UserId(owner)), &room).await.unwrap());
152 assert!(!g.can_read(None, &room).await.unwrap());
153 }
154
155 #[sqlx::test]
156 async fn a_banned_user_loses_the_room_not_just_the_keyboard(_pool: sqlx::PgPool) {
157 let mut h = TestHarness::new().await;
158 let owner = h.login_as("owner").await;
159 let id = h.create_community("Test", "test").await;
160 h.add_membership(owner, id, "owner").await;
161 let member = h.login_as("member").await;
162 h.add_membership(member, id, "member").await;
163 set_policy(&h, id, ChatPolicy::PublicRead).await;
164
165 let room = resolve(&h, "test").await.unwrap();
166 h.ban_user(id, member, owner, "ban").await;
167
168 assert!(
169 !gate(&h, ChatPolicy::PublicRead, Some(member), false)
170 .can_read(Some(UserId(member)), &room)
171 .await
172 .unwrap(),
173 "a ban beats public_read for the banned user specifically"
174 );
175 }
176
177 // Writes, per policy
178
179 #[sqlx::test]
180 async fn a_member_in_good_standing_writes_in_every_non_fan_mode(_pool: sqlx::PgPool) {
181 let mut h = TestHarness::new().await;
182 let owner = h.login_as("owner").await;
183 let id = h.create_community("Test", "test").await;
184 h.add_membership(owner, id, "owner").await;
185 let member = h.login_as("member").await;
186 h.add_membership(member, id, "member").await;
187
188 for policy in [ChatPolicy::Members, ChatPolicy::PublicRead] {
189 set_policy(&h, id, policy).await;
190 let room = resolve(&h, "test").await.unwrap();
191 assert_eq!(
192 gate(&h, policy, Some(member), false)
193 .can_write(UserId(member), &room)
194 .await
195 .unwrap(),
196 WriteAccess::Allow,
197 "member write under {policy}"
198 );
199 }
200 }
201
202 #[sqlx::test]
203 async fn a_non_member_reads_but_cannot_send(_pool: sqlx::PgPool) {
204 let mut h = TestHarness::new().await;
205 let owner = h.login_as("owner").await;
206 let id = h.create_community("Test", "test").await;
207 h.add_membership(owner, id, "owner").await;
208 let stranger = h.login_as("stranger").await;
209 set_policy(&h, id, ChatPolicy::PublicRead).await;
210
211 let room = resolve(&h, "test").await.unwrap();
212 let g = gate(&h, ChatPolicy::PublicRead, Some(stranger), true);
213
214 assert!(g.can_read(Some(UserId(stranger)), &room).await.unwrap());
215 assert_eq!(
216 g.can_write(UserId(stranger), &room).await.unwrap(),
217 WriteAccess::Deny(DenyReason::NotAMember),
218 "every write mode is members in good standing, Fan+ perk or not"
219 );
220 }
221
222 #[sqlx::test]
223 async fn fan_plus_gates_writing_on_the_perk_and_nothing_else(_pool: sqlx::PgPool) {
224 let mut h = TestHarness::new().await;
225 let owner = h.login_as("owner").await;
226 let id = h.create_community("Test", "test").await;
227 h.add_membership(owner, id, "owner").await;
228 let member = h.login_as("member").await;
229 h.add_membership(member, id, "member").await;
230 set_policy(&h, id, ChatPolicy::FanPlus).await;
231
232 let room = resolve(&h, "test").await.unwrap();
233
234 assert_eq!(
235 gate(&h, ChatPolicy::FanPlus, Some(member), false)
236 .can_write(UserId(member), &room)
237 .await
238 .unwrap(),
239 WriteAccess::Deny(DenyReason::TierRequired)
240 );
241 assert_eq!(
242 gate(&h, ChatPolicy::FanPlus, Some(member), true)
243 .can_write(UserId(member), &room)
244 .await
245 .unwrap(),
246 WriteAccess::Allow
247 );
248 }
249
250 #[sqlx::test]
251 async fn the_perk_does_not_rescue_a_muted_member(_pool: sqlx::PgPool) {
252 // Good standing is checked before the tier, so paying does not buy past a
253 // moderator's decision.
254 let mut h = TestHarness::new().await;
255 let owner = h.login_as("owner").await;
256 let id = h.create_community("Test", "test").await;
257 h.add_membership(owner, id, "owner").await;
258 let member = h.login_as("member").await;
259 h.add_membership(member, id, "member").await;
260 set_policy(&h, id, ChatPolicy::FanPlus).await;
261 h.ban_user(id, member, owner, "mute").await;
262
263 let room = resolve(&h, "test").await.unwrap();
264 assert_eq!(
265 gate(&h, ChatPolicy::FanPlus, Some(member), true)
266 .can_write(UserId(member), &room)
267 .await
268 .unwrap(),
269 WriteAccess::Deny(DenyReason::Muted)
270 );
271 }
272
273 #[sqlx::test]
274 async fn a_ban_and_a_mute_are_reported_as_different_refusals(_pool: sqlx::PgPool) {
275 let mut h = TestHarness::new().await;
276 let owner = h.login_as("owner").await;
277 let id = h.create_community("Test", "test").await;
278 h.add_membership(owner, id, "owner").await;
279 set_policy(&h, id, ChatPolicy::Members).await;
280 let room = resolve(&h, "test").await.unwrap();
281
282 let banned = h.login_as("banned").await;
283 h.add_membership(banned, id, "member").await;
284 h.ban_user(id, banned, owner, "ban").await;
285
286 let muted = h.login_as("muted").await;
287 h.add_membership(muted, id, "member").await;
288 h.ban_user(id, muted, owner, "mute").await;
289
290 assert_eq!(
291 gate(&h, ChatPolicy::Members, Some(banned), false)
292 .can_write(UserId(banned), &room)
293 .await
294 .unwrap(),
295 WriteAccess::Deny(DenyReason::Banned)
296 );
297 assert_eq!(
298 gate(&h, ChatPolicy::Members, Some(muted), false)
299 .can_write(UserId(muted), &room)
300 .await
301 .unwrap(),
302 WriteAccess::Deny(DenyReason::Muted)
303 );
304 }
305
306 #[sqlx::test]
307 async fn a_gate_refuses_to_answer_about_a_different_user(_pool: sqlx::PgPool) {
308 // The seal on the per-request gate: it holds one viewer's session perks,
309 // and answering about anybody else would consult the wrong ones.
310 let mut h = TestHarness::new().await;
311 let owner = h.login_as("owner").await;
312 let id = h.create_community("Test", "test").await;
313 h.add_membership(owner, id, "owner").await;
314 let other = h.login_as("other").await;
315 h.add_membership(other, id, "member").await;
316 set_policy(&h, id, ChatPolicy::Members).await;
317
318 let room = resolve(&h, "test").await.unwrap();
319
320 assert!(
321 gate(&h, ChatPolicy::Members, Some(owner), false)
322 .can_write(UserId(other), &room)
323 .await
324 .is_err()
325 );
326 }
327
328 #[sqlx::test]
329 async fn moderators_are_recognised_and_members_are_not(_pool: sqlx::PgPool) {
330 let mut h = TestHarness::new().await;
331 let owner = h.login_as("owner").await;
332 let id = h.create_community("Test", "test").await;
333 h.add_membership(owner, id, "owner").await;
334 let moderator = h.login_as("mod").await;
335 h.add_membership(moderator, id, "moderator").await;
336 let member = h.login_as("member").await;
337 h.add_membership(member, id, "member").await;
338 let stranger = h.login_as("stranger").await;
339 set_policy(&h, id, ChatPolicy::Members).await;
340
341 let room = resolve(&h, "test").await.unwrap();
342 let g = gate(&h, ChatPolicy::Members, Some(owner), false);
343
344 for (user, expected) in [
345 (owner, true),
346 (moderator, true),
347 (member, false),
348 (stranger, false),
349 ] {
350 assert_eq!(
351 g.is_moderator(UserId(user), &room).await.unwrap(),
352 expected,
353 "is_moderator for {user}"
354 );
355 }
356 }
357
358 // Identity
359
360 #[sqlx::test]
361 async fn identity_resolves_names_and_falls_back_to_the_username(_pool: sqlx::PgPool) {
362 let mut h = TestHarness::new().await;
363 let alice = h.login_as("alice").await;
364
365 let directory = MtChatIdentity::new(h.db.clone(), IdentityCache::new());
366 let resolved = directory.identify(&[UserId(alice)]).await.unwrap();
367
368 let identity = resolved.get(&UserId(alice)).expect("alice resolves");
369 assert!(
370 !identity.display_name.is_empty(),
371 "a blank name in a room is worse than an ugly one"
372 );
373 }
374
375 #[sqlx::test]
376 async fn an_unknown_user_is_omitted_rather_than_erroring(_pool: sqlx::PgPool) {
377 // A deleted account must not take down the room.
378 let h = TestHarness::new().await;
379 let ghost = UserId(Uuid::new_v4());
380
381 let resolved = MtChatIdentity::new(h.db.clone(), IdentityCache::new())
382 .identify(&[ghost])
383 .await
384 .expect("a missing user is not an error");
385
386 assert!(resolved.is_empty());
387 }
388
389 #[sqlx::test]
390 async fn attach_fills_in_authors_across_a_backlog(_pool: sqlx::PgPool) {
391 // The crate's provided batching, over the real directory.
392 let mut h = TestHarness::new().await;
393 let alice = h.login_as("alice").await;
394 let id = h.create_community("Test", "test").await;
395 h.add_membership(alice, id, "owner").await;
396
397 let mut messages: Vec<_> = (1..=3)
398 .map(|i| livechat::Message {
399 id: MessageId(i),
400 room_id: livechat::RoomId(id),
401 author_id: UserId(alice),
402 body_html: "hi".into(),
403 created_at: 0,
404 nonce: None,
405 author: None,
406 })
407 .collect();
408
409 MtChatIdentity::new(h.db.clone(), IdentityCache::new())
410 .attach(&mut messages)
411 .await
412 .unwrap();
413
414 assert!(messages.iter().all(|m| m.author.is_some()));
415 }
416
417 // Moderation
418
419 #[sqlx::test]
420 async fn an_author_deletes_their_own_message_and_not_another(_pool: sqlx::PgPool) {
421 let mut h = TestHarness::new().await;
422 let owner = h.login_as("owner").await;
423 let id = h.create_community("Test", "test").await;
424 h.add_membership(owner, id, "owner").await;
425 let member = h.login_as("member").await;
426 h.add_membership(member, id, "member").await;
427 set_policy(&h, id, ChatPolicy::Members).await;
428 let room = resolve(&h, "test").await.unwrap();
429
430 let mine = mt_db::mutations::insert_chat_message(&h.db, id, member, "mine", 168)
431 .await
432 .unwrap()
433 .id;
434 let theirs = mt_db::mutations::insert_chat_message(&h.db, id, owner, "theirs", 168)
435 .await
436 .unwrap()
437 .id;
438
439 let as_member = MtChatModeration::new(h.db.clone(), false);
440
441 as_member
442 .delete_message(UserId(member), &room, MessageId(mine))
443 .await
444 .expect("own message");
445 assert!(
446 as_member
447 .delete_message(UserId(member), &room, MessageId(theirs))
448 .await
449 .is_err(),
450 "a member cannot delete someone else's message"
451 );
452
453 let left = mt_db::queries::recent_backlog(&h.db, id, 100)
454 .await
455 .unwrap();
456 assert_eq!(left.len(), 1);
457 assert_eq!(left[0].body_html, "theirs");
458 }
459
460 #[sqlx::test]
461 async fn a_moderator_removes_anyones_message_and_it_is_logged(_pool: sqlx::PgPool) {
462 let mut h = TestHarness::new().await;
463 let owner = h.login_as("owner").await;
464 let id = h.create_community("Test", "test").await;
465 h.add_membership(owner, id, "owner").await;
466 let member = h.login_as("member").await;
467 h.add_membership(member, id, "member").await;
468 set_policy(&h, id, ChatPolicy::Members).await;
469 let room = resolve(&h, "test").await.unwrap();
470
471 let theirs = mt_db::mutations::insert_chat_message(&h.db, id, member, "spam", 168)
472 .await
473 .unwrap()
474 .id;
475
476 MtChatModeration::new(h.db.clone(), true)
477 .delete_message(UserId(owner), &room, MessageId(theirs))
478 .await
479 .unwrap();
480
481 let logged: i64 = sqlx::query_scalar(
482 "SELECT COUNT(*) FROM mod_log WHERE community_id = $1 AND action = 'chat_delete_message'",
483 )
484 .bind(id)
485 .fetch_one(&h.db)
486 .await
487 .unwrap();
488 assert_eq!(logged, 1);
489 }
490
491 #[sqlx::test]
492 async fn deleting_your_own_message_is_not_a_mod_log_entry(_pool: sqlx::PgPool) {
493 // The mod log is a record of moderation, not of everything anyone thought
494 // better of.
495 let mut h = TestHarness::new().await;
496 let owner = h.login_as("owner").await;
497 let id = h.create_community("Test", "test").await;
498 h.add_membership(owner, id, "owner").await;
499 set_policy(&h, id, ChatPolicy::Members).await;
500 let room = resolve(&h, "test").await.unwrap();
501
502 let mine = mt_db::mutations::insert_chat_message(&h.db, id, owner, "oops", 168)
503 .await
504 .unwrap()
505 .id;
506
507 // Owner is a moderator, and still deleting their own message.
508 MtChatModeration::new(h.db.clone(), true)
509 .delete_message(UserId(owner), &room, MessageId(mine))
510 .await
511 .unwrap();
512
513 let logged: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM mod_log WHERE community_id = $1")
514 .bind(id)
515 .fetch_one(&h.db)
516 .await
517 .unwrap();
518 assert_eq!(logged, 0);
519 }
520
521 #[sqlx::test]
522 async fn deleting_a_message_that_is_already_gone_is_not_an_error(_pool: sqlx::PgPool) {
523 // Two moderators clicking the same message is normal, and the desired end
524 // state has been reached either way.
525 let mut h = TestHarness::new().await;
526 let owner = h.login_as("owner").await;
527 let id = h.create_community("Test", "test").await;
528 h.add_membership(owner, id, "owner").await;
529 set_policy(&h, id, ChatPolicy::Members).await;
530 let room = resolve(&h, "test").await.unwrap();
531
532 MtChatModeration::new(h.db.clone(), true)
533 .delete_message(UserId(owner), &room, MessageId(999_999))
534 .await
535 .expect("idempotent");
536 }
537
538 #[sqlx::test]
539 async fn a_member_cannot_ban_timeout_or_purge(_pool: sqlx::PgPool) {
540 let mut h = TestHarness::new().await;
541 let owner = h.login_as("owner").await;
542 let id = h.create_community("Test", "test").await;
543 h.add_membership(owner, id, "owner").await;
544 let member = h.login_as("member").await;
545 h.add_membership(member, id, "member").await;
546 set_policy(&h, id, ChatPolicy::Members).await;
547 let room = resolve(&h, "test").await.unwrap();
548
549 let as_member = MtChatModeration::new(h.db.clone(), false);
550
551 assert!(
552 as_member
553 .ban_user(UserId(member), &room, UserId(owner), None)
554 .await
555 .is_err()
556 );
557 assert!(
558 as_member
559 .purge_user(UserId(member), &room, UserId(owner))
560 .await
561 .is_err()
562 );
563 assert!(
564 as_member
565 .timeout_user(
566 UserId(member),
567 &room,
568 UserId(owner),
569 std::time::Duration::from_mins(5)
570 )
571 .await
572 .is_err()
573 );
574 }
575
576 #[sqlx::test]
577 async fn a_chat_ban_is_a_community_ban(_pool: sqlx::PgPool) {
578 // Not a chat-scoped punishment: the same row a forum ban writes, so a user
579 // banned from chat is banned.
580 let mut h = TestHarness::new().await;
581 let owner = h.login_as("owner").await;
582 let id = h.create_community("Test", "test").await;
583 h.add_membership(owner, id, "owner").await;
584 let member = h.login_as("member").await;
585 h.add_membership(member, id, "member").await;
586 set_policy(&h, id, ChatPolicy::Members).await;
587 let room = resolve(&h, "test").await.unwrap();
588
589 MtChatModeration::new(h.db.clone(), true)
590 .ban_user(UserId(owner), &room, UserId(member), Some("spam"))
591 .await
592 .unwrap();
593
594 assert!(
595 mt_db::queries::is_user_banned(&h.db, id, member)
596 .await
597 .unwrap(),
598 "the forum's own ban check must see it"
599 );
600 }
601
602 #[sqlx::test]
603 async fn a_timeout_is_a_mute_that_expires(_pool: sqlx::PgPool) {
604 let mut h = TestHarness::new().await;
605 let owner = h.login_as("owner").await;
606 let id = h.create_community("Test", "test").await;
607 h.add_membership(owner, id, "owner").await;
608 let member = h.login_as("member").await;
609 h.add_membership(member, id, "member").await;
610 set_policy(&h, id, ChatPolicy::Members).await;
611 let room = resolve(&h, "test").await.unwrap();
612
613 MtChatModeration::new(h.db.clone(), true)
614 .timeout_user(
615 UserId(owner),
616 &room,
617 UserId(member),
618 std::time::Duration::from_mins(10),
619 )
620 .await
621 .unwrap();
622
623 assert!(
624 mt_db::queries::is_user_muted(&h.db, id, member)
625 .await
626 .unwrap()
627 );
628 let expires: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
629 "SELECT expires_at FROM community_bans
630 WHERE community_id = $1 AND user_id = $2 AND ban_type = 'mute'",
631 )
632 .bind(id)
633 .bind(member)
634 .fetch_one(&h.db)
635 .await
636 .unwrap();
637 assert!(expires.is_some(), "a timeout must expire, unlike a mute");
638 }
639
640 #[sqlx::test]
641 async fn a_purge_records_what_it_destroyed(_pool: sqlx::PgPool) {
642 // Content destruction is the case that most needs an audit row, and it is
643 // written on the same transaction as the deletion.
644 let mut h = TestHarness::new().await;
645 let owner = h.login_as("owner").await;
646 let id = h.create_community("Test", "test").await;
647 h.add_membership(owner, id, "owner").await;
648 let member = h.login_as("member").await;
649 h.add_membership(member, id, "member").await;
650 set_policy(&h, id, ChatPolicy::Members).await;
651 let room = resolve(&h, "test").await.unwrap();
652
653 for _ in 0..3 {
654 mt_db::mutations::insert_chat_message(&h.db, id, member, "spam", 168)
655 .await
656 .unwrap();
657 }
658
659 let removed = MtChatModeration::new(h.db.clone(), true)
660 .purge_user(UserId(owner), &room, UserId(member))
661 .await
662 .unwrap();
663
664 assert_eq!(removed, 3);
665 let reason: String = sqlx::query_scalar(
666 "SELECT reason FROM mod_log WHERE community_id = $1 AND action = 'chat_purge'",
667 )
668 .bind(id)
669 .fetch_one(&h.db)
670 .await
671 .unwrap();
672 assert_eq!(reason, "3 chat messages");
673 }
674