Skip to main content

max / makenotwork

5.7 KB · 173 lines History Blame Raw
1 //! `ChatRooms`: a community slug in, a `livechat::Room` out.
2 //!
3 //! One room per community, so there is no rooms table and no room id of its
4 //! own: `RoomId` *is* the community id. A community with `chat_policy <> 'off'`
5 //! is the room.
6
7 use async_trait::async_trait;
8 use livechat::{ChatError, ChatRooms, Retention, Room, RoomId, RoomState};
9 use mt_core::types::{ChatPolicy, CommunityState};
10 use sqlx::PgPool;
11
12 use super::host_error;
13
14 pub struct MtChatRooms {
15 db: PgPool,
16 }
17
18 impl MtChatRooms {
19 pub fn new(db: PgPool) -> Self {
20 Self { db }
21 }
22 }
23
24 /// Fold a community's three independent states into the room's one.
25 ///
26 /// Order matters and is fail-closed: the checks that hide the room entirely run
27 /// before the ones that merely quieten it, so a suspended *and* frozen
28 /// community is `Closed`, not `ReadOnly`.
29 pub(crate) fn room_state(policy: ChatPolicy, state: CommunityState, suspended: bool) -> RoomState {
30 // `off` must be total: no route, no hub room, no affordance. Closed is what
31 // the crate renders as a 404, so a disabled room and an absent one are
32 // indistinguishable from outside, which is the point.
33 if !policy.is_enabled() {
34 return RoomState::Closed;
35 }
36
37 // A suspended community 403s everywhere else in the app. Chat closes rather
38 // than 403s: there is nothing useful to show a visitor, and a live socket
39 // into a suspended community is exactly what suspension is meant to stop.
40 if suspended {
41 return RoomState::Closed;
42 }
43
44 // Frozen and Archived go read-only, matching how threads already behave.
45 // Asking the predicate rather than matching the variants means a new
46 // `CommunityState` gets chat's answer for free, and gets the same answer
47 // the forum gives.
48 if state.allows_writes_for_members() {
49 RoomState::Open
50 } else {
51 RoomState::ReadOnly
52 }
53 }
54
55 /// Build the crate's bounded `Retention` from the community's columns.
56 ///
57 /// Migration 039 CHECK-bounds both columns against the same ceilings
58 /// `Retention::new` enforces, so this cannot normally fail. It still refuses
59 /// rather than clamping if it ever does: a room quietly running a different
60 /// retention than its settings screen reports is worse than a room that errors.
61 fn retention(hours: i32, max_messages: i32) -> Result<Retention, ChatError> {
62 Retention::new(
63 std::time::Duration::from_hours(u64::try_from(hours).unwrap_or(0)),
64 usize::try_from(max_messages).unwrap_or(0),
65 )
66 }
67
68 #[async_trait]
69 impl ChatRooms for MtChatRooms {
70 async fn resolve(&self, key: &str) -> Result<Option<Room>, ChatError> {
71 let Some(row) = mt_db::queries::get_chat_room_by_slug(&self.db, key)
72 .await
73 .map_err(host_error)?
74 else {
75 return Ok(None);
76 };
77
78 Ok(Some(Room {
79 id: RoomId(row.id),
80 state: room_state(row.policy, row.state, row.suspended),
81 retention: retention(row.retention_hours, row.max_messages)?,
82 }))
83 }
84 }
85
86 #[cfg(test)]
87 mod tests {
88 use super::*;
89
90 #[test]
91 fn off_closes_the_room_whatever_else_is_true() {
92 for state in [
93 CommunityState::Active,
94 CommunityState::Restricted,
95 CommunityState::Frozen,
96 CommunityState::Archived,
97 ] {
98 for suspended in [false, true] {
99 assert_eq!(
100 room_state(ChatPolicy::Off, state, suspended),
101 RoomState::Closed,
102 "off must be total: state={state:?} suspended={suspended}"
103 );
104 }
105 }
106 }
107
108 #[test]
109 fn suspension_closes_rather_than_quietens() {
110 // Fail-closed ordering: suspended and frozen together is Closed, not
111 // ReadOnly. Getting this backwards would leave a live socket open into
112 // a suspended community.
113 assert_eq!(
114 room_state(ChatPolicy::Members, CommunityState::Frozen, true),
115 RoomState::Closed
116 );
117 }
118
119 #[test]
120 fn frozen_and_archived_are_read_only() {
121 for state in [CommunityState::Frozen, CommunityState::Archived] {
122 assert_eq!(
123 room_state(ChatPolicy::Members, state, false),
124 RoomState::ReadOnly,
125 "{state:?} must match how threads behave"
126 );
127 }
128 }
129
130 #[test]
131 fn active_and_restricted_are_open() {
132 // Restricted restricts new threads, not continuing writes, and chat is
133 // the continuing kind.
134 for state in [CommunityState::Active, CommunityState::Restricted] {
135 assert_eq!(
136 room_state(ChatPolicy::Members, state, false),
137 RoomState::Open
138 );
139 }
140 }
141
142 #[test]
143 fn every_enabled_policy_resolves_the_same_state() {
144 // Policy decides who may read and write; it does not decide whether the
145 // room is open. Only `off` is a state question.
146 for policy in [
147 ChatPolicy::Members,
148 ChatPolicy::PublicRead,
149 ChatPolicy::FanPlus,
150 ] {
151 assert_eq!(
152 room_state(policy, CommunityState::Active, false),
153 RoomState::Open
154 );
155 }
156 }
157
158 #[test]
159 fn the_column_defaults_build_a_valid_retention() {
160 // Migration 039's defaults must satisfy the crate ceilings, or every
161 // room in the app fails to resolve.
162 let r = retention(168, 5_000).expect("forum defaults");
163 assert_eq!(r, Retention::forum_default());
164 }
165
166 #[test]
167 fn retention_past_the_ceiling_is_refused_not_clamped() {
168 assert!(retention(721, 5_000).is_err());
169 assert!(retention(168, 20_001).is_err());
170 assert!(retention(0, 5_000).is_err());
171 }
172 }
173