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

125 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +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 import query_log 

15from tests.fixtures.sessions import real_moderation_session 

16 

17 

18def process_jobs() -> None: 

19 # One span for the whole drain, not one per job type: Job is a frozen dataclass deriving its name and payload 

20 # type from the handler's __name__ and type hints, so wrapping handlers to name them breaks get_type_hints. 

21 # Splitting these out wants a span alongside the existing tracer span in worker.process_job. 

22 with query_log.span("job", "process_jobs"): 

23 while process_job(): 

24 pass 

25 

26 

27class EmailCollector: 

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

29 

30 def __init__(self) -> None: 

31 # Collected emails by recipient address, chronologically. 

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

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

34 

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

36 if payload.recipient not in self.by_recipient: 

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

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

39 

40 def __enter__(self): 

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

42 self.by_recipient.clear() 

43 self._patch.start() 

44 return self 

45 

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

47 self._patch.stop() 

48 return False # Let any exception propagate 

49 

50 def count(self) -> int: 

51 process_jobs() 

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

53 

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

55 process_jobs() 

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

57 

58 def count_for_mods(self) -> int: 

59 return self.count_for_recipient(config.MODS_EMAIL_RECIPIENT) 

60 

61 def count_for_reports(self) -> int: 

62 return self.count_for_recipient(config.REPORTS_EMAIL_RECIPIENT) 

63 

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

65 """ 

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

67 optionally asserting that it is the last one. 

68 """ 

69 process_jobs() 

70 emails = self.by_recipient.get(recipient) 

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

72 if last: 

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

74 return emails.pop(0) 

75 

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

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

78 

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

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

81 

82 

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

84class Push: 

85 topic_action: str 

86 content: PushNotificationContent 

87 key: str | None = None 

88 ttl: int | None = None 

89 

90 

91class PushCollector: 

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

93 

94 def __init__(self) -> None: 

95 # Collected notifications by user id, chronologically. 

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

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

98 

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

100 if user_id not in self.by_user: 

101 self.by_user[user_id] = [] 

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

103 

104 def __enter__(self): 

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

106 self.by_user.clear() 

107 self._patch.start() 

108 return self 

109 

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

111 self._patch.stop() 

112 return False # Let any exception propagate 

113 

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

115 process_jobs() 

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

117 

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

119 """ 

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

121 optionally asserting that it is the last one. 

122 """ 

123 process_jobs() 

124 pushes = self.by_user.get(user_id) 

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

126 if last: 

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

128 return pushes.pop(0) 

129 

130 

131class Moderator: 

132 """ 

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

134 

135 Usage: 

136 def test_example(db, moderator): 

137 user, token = generate_user() 

138 # ... create a host request ... 

139 moderator.approve_host_request(host_request_id) 

140 """ 

141 

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

143 self.user = user 

144 self.token = token 

145 

146 def set_visibility( 

147 self, 

148 object_type: moderation_pb2.ModerationObjectType.ValueType, 

149 object_id: int, 

150 visibility: moderation_pb2.ModerationVisibility.ValueType, 

151 reason: str = "Test moderation", 

152 ) -> None: 

153 """Move a piece of moderated content to the given visibility through the moderation API.""" 

154 raising = visibility in ( 

155 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

156 moderation_pb2.MODERATION_VISIBILITY_UNLISTED, 

157 ) 

158 with real_moderation_session(self.token) as api: 

159 state_res = api.GetModerationState( 

160 moderation_pb2.GetModerationStateReq(object_type=object_type, object_id=object_id) 

161 ) 

162 api.ModerateContent( 

163 moderation_pb2.ModerateContentReq( 

164 moderation_state_id=state_res.moderation_state.moderation_state_id, 

165 action=( 

166 moderation_pb2.MODERATION_ACTION_APPROVE if raising else moderation_pb2.MODERATION_ACTION_HIDE 

167 ), 

168 visibility=visibility, 

169 reason=reason, 

170 clear_flags=True, 

171 ) 

172 ) 

173 

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

175 """host_request_id is the conversation_id of the host request.""" 

176 self.set_visibility( 

177 moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

178 host_request_id, 

179 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

180 reason, 

181 ) 

182 

183 def hide_host_request(self, host_request_id: int, reason: str = "Test hide") -> None: 

184 """host_request_id is the conversation_id of the host request.""" 

185 self.set_visibility( 

186 moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

187 host_request_id, 

188 moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

189 reason, 

190 ) 

191 

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

193 """group_chat_id is the conversation_id of the group chat.""" 

194 self.set_visibility( 

195 moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

196 group_chat_id, 

197 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

198 reason, 

199 ) 

200 

201 def set_group_chat_visibility( 

202 self, 

203 group_chat_id: int, 

204 visibility: moderation_pb2.ModerationVisibility.ValueType, 

205 reason: str = "Test moderation", 

206 ) -> None: 

207 self.set_visibility(moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, group_chat_id, visibility, reason) 

208 

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

210 """friend_request_id is the FriendRelationship id.""" 

211 self.set_visibility( 

212 moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST, 

213 friend_request_id, 

214 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

215 reason, 

216 ) 

217 

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

219 """occurrence_id is the EventOccurrence id, which is what the proto calls event_id.""" 

220 self.set_visibility( 

221 moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE, 

222 occurrence_id, 

223 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

224 reason, 

225 ) 

226 

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

228 """comment_id is the database id of the Comment.""" 

229 self.set_visibility( 

230 moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT, 

231 comment_id, 

232 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

233 reason, 

234 ) 

235 

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

237 """reply_id is the database id of the Reply.""" 

238 self.set_visibility( 

239 moderation_pb2.MODERATION_OBJECT_TYPE_REPLY, 

240 reply_id, 

241 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

242 reason, 

243 ) 

244 

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

246 """discussion_id is the database id of the Discussion.""" 

247 self.set_visibility( 

248 moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION, 

249 discussion_id, 

250 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

251 reason, 

252 ) 

253 

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

255 self.set_visibility( 

256 moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE, 

257 reference_id, 

258 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

259 reason, 

260 ) 

261 

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

263 self.set_visibility( 

264 moderation_pb2.MODERATION_OBJECT_TYPE_PUBLIC_TRIP, 

265 public_trip_id, 

266 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

267 reason, 

268 ) 

269 

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

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

272 self.set_thread_post_visibility(packed_thread_id, moderation_pb2.MODERATION_VISIBILITY_VISIBLE, reason) 

273 

274 def set_thread_post_visibility( 

275 self, 

276 packed_thread_id: int, 

277 visibility: moderation_pb2.ModerationVisibility.ValueType, 

278 reason: str = "Test moderation", 

279 ) -> None: 

280 """Moderate whichever of Comment/Reply the packed thread_id refers to.""" 

281 database_id, depth = unpack_thread_id(packed_thread_id) 

282 if depth == 1: 

283 object_type = moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT 

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

285 object_type = moderation_pb2.MODERATION_OBJECT_TYPE_REPLY 

286 else: 

287 raise ValueError(f"thread_id {packed_thread_id} has unsupported depth {depth}") 

288 self.set_visibility(object_type, database_id, visibility, reason)