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

133 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1from dataclasses import dataclass 

2from datetime import datetime, timedelta 

3from typing import Any 

4from unittest.mock import patch 

5 

6from sqlalchemy.orm import Session 

7 

8from couchers.config import config 

9from couchers.jobs.worker import process_job 

10from couchers.models import User 

11from couchers.notifications.push import PushNotificationContent 

12from couchers.proto import moderation_pb2 

13from couchers.proto.internal import jobs_pb2 

14from couchers.servicers.threads import unpack_thread_id 

15from couchers.utils import now 

16from tests.fixtures.sessions import real_moderation_session 

17 

18 

19def process_jobs() -> None: 

20 while process_job(): 

21 pass 

22 

23 

24def now_5_min_in_future() -> datetime: 

25 return now() + timedelta(minutes=5) 

26 

27 

28class EmailCollector: 

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

30 

31 def __init__(self) -> None: 

32 # Collected emails by recipient address, chronologically. 

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

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

35 

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

37 if payload.recipient not in self.by_recipient: 

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

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

40 

41 def __enter__(self): 

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

43 self.by_recipient.clear() 

44 self._patch.start() 

45 return self 

46 

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

48 self._patch.stop() 

49 return False # Let any exception propagate 

50 

51 def count(self) -> int: 

52 process_jobs() 

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

54 

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

56 process_jobs() 

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

58 

59 def count_for_mods(self) -> int: 

60 return self.count_for_recipient(config.MODS_EMAIL_RECIPIENT) 

61 

62 def count_for_reports(self) -> int: 

63 return self.count_for_recipient(config.REPORTS_EMAIL_RECIPIENT) 

64 

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

66 """ 

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

68 optionally asserting that it is the last one. 

69 """ 

70 process_jobs() 

71 emails = self.by_recipient.get(recipient) 

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

73 if last: 

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

75 return emails.pop(0) 

76 

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

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

79 

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

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

82 

83 

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

85class Push: 

86 topic_action: str 

87 content: PushNotificationContent 

88 key: str | None = None 

89 ttl: int | None = None 

90 

91 

92class PushCollector: 

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

94 

95 def __init__(self) -> None: 

96 # Collected notifications by user id, chronologically. 

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

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

99 

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

101 if user_id not in self.by_user: 

102 self.by_user[user_id] = [] 

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

104 

105 def __enter__(self): 

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

107 self.by_user.clear() 

108 self._patch.start() 

109 return self 

110 

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

112 self._patch.stop() 

113 return False # Let any exception propagate 

114 

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

116 process_jobs() 

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

118 

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

120 """ 

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

122 optionally asserting that it is the last one. 

123 """ 

124 process_jobs() 

125 pushes = self.by_user.get(user_id) 

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

127 if last: 

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

129 return pushes.pop(0) 

130 

131 

132class Moderator: 

133 """ 

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

135 

136 Usage: 

137 def test_example(db, moderator): 

138 user, token = generate_user() 

139 # ... create a host request ... 

140 moderator.approve_host_request(host_request_id) 

141 """ 

142 

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

144 self.user = user 

145 self.token = token 

146 

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

148 """ 

149 Approve a host request using the moderation API. 

150 

151 Args: 

152 host_request_id: The conversation_id of the host request 

153 reason: Optional reason for approval 

154 """ 

155 with real_moderation_session(self.token) as api: 

156 state_res = api.GetModerationState( 

157 moderation_pb2.GetModerationStateReq( 

158 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

159 object_id=host_request_id, 

160 ) 

161 ) 

162 api.ModerateContent( 

163 moderation_pb2.ModerateContentReq( 

164 moderation_state_id=state_res.moderation_state.moderation_state_id, 

165 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

166 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

167 reason=reason, 

168 clear_flags=True, 

169 ) 

170 ) 

171 

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

173 """ 

174 Approve a group chat using the moderation API. 

175 

176 Args: 

177 group_chat_id: The conversation_id of the group chat 

178 reason: Optional reason for approval 

179 """ 

180 with real_moderation_session(self.token) as api: 

181 state_res = api.GetModerationState( 

182 moderation_pb2.GetModerationStateReq( 

183 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

184 object_id=group_chat_id, 

185 ) 

186 ) 

187 api.ModerateContent( 

188 moderation_pb2.ModerateContentReq( 

189 moderation_state_id=state_res.moderation_state.moderation_state_id, 

190 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

191 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

192 reason=reason, 

193 clear_flags=True, 

194 ) 

195 ) 

196 

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

198 """ 

199 Approve a friend request using the moderation API. 

200 

201 Args: 

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

203 reason: Optional reason for approval 

204 """ 

205 with real_moderation_session(self.token) as api: 

206 state_res = api.GetModerationState( 

207 moderation_pb2.GetModerationStateReq( 

208 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST, 

209 object_id=friend_request_id, 

210 ) 

211 ) 

212 api.ModerateContent( 

213 moderation_pb2.ModerateContentReq( 

214 moderation_state_id=state_res.moderation_state.moderation_state_id, 

215 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

216 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

217 reason=reason, 

218 clear_flags=True, 

219 ) 

220 ) 

221 

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

223 """ 

224 Approve an event occurrence using the moderation API. 

225 

226 Args: 

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

228 reason: Optional reason for approval 

229 """ 

230 with real_moderation_session(self.token) as api: 

231 state_res = api.GetModerationState( 

232 moderation_pb2.GetModerationStateReq( 

233 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE, 

234 object_id=occurrence_id, 

235 ) 

236 ) 

237 api.ModerateContent( 

238 moderation_pb2.ModerateContentReq( 

239 moderation_state_id=state_res.moderation_state.moderation_state_id, 

240 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

241 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

242 reason=reason, 

243 clear_flags=True, 

244 ) 

245 ) 

246 

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

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

249 with real_moderation_session(self.token) as api: 

250 state_res = api.GetModerationState( 

251 moderation_pb2.GetModerationStateReq( 

252 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT, 

253 object_id=comment_id, 

254 ) 

255 ) 

256 api.ModerateContent( 

257 moderation_pb2.ModerateContentReq( 

258 moderation_state_id=state_res.moderation_state.moderation_state_id, 

259 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

260 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

261 reason=reason, 

262 clear_flags=True, 

263 ) 

264 ) 

265 

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

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

268 with real_moderation_session(self.token) as api: 

269 state_res = api.GetModerationState( 

270 moderation_pb2.GetModerationStateReq( 

271 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_REPLY, 

272 object_id=reply_id, 

273 ) 

274 ) 

275 api.ModerateContent( 

276 moderation_pb2.ModerateContentReq( 

277 moderation_state_id=state_res.moderation_state.moderation_state_id, 

278 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

279 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

280 reason=reason, 

281 clear_flags=True, 

282 ) 

283 ) 

284 

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

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

287 with real_moderation_session(self.token) as api: 

288 state_res = api.GetModerationState( 

289 moderation_pb2.GetModerationStateReq( 

290 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION, 

291 object_id=discussion_id, 

292 ) 

293 ) 

294 api.ModerateContent( 

295 moderation_pb2.ModerateContentReq( 

296 moderation_state_id=state_res.moderation_state.moderation_state_id, 

297 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

298 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

299 reason=reason, 

300 clear_flags=True, 

301 ) 

302 ) 

303 

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

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

306 with real_moderation_session(self.token) as api: 

307 state_res = api.GetModerationState( 

308 moderation_pb2.GetModerationStateReq( 

309 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE, 

310 object_id=reference_id, 

311 ) 

312 ) 

313 api.ModerateContent( 

314 moderation_pb2.ModerateContentReq( 

315 moderation_state_id=state_res.moderation_state.moderation_state_id, 

316 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

317 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

318 reason=reason, 

319 clear_flags=True, 

320 ) 

321 ) 

322 

323 def approve_public_trip(self, public_trip_id: int, reason: str = "Test approval") -> None: 

324 """Approve a PublicTrip using the moderation API.""" 

325 with real_moderation_session(self.token) as api: 

326 state_res = api.GetModerationState( 

327 moderation_pb2.GetModerationStateReq( 

328 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_PUBLIC_TRIP, 

329 object_id=public_trip_id, 

330 ) 

331 ) 

332 api.ModerateContent( 

333 moderation_pb2.ModerateContentReq( 

334 moderation_state_id=state_res.moderation_state.moderation_state_id, 

335 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

336 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

337 reason=reason, 

338 clear_flags=True, 

339 ) 

340 ) 

341 

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

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

344 database_id, depth = unpack_thread_id(packed_thread_id) 

345 if depth == 1: 

346 self.approve_comment(database_id, reason=reason) 

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

348 self.approve_reply(database_id, reason=reason) 

349 else: 

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