Coverage for app/backend/src/tests/fixtures/misc.py: 98%

125 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-14 11:44 +0000

1from dataclasses import dataclass 

2from typing import Any 

3from unittest.mock import patch 

4 

5from sqlalchemy.orm import Session 

6 

7from couchers.config import config 

8from couchers.jobs.worker import process_job 

9from couchers.models import User 

10from couchers.notifications.push import PushNotificationContent 

11from couchers.proto import moderation_pb2 

12from couchers.proto.internal import jobs_pb2 

13from couchers.servicers.threads import unpack_thread_id 

14from tests.fixtures.sessions import real_moderation_session 

15 

16 

17def process_jobs() -> None: 

18 while process_job(): 

19 pass 

20 

21 

22class EmailCollector: 

23 """Intercepts emails so they can be verified by tests.""" 

24 

25 def __init__(self) -> None: 

26 # Collected emails by recipient address, chronologically. 

27 self.by_recipient: dict[str, list[jobs_pb2.SendEmailPayload]] = {} 

28 self._patch = patch("couchers.email.queuing._queue_email", self._mock_queue_email) 

29 

30 def _mock_queue_email(self, session: Session, payload: jobs_pb2.SendEmailPayload) -> None: 

31 if payload.recipient not in self.by_recipient: 

32 self.by_recipient[payload.recipient] = [] 

33 self.by_recipient[payload.recipient].append(payload) 

34 

35 def __enter__(self): 

36 process_jobs() # Flush any emails prior to this point 

37 self.by_recipient.clear() 

38 self._patch.start() 

39 return self 

40 

41 def __exit__(self, exc_type, exc_val, exc_tb): 

42 self._patch.stop() 

43 return False # Let any exception propagate 

44 

45 def count(self) -> int: 

46 process_jobs() 

47 return sum(len(v) for v in self.by_recipient.values()) 

48 

49 def count_for_recipient(self, recipient: str) -> int: 

50 process_jobs() 

51 return len(self.by_recipient.get(recipient, [])) 

52 

53 def count_for_mods(self) -> int: 

54 return self.count_for_recipient(config.MODS_EMAIL_RECIPIENT) 

55 

56 def count_for_reports(self) -> int: 

57 return self.count_for_recipient(config.REPORTS_EMAIL_RECIPIENT) 

58 

59 def pop_for_recipient(self, recipient: str, *, last: bool = False) -> jobs_pb2.SendEmailPayload: 

60 """ 

61 Removes and returns the oldest email queued to a given recipient, 

62 optionally asserting that it is the last one. 

63 """ 

64 process_jobs() 

65 emails = self.by_recipient.get(recipient) 

66 assert emails, f"No emails to pop for recipient {recipient}." 

67 if last: 

68 assert len(emails) == 1, f"Expected a single email for recipient {recipient}." 

69 return emails.pop(0) 

70 

71 def pop_for_mods(self, *, last: bool = False) -> jobs_pb2.SendEmailPayload: 

72 return self.pop_for_recipient(config.MODS_EMAIL_RECIPIENT, last=last) 

73 

74 def pop_for_reports(self, *, last: bool = False) -> jobs_pb2.SendEmailPayload: 

75 return self.pop_for_recipient(config.REPORTS_EMAIL_RECIPIENT, last=last) 

76 

77 

78@dataclass(frozen=True, slots=True, kw_only=True) 

79class Push: 

80 topic_action: str 

81 content: PushNotificationContent 

82 key: str | None = None 

83 ttl: int | None = None 

84 

85 

86class PushCollector: 

87 """Captures push notifications and allows inspecting them.""" 

88 

89 def __init__(self) -> None: 

90 # Collected notifications by user id, chronologically. 

91 self.by_user: dict[int, list[Push]] = {} 

92 self._patch = patch("couchers.notifications.push._push_to_user", self._mock_push_to_user) 

93 

94 def _mock_push_to_user(self, session: Session, user_id: int, **kwargs: Any) -> None: 

95 if user_id not in self.by_user: 

96 self.by_user[user_id] = [] 

97 self.by_user[user_id].append(Push(**kwargs)) 

98 

99 def __enter__(self): 

100 process_jobs() # Flush any push notifications prior to this point 

101 self.by_user.clear() 

102 self._patch.start() 

103 return self 

104 

105 def __exit__(self, exc_type, exc_val, exc_tb): 

106 self._patch.stop() 

107 return False # Let any exception propagate 

108 

109 def count_for_user(self, user_id: int) -> int: 

110 process_jobs() 

111 return len(self.by_user.get(user_id, [])) 

112 

113 def pop_for_user(self, user_id: int, *, last: bool = False) -> Push: 

114 """ 

115 Removes and returns the oldest push notification received by the given user, 

116 optionally asserting that it is the last one. 

117 """ 

118 process_jobs() 

119 pushes = self.by_user.get(user_id) 

120 assert pushes, f"No notifications to pop for user {user_id}." 

121 if last: 

122 assert len(pushes) == 1, f"Expected a single notification for user {user_id}." 

123 return pushes.pop(0) 

124 

125 

126class Moderator: 

127 """ 

128 A test fixture that provides a moderator user and methods to exercise the moderation API. 

129 

130 Usage: 

131 def test_example(db, moderator): 

132 user, token = generate_user() 

133 # ... create a host request ... 

134 moderator.approve_host_request(host_request_id) 

135 """ 

136 

137 def __init__(self, user: User, token: str): 

138 self.user = user 

139 self.token = token 

140 

141 def approve_host_request(self, host_request_id: int, reason: str = "Test approval") -> None: 

142 """ 

143 Approve a host request using the moderation API. 

144 

145 Args: 

146 host_request_id: The conversation_id of the host request 

147 reason: Optional reason for approval 

148 """ 

149 with real_moderation_session(self.token) as api: 

150 state_res = api.GetModerationState( 

151 moderation_pb2.GetModerationStateReq( 

152 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

153 object_id=host_request_id, 

154 ) 

155 ) 

156 api.ModerateContent( 

157 moderation_pb2.ModerateContentReq( 

158 moderation_state_id=state_res.moderation_state.moderation_state_id, 

159 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

160 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

161 reason=reason, 

162 ) 

163 ) 

164 

165 def approve_group_chat(self, group_chat_id: int, reason: str = "Test approval") -> None: 

166 """ 

167 Approve a group chat using the moderation API. 

168 

169 Args: 

170 group_chat_id: The conversation_id of the group chat 

171 reason: Optional reason for approval 

172 """ 

173 with real_moderation_session(self.token) as api: 

174 state_res = api.GetModerationState( 

175 moderation_pb2.GetModerationStateReq( 

176 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

177 object_id=group_chat_id, 

178 ) 

179 ) 

180 api.ModerateContent( 

181 moderation_pb2.ModerateContentReq( 

182 moderation_state_id=state_res.moderation_state.moderation_state_id, 

183 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

184 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

185 reason=reason, 

186 ) 

187 ) 

188 

189 def approve_friend_request(self, friend_request_id: int, reason: str = "Test approval") -> None: 

190 """ 

191 Approve a friend request using the moderation API. 

192 

193 Args: 

194 friend_request_id: The ID of the friend request (FriendRelationship.id) 

195 reason: Optional reason for approval 

196 """ 

197 with real_moderation_session(self.token) as api: 

198 state_res = api.GetModerationState( 

199 moderation_pb2.GetModerationStateReq( 

200 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST, 

201 object_id=friend_request_id, 

202 ) 

203 ) 

204 api.ModerateContent( 

205 moderation_pb2.ModerateContentReq( 

206 moderation_state_id=state_res.moderation_state.moderation_state_id, 

207 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

208 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

209 reason=reason, 

210 ) 

211 ) 

212 

213 def approve_event_occurrence(self, occurrence_id: int, reason: str = "Test approval") -> None: 

214 """ 

215 Approve an event occurrence using the moderation API. 

216 

217 Args: 

218 occurrence_id: The ID of the EventOccurrence (what the proto calls event_id) 

219 reason: Optional reason for approval 

220 """ 

221 with real_moderation_session(self.token) as api: 

222 state_res = api.GetModerationState( 

223 moderation_pb2.GetModerationStateReq( 

224 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE, 

225 object_id=occurrence_id, 

226 ) 

227 ) 

228 api.ModerateContent( 

229 moderation_pb2.ModerateContentReq( 

230 moderation_state_id=state_res.moderation_state.moderation_state_id, 

231 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

232 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

233 reason=reason, 

234 ) 

235 ) 

236 

237 def approve_comment(self, comment_id: int, reason: str = "Test approval") -> None: 

238 """Approve a Comment using the moderation API. comment_id is the database id of the Comment.""" 

239 with real_moderation_session(self.token) as api: 

240 state_res = api.GetModerationState( 

241 moderation_pb2.GetModerationStateReq( 

242 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT, 

243 object_id=comment_id, 

244 ) 

245 ) 

246 api.ModerateContent( 

247 moderation_pb2.ModerateContentReq( 

248 moderation_state_id=state_res.moderation_state.moderation_state_id, 

249 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

250 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

251 reason=reason, 

252 ) 

253 ) 

254 

255 def approve_reply(self, reply_id: int, reason: str = "Test approval") -> None: 

256 """Approve a Reply using the moderation API. reply_id is the database id of the Reply.""" 

257 with real_moderation_session(self.token) as api: 

258 state_res = api.GetModerationState( 

259 moderation_pb2.GetModerationStateReq( 

260 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_REPLY, 

261 object_id=reply_id, 

262 ) 

263 ) 

264 api.ModerateContent( 

265 moderation_pb2.ModerateContentReq( 

266 moderation_state_id=state_res.moderation_state.moderation_state_id, 

267 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

268 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

269 reason=reason, 

270 ) 

271 ) 

272 

273 def approve_discussion(self, discussion_id: int, reason: str = "Test approval") -> None: 

274 """Approve a Discussion using the moderation API. discussion_id is the database id of the Discussion.""" 

275 with real_moderation_session(self.token) as api: 

276 state_res = api.GetModerationState( 

277 moderation_pb2.GetModerationStateReq( 

278 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION, 

279 object_id=discussion_id, 

280 ) 

281 ) 

282 api.ModerateContent( 

283 moderation_pb2.ModerateContentReq( 

284 moderation_state_id=state_res.moderation_state.moderation_state_id, 

285 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

286 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

287 reason=reason, 

288 ) 

289 ) 

290 

291 def approve_reference(self, reference_id: int, reason: str = "Test approval") -> None: 

292 """Approve a Reference using the moderation API.""" 

293 with real_moderation_session(self.token) as api: 

294 state_res = api.GetModerationState( 

295 moderation_pb2.GetModerationStateReq( 

296 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE, 

297 object_id=reference_id, 

298 ) 

299 ) 

300 api.ModerateContent( 

301 moderation_pb2.ModerateContentReq( 

302 moderation_state_id=state_res.moderation_state.moderation_state_id, 

303 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

304 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

305 reason=reason, 

306 ) 

307 ) 

308 

309 def approve_thread_post(self, packed_thread_id: int, reason: str = "Test approval") -> None: 

310 """Approve whichever of Comment/Reply the packed thread_id refers to.""" 

311 database_id, depth = unpack_thread_id(packed_thread_id) 

312 if depth == 1: 

313 self.approve_comment(database_id, reason=reason) 

314 elif depth == 2: 314 ↛ 317line 314 didn't jump to line 317 because the condition on line 314 was always true

315 self.approve_reply(database_id, reason=reason) 

316 else: 

317 raise ValueError(f"approve_thread_post: thread_id {packed_thread_id} has unsupported depth {depth}")