Coverage for app/backend/src/tests/test_bg_jobs.py: 99%

894 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1from datetime import date, timedelta 

2from typing import Any 

3from unittest.mock import call, patch 

4 

5import pytest 

6import requests 

7from google.protobuf import empty_pb2 

8from google.protobuf.empty_pb2 import Empty 

9from prometheus_client import REGISTRY 

10from sqlalchemy import select, text 

11from sqlalchemy.sql import delete, func 

12 

13import couchers.jobs.worker 

14from couchers import experimentation 

15from couchers.config import config 

16from couchers.constants import ( 

17 HOST_REQUEST_MAX_REMINDERS, 

18 HOST_REQUEST_REMINDER_INTERVAL, 

19 MISSED_MESSAGES_DELAY, 

20 MISSED_MESSAGES_DELAY_WITH_PUSH, 

21) 

22from couchers.crypto import urlsafe_secure_token 

23from couchers.db import session_scope 

24from couchers.email.dev import print_dev_email 

25from couchers.email.queuing import queue_email 

26from couchers.jobs import handlers 

27from couchers.jobs.definitions import Job 

28from couchers.jobs.enqueue import queue_job 

29from couchers.jobs.handlers import ( 

30 add_users_to_email_list, 

31 enforce_community_membership, 

32 purge_account_deletion_tokens, 

33 purge_login_tokens, 

34 purge_password_reset_tokens, 

35 send_host_request_reminders, 

36 send_message_notifications, 

37 send_onboarding_emails, 

38 send_reference_reminders, 

39 send_request_notifications, 

40 update_badges, 

41 update_recommendation_scores, 

42) 

43from couchers.jobs.worker import _run_job_and_schedule, process_job, run_scheduler, service_jobs 

44from couchers.materialized_views import refresh_materialized_views 

45from couchers.metrics import create_prometheus_server 

46from couchers.models import ( 

47 AccountDeletionToken, 

48 BackgroundJob, 

49 BackgroundJobState, 

50 DeviceType, 

51 Email, 

52 HostingStatus, 

53 HostRequest, 

54 HostRequestStatus, 

55 LoginToken, 

56 Message, 

57 MessageType, 

58 PasswordResetToken, 

59 PostalVerificationAttempt, 

60 PostalVerificationStatus, 

61 PushNotificationPlatform, 

62 PushNotificationSubscription, 

63 User, 

64 UserBadge, 

65 UserBlock, 

66 Volunteer, 

67) 

68from couchers.proto import conversations_pb2, messages_pb2, requests_pb2 

69from couchers.proto.internal import jobs_pb2 

70from couchers.utils import now, today 

71from tests.fixtures.db import generate_user, make_friends, make_user_block, make_volunteer 

72from tests.fixtures.misc import PushCollector, process_jobs 

73from tests.fixtures.sessions import conversations_session, requests_session 

74from tests.fixtures.timewarp import Timewarp 

75from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date 

76from tests.test_requests import valid_request_text 

77 

78 

79def _add_mobile_push_subscription(user_id: int, *, disabled: bool = False) -> None: 

80 with session_scope() as session: 

81 sub = PushNotificationSubscription( 

82 user_id=user_id, 

83 platform=PushNotificationPlatform.expo, 

84 token=f"ExponentPushToken[{user_id}]", 

85 device_name="Test phone", 

86 device_type=DeviceType.ios, 

87 ) 

88 session.add(sub) 

89 if disabled: 

90 session.flush() 

91 sub.disabled_at = now() 

92 

93 

94def _count_queued_emails() -> int: 

95 with session_scope() as session: 

96 return session.execute( 

97 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

98 ).scalar_one() 

99 

100 

101def _check_job_counter(port, job, status, attempt, exception): 

102 metrics_string = requests.get(f"http://localhost:{port}").text 

103 string_to_check = f'attempt="{attempt}",exception="{exception}",job="{job}",status="{status}"' 

104 assert string_to_check in metrics_string 

105 

106 

107def _queued_seconds_sum(): 

108 total = 0.0 

109 for metric in REGISTRY.collect(): 

110 if metric.name == "couchers_background_jobs_queued_seconds": 

111 total += sum(sample.value for sample in metric.samples if sample.name.endswith("_sum")) 

112 return total 

113 

114 

115def test_email_job(db): 

116 with session_scope() as session: 

117 queue_email( 

118 session, 

119 jobs_pb2.SendEmailPayload( 

120 sender_name="sender_name", 

121 sender_email="sender_email", 

122 recipient="recipient", 

123 subject="subject", 

124 plain="plain", 

125 html="html", 

126 ), 

127 ) 

128 

129 def mock_print_dev_email(payload): 

130 assert payload.sender_name == "sender_name" 

131 assert payload.sender_email == "sender_email" 

132 assert payload.recipient == "recipient" 

133 assert payload.subject == "subject" 

134 assert payload.plain == "plain" 

135 assert payload.html == "html" 

136 return print_dev_email(payload) 

137 

138 with patch("couchers.jobs.handlers.print_dev_email", mock_print_dev_email): 

139 process_job() 

140 

141 with session_scope() as session: 

142 assert ( 

143 session.execute( 

144 select(func.count()) 

145 .select_from(BackgroundJob) 

146 .where(BackgroundJob.state == BackgroundJobState.completed) 

147 ).scalar_one() 

148 == 1 

149 ) 

150 assert ( 

151 session.execute( 

152 select(func.count()) 

153 .select_from(BackgroundJob) 

154 .where(BackgroundJob.state != BackgroundJobState.completed) 

155 ).scalar_one() 

156 == 0 

157 ) 

158 

159 

160def test_purge_login_tokens(db): 

161 user, api_token = generate_user() 

162 

163 with session_scope() as session: 

164 login_token = LoginToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now()) 

165 session.add(login_token) 

166 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 1 

167 

168 queue_job(session, job=purge_login_tokens, payload=empty_pb2.Empty()) 

169 process_job() 

170 

171 with session_scope() as session: 

172 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 0 

173 

174 with session_scope() as session: 

175 assert ( 

176 session.execute( 

177 select(func.count()) 

178 .select_from(BackgroundJob) 

179 .where(BackgroundJob.state == BackgroundJobState.completed) 

180 ).scalar_one() 

181 == 1 

182 ) 

183 assert ( 

184 session.execute( 

185 select(func.count()) 

186 .select_from(BackgroundJob) 

187 .where(BackgroundJob.state != BackgroundJobState.completed) 

188 ).scalar_one() 

189 == 0 

190 ) 

191 

192 

193def test_purge_password_reset_tokens(db): 

194 user, api_token = generate_user() 

195 

196 with session_scope() as session: 

197 password_reset_token = PasswordResetToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now()) 

198 session.add(password_reset_token) 

199 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 1 

200 

201 queue_job(session, job=purge_password_reset_tokens, payload=empty_pb2.Empty()) 

202 process_job() 

203 

204 with session_scope() as session: 

205 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 0 

206 

207 with session_scope() as session: 

208 assert ( 

209 session.execute( 

210 select(func.count()) 

211 .select_from(BackgroundJob) 

212 .where(BackgroundJob.state == BackgroundJobState.completed) 

213 ).scalar_one() 

214 == 1 

215 ) 

216 assert ( 

217 session.execute( 

218 select(func.count()) 

219 .select_from(BackgroundJob) 

220 .where(BackgroundJob.state != BackgroundJobState.completed) 

221 ).scalar_one() 

222 == 0 

223 ) 

224 

225 

226def test_purge_account_deletion_tokens(db): 

227 user, api_token = generate_user() 

228 user2, api_token2 = generate_user() 

229 user3, api_token3 = generate_user() 

230 

231 with session_scope() as session: 

232 """ 

233 3 cases: 

234 1) Token is valid 

235 2) Token expired but account retrievable 

236 3) Account is irretrievable (and expired) 

237 """ 

238 account_deletion_tokens = [ 

239 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() - timedelta(hours=2)), 

240 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user2.id, expiry=now()), 

241 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user3.id, expiry=now() + timedelta(hours=5)), 

242 ] 

243 for token in account_deletion_tokens: 

244 session.add(token) 

245 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 3 

246 

247 queue_job(session, job=purge_account_deletion_tokens, payload=empty_pb2.Empty()) 

248 process_job() 

249 

250 with session_scope() as session: 

251 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 1 

252 

253 with session_scope() as session: 

254 assert ( 

255 session.execute( 

256 select(func.count()) 

257 .select_from(BackgroundJob) 

258 .where(BackgroundJob.state == BackgroundJobState.completed) 

259 ).scalar_one() 

260 == 1 

261 ) 

262 assert ( 

263 session.execute( 

264 select(func.count()) 

265 .select_from(BackgroundJob) 

266 .where(BackgroundJob.state != BackgroundJobState.completed) 

267 ).scalar_one() 

268 == 0 

269 ) 

270 

271 

272def test_enforce_community_memberships(db): 

273 with session_scope() as session: 

274 queue_job(session, job=enforce_community_membership, payload=empty_pb2.Empty()) 

275 process_job() 

276 

277 with session_scope() as session: 

278 assert ( 

279 session.execute( 

280 select(func.count()) 

281 .select_from(BackgroundJob) 

282 .where(BackgroundJob.state == BackgroundJobState.completed) 

283 ).scalar_one() 

284 == 1 

285 ) 

286 assert ( 

287 session.execute( 

288 select(func.count()) 

289 .select_from(BackgroundJob) 

290 .where(BackgroundJob.state != BackgroundJobState.completed) 

291 ).scalar_one() 

292 == 0 

293 ) 

294 

295 

296def test_refresh_materialized_views(db): 

297 with session_scope() as session: 

298 queue_job(session, job=refresh_materialized_views, payload=empty_pb2.Empty()) 

299 

300 process_job() 

301 

302 with session_scope() as session: 

303 assert ( 

304 session.execute( 

305 select(func.count()) 

306 .select_from(BackgroundJob) 

307 .where(BackgroundJob.state == BackgroundJobState.completed) 

308 ).scalar_one() 

309 == 1 

310 ) 

311 assert ( 

312 session.execute( 

313 select(func.count()) 

314 .select_from(BackgroundJob) 

315 .where(BackgroundJob.state != BackgroundJobState.completed) 

316 ).scalar_one() 

317 == 0 

318 ) 

319 

320 

321def test_service_jobs(db): 

322 with session_scope() as session: 

323 queue_email( 

324 session, 

325 jobs_pb2.SendEmailPayload( 

326 sender_name="sender_name", 

327 sender_email="sender_email", 

328 recipient="recipient", 

329 subject="subject", 

330 plain="plain", 

331 html="html", 

332 ), 

333 ) 

334 

335 # we create this HitSleep exception here, and mock out the normal sleep(1) in the infinite loop to instead raise 

336 # this. that allows us to conveniently get out of the infinite loop and know we had no more jobs left 

337 class HitSleep(Exception): 

338 pass 

339 

340 # the mock `sleep` function that instead raises the aforementioned exception 

341 def raising_sleep(seconds): 

342 raise HitSleep() 

343 

344 with pytest.raises(HitSleep): 

345 with patch("couchers.jobs.worker.sleep", raising_sleep): 

346 service_jobs() 

347 

348 with session_scope() as session: 

349 assert ( 

350 session.execute( 

351 select(func.count()) 

352 .select_from(BackgroundJob) 

353 .where(BackgroundJob.state == BackgroundJobState.completed) 

354 ).scalar_one() 

355 == 1 

356 ) 

357 assert ( 

358 session.execute( 

359 select(func.count()) 

360 .select_from(BackgroundJob) 

361 .where(BackgroundJob.state != BackgroundJobState.completed) 

362 ).scalar_one() 

363 == 0 

364 ) 

365 

366 

367def test_scheduler(db, monkeypatch): 

368 def purge_login_tokens(payload: empty_pb2.Empty): 

369 return 

370 

371 def send_message_notifications(payload: empty_pb2.Empty): 

372 return 

373 

374 MOCK_JOBS = { 

375 "purge_login_tokens": Job(purge_login_tokens, timedelta(seconds=7)), 

376 "send_message_notifications": Job(send_message_notifications, timedelta(seconds=11)), 

377 } 

378 

379 current_time = 0 

380 end_time = 70 

381 

382 class EndOfTime(Exception): 

383 pass 

384 

385 def mock_monotonic(): 

386 return current_time 

387 

388 def mock_sleep(seconds): 

389 nonlocal current_time 

390 current_time += seconds 

391 if current_time > end_time: 

392 raise EndOfTime() 

393 

394 realized_schedule = [] 

395 

396 def mock_run_job_and_schedule(sched, job: Job[Any], frequency: timedelta) -> None: 

397 realized_schedule.append((current_time, job.name)) 

398 _run_job_and_schedule(sched, job, frequency) 

399 

400 monkeypatch.setattr(couchers.jobs.worker, "_run_job_and_schedule", mock_run_job_and_schedule) 

401 monkeypatch.setattr(couchers.jobs.worker, "JOBS", MOCK_JOBS) 

402 monkeypatch.setattr(couchers.jobs.worker, "monotonic", mock_monotonic) 

403 monkeypatch.setattr(couchers.jobs.worker, "sleep", mock_sleep) 

404 

405 with pytest.raises(EndOfTime): 

406 run_scheduler() 

407 

408 # Convert to job indices for comparison (to maintain test compatibility) 

409 job_order = ["purge_login_tokens", "send_message_notifications"] 

410 realized_schedule_indices = [(time, job_order.index(job_name)) for time, job_name in realized_schedule] 

411 

412 assert realized_schedule_indices == [ 

413 (0.0, 0), 

414 (0.0, 1), 

415 (7.0, 0), 

416 (11.0, 1), 

417 (14.0, 0), 

418 (21.0, 0), 

419 (22.0, 1), 

420 (28.0, 0), 

421 (33.0, 1), 

422 (35.0, 0), 

423 (42.0, 0), 

424 (44.0, 1), 

425 (49.0, 0), 

426 (55.0, 1), 

427 (56.0, 0), 

428 (63.0, 0), 

429 (66.0, 1), 

430 (70.0, 0), 

431 ] 

432 

433 with session_scope() as session: 

434 assert ( 

435 session.execute( 

436 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.pending) 

437 ).scalar_one() 

438 == 18 

439 ) 

440 assert ( 

441 session.execute( 

442 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.pending) 

443 ).scalar_one() 

444 == 0 

445 ) 

446 

447 

448def test_job_retry(db): 

449 called_count = 0 

450 

451 def mock_job(payload: empty_pb2.Empty) -> None: 

452 nonlocal called_count 

453 called_count += 1 

454 raise Exception() 

455 

456 with session_scope() as session: 

457 queue_job(session, job=mock_job, payload=empty_pb2.Empty()) 

458 

459 MOCK_JOBS: dict[str, Job[Any]] = { 

460 "mock_job": Job(mock_job), 

461 } 

462 # port 0 so parallel test runs don't fight over the port 

463 metrics_server = create_prometheus_server(port=0) 

464 

465 # if IN_TEST is true, then the bg worker will raise on exceptions 

466 config.IN_TEST = False 

467 

468 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS): 

469 process_job() 

470 with session_scope() as session: 

471 assert ( 

472 session.execute( 

473 select(func.count()) 

474 .select_from(BackgroundJob) 

475 .where(BackgroundJob.state == BackgroundJobState.error) 

476 ).scalar_one() 

477 == 1 

478 ) 

479 assert ( 

480 session.execute( 

481 select(func.count()) 

482 .select_from(BackgroundJob) 

483 .where(BackgroundJob.state != BackgroundJobState.error) 

484 ).scalar_one() 

485 == 0 

486 ) 

487 

488 job = session.execute(select(BackgroundJob)).scalar_one() 

489 assert job.next_attempt_after > now() + timedelta(seconds=25) 

490 

491 job.next_attempt_after = func.now() 

492 process_job() 

493 with session_scope() as session: 

494 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now() 

495 process_job() 

496 with session_scope() as session: 

497 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now() 

498 process_job() 

499 with session_scope() as session: 

500 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now() 

501 process_job() 

502 

503 with session_scope() as session: 

504 assert ( 

505 session.execute( 

506 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.failed) 

507 ).scalar_one() 

508 == 1 

509 ) 

510 assert ( 

511 session.execute( 

512 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.failed) 

513 ).scalar_one() 

514 == 0 

515 ) 

516 

517 _check_job_counter(metrics_server.server_port, "mock_job", "error", "4", "Exception") 

518 _check_job_counter(metrics_server.server_port, "mock_job", "failed", "5", "Exception") 

519 metrics_server.shutdown() 

520 metrics_server.server_close() 

521 

522 

523def test_job_retry_backs_off_from_now_not_from_a_stale_next_attempt_after(db): 

524 def mock_job(payload: empty_pb2.Empty) -> None: 

525 raise Exception() 

526 

527 MOCK_JOBS: dict[str, Job[Any]] = {"mock_job": Job(mock_job)} 

528 

529 with session_scope() as session: 

530 queue_job(session, job=mock_job, payload=empty_pb2.Empty()) 

531 session.flush() 

532 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = now() - timedelta(hours=1) 

533 

534 config.IN_TEST = False 

535 

536 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS): 

537 assert process_job() 

538 

539 with session_scope() as session: 

540 job = session.execute(select(BackgroundJob)).scalar_one() 

541 assert job.state == BackgroundJobState.error 

542 assert job.try_count == 1 

543 assert now() + timedelta(seconds=25) < job.next_attempt_after < now() + timedelta(seconds=35) 

544 

545 assert not process_job() 

546 

547 

548def test_job_queued_latency_excludes_retry_backoff(db): 

549 def mock_job(payload: empty_pb2.Empty) -> None: 

550 pass 

551 

552 MOCK_JOBS: dict[str, Job[Any]] = {"mock_job": Job(mock_job)} 

553 

554 with session_scope() as session: 

555 queue_job(session, job=mock_job, payload=empty_pb2.Empty()) 

556 session.flush() 

557 job = session.execute(select(BackgroundJob)).scalar_one() 

558 job.queued = now() - timedelta(hours=1) 

559 job.next_attempt_after = now() 

560 

561 before = _queued_seconds_sum() 

562 

563 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS): 

564 assert process_job() 

565 

566 assert _queued_seconds_sum() - before < 60 

567 

568 

569def test_job_dequeue_steps_over_other_workers_jobs(db): 

570 handled = [] 

571 

572 def mock_job(payload: jobs_pb2.SendEmailPayload) -> None: 

573 handled.append(payload.subject) 

574 

575 MOCK_JOBS: dict[str, Job[Any]] = {"mock_job": Job(mock_job)} 

576 

577 with session_scope() as session: 

578 for subject in ["first", "second", "third"]: 

579 queue_job(session, job=mock_job, payload=jobs_pb2.SendEmailPayload(subject=subject)) 

580 session.flush() 

581 # queued in one transaction, so they'd otherwise all share a next_attempt_after and tie in the ordering 

582 for i, job in enumerate(session.execute(select(BackgroundJob).order_by(BackgroundJob.id)).scalars()): 

583 job.next_attempt_after = now() - timedelta(seconds=3 - i) 

584 

585 # another worker already finished "first" and committed 

586 with session_scope() as session: 

587 finished = ( 

588 session.execute(select(BackgroundJob).order_by(BackgroundJob.next_attempt_after).limit(1)).scalars().one() 

589 ) 

590 finished.state = BackgroundJobState.completed 

591 

592 with session_scope() as holder: 

593 # another worker is holding "second", mid-flight 

594 held = ( 

595 holder.execute( 

596 select(BackgroundJob) 

597 .where(BackgroundJob.ready_for_retry) 

598 .order_by(BackgroundJob.next_attempt_after) 

599 .limit(1) 

600 .with_for_update(skip_locked=True) 

601 ) 

602 .scalars() 

603 .one() 

604 ) 

605 assert held.payload == jobs_pb2.SendEmailPayload(subject="second").SerializeToString() 

606 

607 # the dequeue must run at READ COMMITTED: under a stricter isolation level it can't follow the update chain of 

608 # the row the other worker just completed, and aborts the whole transaction rather than stepping over it 

609 assert holder.execute(text("show transaction_isolation")).scalar_one() == "read committed" 

610 

611 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS): 

612 assert process_job() 

613 

614 assert handled == ["third"] 

615 

616 

617def test_no_jobs_no_problem(db): 

618 with session_scope() as session: 

619 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0 

620 

621 assert not process_job() 

622 

623 with session_scope() as session: 

624 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0 

625 

626 

627def test_send_message_notifications_basic(db, moderator, timewarp: Timewarp): 

628 user1, token1 = generate_user() 

629 user2, token2 = generate_user() 

630 user3, token3 = generate_user() 

631 

632 make_friends(user1, user2) 

633 make_friends(user1, user3) 

634 make_friends(user2, user3) 

635 

636 send_message_notifications(empty_pb2.Empty()) 

637 process_jobs() 

638 

639 # should find no jobs, since there's no messages 

640 with session_scope() as session: 

641 assert ( 

642 session.execute( 

643 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

644 ).scalar_one() 

645 == 0 

646 ) 

647 

648 with conversations_session(token1) as c: 

649 group_chat_id1 = c.CreateGroupChat( 

650 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id]) 

651 ).group_chat_id 

652 moderator.approve_group_chat(group_chat_id1) 

653 

654 with conversations_session(token1) as c: 

655 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 1")) 

656 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 2")) 

657 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 3")) 

658 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 4")) 

659 

660 with conversations_session(token3) as c: 

661 group_chat_id2 = c.CreateGroupChat( 

662 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]) 

663 ).group_chat_id 

664 moderator.approve_group_chat(group_chat_id2) 

665 

666 with conversations_session(token3) as c: 

667 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 5")) 

668 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 6")) 

669 

670 send_message_notifications(empty_pb2.Empty()) 

671 process_jobs() 

672 

673 # no emails sent out 

674 with session_scope() as session: 

675 assert ( 

676 session.execute( 

677 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

678 ).scalar_one() 

679 == 0 

680 ) 

681 

682 timewarp.advance(MISSED_MESSAGES_DELAY) 

683 

684 # this should generate emails for both user2 and user3 

685 send_message_notifications(empty_pb2.Empty()) 

686 process_jobs() 

687 

688 with session_scope() as session: 

689 assert ( 

690 session.execute( 

691 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

692 ).scalar_one() 

693 == 2 

694 ) 

695 # delete them all 

696 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

697 

698 # shouldn't generate any more emails 

699 send_message_notifications(empty_pb2.Empty()) 

700 process_jobs() 

701 

702 with session_scope() as session: 

703 assert ( 

704 session.execute( 

705 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

706 ).scalar_one() 

707 == 0 

708 ) 

709 

710 

711def test_send_message_notifications_ignores_abandoned_subscription(db, moderator, timewarp: Timewarp): 

712 """ 

713 Rejoining a chat leaves the earlier subscription behind with its own last-seen state, and the job 

714 used to notify off that stale one even once the user had caught up on their newest subscription. 

715 """ 

716 user1, token1 = generate_user() 

717 user2, token2 = generate_user() 

718 user3, token3 = generate_user() 

719 

720 make_friends(user1, user2) 

721 make_friends(user1, user3) 

722 make_friends(user2, user3) 

723 

724 with conversations_session(token1) as c: 

725 group_chat_id = c.CreateGroupChat( 

726 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id]) 

727 ).group_chat_id 

728 moderator.approve_group_chat(group_chat_id) 

729 

730 with conversations_session(token1) as c: 

731 # unread and un-notified for user2 when they get removed below 

732 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1")) 

733 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2")) 

734 c.RemoveGroupChatUser(conversations_pb2.RemoveGroupChatUserReq(group_chat_id=group_chat_id, user_id=user2.id)) 

735 c.InviteToGroupChat(conversations_pb2.InviteToGroupChatReq(group_chat_id=group_chat_id, user_id=user2.id)) 

736 

737 for token in [token2, token3]: 

738 with conversations_session(token) as c: 

739 latest_message_id = c.GetGroupChat( 

740 conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id) 

741 ).latest_message.message_id 

742 c.MarkLastSeenGroupChat( 

743 conversations_pb2.MarkLastSeenGroupChatReq( 

744 group_chat_id=group_chat_id, last_seen_message_id=latest_message_id 

745 ) 

746 ) 

747 

748 timewarp.advance(MISSED_MESSAGES_DELAY) 

749 

750 send_message_notifications(empty_pb2.Empty()) 

751 process_jobs() 

752 

753 with session_scope() as session: 

754 assert ( 

755 session.execute( 

756 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

757 ).scalar_one() 

758 == 0 

759 ) 

760 

761 

762def test_send_message_notifications_muted(db, moderator, timewarp: Timewarp): 

763 user1, token1 = generate_user() 

764 user2, token2 = generate_user() 

765 user3, token3 = generate_user() 

766 

767 make_friends(user1, user2) 

768 make_friends(user1, user3) 

769 make_friends(user2, user3) 

770 

771 send_message_notifications(empty_pb2.Empty()) 

772 process_jobs() 

773 

774 # should find no jobs, since there's no messages 

775 with session_scope() as session: 

776 assert ( 

777 session.execute( 

778 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

779 ).scalar_one() 

780 == 0 

781 ) 

782 

783 with conversations_session(token1) as c: 

784 group_chat_id = c.CreateGroupChat( 

785 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id]) 

786 ).group_chat_id 

787 moderator.approve_group_chat(group_chat_id) 

788 

789 with conversations_session(token3) as c: 

790 # mute it for user 3 

791 c.MuteGroupChat(conversations_pb2.MuteGroupChatReq(group_chat_id=group_chat_id, forever=True)) 

792 

793 with conversations_session(token1) as c: 

794 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1")) 

795 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2")) 

796 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3")) 

797 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4")) 

798 

799 with conversations_session(token3) as c: 

800 group_chat_id = c.CreateGroupChat( 

801 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]) 

802 ).group_chat_id 

803 moderator.approve_group_chat(group_chat_id) 

804 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 5")) 

805 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 6")) 

806 

807 send_message_notifications(empty_pb2.Empty()) 

808 process_jobs() 

809 

810 # no emails sent out 

811 with session_scope() as session: 

812 assert ( 

813 session.execute( 

814 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

815 ).scalar_one() 

816 == 0 

817 ) 

818 

819 timewarp.advance(MISSED_MESSAGES_DELAY) 

820 

821 # this should generate emails for both user2 and NOT user3 

822 send_message_notifications(empty_pb2.Empty()) 

823 process_jobs() 

824 

825 with session_scope() as session: 

826 assert ( 

827 session.execute( 

828 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

829 ).scalar_one() 

830 == 1 

831 ) 

832 # delete them all 

833 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

834 

835 # shouldn't generate any more emails 

836 send_message_notifications(empty_pb2.Empty()) 

837 process_jobs() 

838 

839 with session_scope() as session: 

840 assert ( 

841 session.execute( 

842 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

843 ).scalar_one() 

844 == 0 

845 ) 

846 

847 

848def _send_one_chat_message(token: str, recipient_id: int, moderator) -> None: 

849 with conversations_session(token) as c: 

850 group_chat_id = c.CreateGroupChat( 

851 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[recipient_id]) 

852 ).group_chat_id 

853 moderator.approve_group_chat(group_chat_id) 

854 

855 with conversations_session(token) as c: 

856 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1")) 

857 

858 with session_scope() as session: 

859 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

860 

861 

862def test_send_message_notifications_delayed_for_push_capable_users( 

863 db, moderator, push_collector: PushCollector, timewarp: Timewarp 

864): 

865 user1, token1 = generate_user() 

866 user2, token2 = generate_user() 

867 make_friends(user1, user2) 

868 _add_mobile_push_subscription(user2.id) 

869 

870 _send_one_chat_message(token1, user2.id, moderator) 

871 

872 # user2 already got a push about this, so the usual 5 minute delay doesn't apply to them 

873 timewarp.advance(MISSED_MESSAGES_DELAY) 

874 send_message_notifications(empty_pb2.Empty()) 

875 process_jobs() 

876 assert _count_queued_emails() == 0 

877 

878 timewarp.advance(MISSED_MESSAGES_DELAY_WITH_PUSH) 

879 send_message_notifications(empty_pb2.Empty()) 

880 process_jobs() 

881 assert _count_queued_emails() == 1 

882 

883 

884def test_send_message_notifications_not_delayed_when_push_subscription_disabled( 

885 db, moderator, push_collector: PushCollector, timewarp: Timewarp 

886): 

887 user1, token1 = generate_user() 

888 user2, token2 = generate_user() 

889 make_friends(user1, user2) 

890 # e.g. the device unregistered, so we can't reach user2 by push and the email is all they'll get 

891 _add_mobile_push_subscription(user2.id, disabled=True) 

892 

893 _send_one_chat_message(token1, user2.id, moderator) 

894 

895 timewarp.advance(MISSED_MESSAGES_DELAY) 

896 send_message_notifications(empty_pb2.Empty()) 

897 process_jobs() 

898 assert _count_queued_emails() == 1 

899 

900 

901def test_send_request_notifications_host_request(db, moderator, timewarp: Timewarp): 

902 user1, token1 = generate_user() 

903 user2, token2 = generate_user() 

904 

905 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

906 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

907 

908 send_request_notifications(empty_pb2.Empty()) 

909 process_jobs() 

910 

911 # should find no jobs, since there's no messages 

912 with session_scope() as session: 

913 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0 

914 

915 with requests_session(token1) as requests: 

916 host_request_id = requests.CreateHostRequest( 

917 requests_pb2.CreateHostRequestReq( 

918 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text() 

919 ) 

920 ).host_request_id 

921 moderator.approve_host_request(host_request_id) 

922 

923 with session_scope() as session: 

924 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

925 

926 timewarp.advance(MISSED_MESSAGES_DELAY) 

927 

928 # the only unseen message is the creation message, which the host was already 

929 # notified about via host_request__create — no missed_messages email 

930 send_request_notifications(empty_pb2.Empty()) 

931 process_jobs() 

932 assert _count_queued_emails() == 0 

933 

934 # test that responding to host request creates email 

935 with requests_session(token2) as requests: 

936 requests.RespondHostRequest( 

937 requests_pb2.RespondHostRequestReq( 

938 host_request_id=host_request_id, 

939 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

940 text="Test request", 

941 ) 

942 ) 

943 

944 with session_scope() as session: 

945 # delete send_email BackgroundJob created by RespondHostRequest 

946 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

947 

948 timewarp.advance(MISSED_MESSAGES_DELAY) 

949 

950 # check send_request_notifications successfully creates background job 

951 send_request_notifications(empty_pb2.Empty()) 

952 process_jobs() 

953 assert _count_queued_emails() == 1 

954 

955 with session_scope() as session: 

956 # delete all BackgroundJobs 

957 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

958 

959 send_request_notifications(empty_pb2.Empty()) 

960 process_jobs() 

961 # should find no messages since guest has already been notified 

962 assert _count_queued_emails() == 0 

963 

964 

965def test_send_request_notifications_host_request_with_followup(db, moderator, timewarp: Timewarp): 

966 """ 

967 When the surfer sends a follow-up message after creating the host request, 

968 the host should get a missed_messages notification (even though the initial 

969 creation message alone would be skipped). 

970 """ 

971 user1, token1 = generate_user() 

972 user2, token2 = generate_user() 

973 

974 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

975 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

976 

977 with requests_session(token1) as requests: 

978 host_request_id = requests.CreateHostRequest( 

979 requests_pb2.CreateHostRequestReq( 

980 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text() 

981 ) 

982 ).host_request_id 

983 moderator.approve_host_request(host_request_id) 

984 

985 # surfer sends a follow-up message 

986 with requests_session(token1) as requests: 

987 requests.SendHostRequestMessage( 

988 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_id, text="Following up on my request!") 

989 ) 

990 

991 with session_scope() as session: 

992 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

993 

994 timewarp.advance(MISSED_MESSAGES_DELAY) 

995 

996 # now there are two unseen text messages for the host, so missed_messages should fire 

997 send_request_notifications(empty_pb2.Empty()) 

998 process_jobs() 

999 assert _count_queued_emails() == 1 

1000 

1001 

1002def test_send_request_notifications_two_requests_one_with_followup(db, moderator, timewarp: Timewarp): 

1003 """ 

1004 A host (user2) receives two requests: first from user1 (with a follow-up message), 

1005 then from user3 (creation only). Because request B is created after request A's 

1006 follow-up, it has a higher message ID. If the background job processes B first and 

1007 advances last_notified_request_message_id past A's messages, one might expect A's 

1008 notification to be lost — but it isn't, because the query results are already 

1009 materialized before the loop begins. 

1010 """ 

1011 user1, token1 = generate_user() 

1012 user2, token2 = generate_user() 

1013 user3, token3 = generate_user() 

1014 

1015 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1016 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1017 

1018 # request A: user1 -> user2, with a follow-up 

1019 with requests_session(token1) as requests: 

1020 host_request_a = requests.CreateHostRequest( 

1021 requests_pb2.CreateHostRequestReq( 

1022 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text() 

1023 ) 

1024 ).host_request_id 

1025 moderator.approve_host_request(host_request_a) 

1026 

1027 with requests_session(token1) as requests: 

1028 requests.SendHostRequestMessage( 

1029 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_a, text="Sorry, meant Tuesday night!") 

1030 ) 

1031 

1032 # request B: user3 -> user2, creation only (higher message IDs than A's follow-up) 

1033 with requests_session(token3) as requests: 

1034 host_request_b = requests.CreateHostRequest( 

1035 requests_pb2.CreateHostRequestReq( 

1036 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text() 

1037 ) 

1038 ).host_request_id 

1039 moderator.approve_host_request(host_request_b) 

1040 

1041 with session_scope() as session: 

1042 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1043 

1044 timewarp.advance(MISSED_MESSAGES_DELAY) 

1045 

1046 # should get exactly 1 missed_messages email: for request A (has follow-up), 

1047 # not request B (creation only, skipped) 

1048 send_request_notifications(empty_pb2.Empty()) 

1049 process_jobs() 

1050 assert _count_queued_emails() == 1 

1051 

1052 

1053def test_send_request_notifications_delayed_for_push_capable_users( 

1054 db, moderator, push_collector: PushCollector, timewarp: Timewarp 

1055): 

1056 user1, token1 = generate_user() 

1057 user2, token2 = generate_user() 

1058 _add_mobile_push_subscription(user1.id) 

1059 

1060 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1061 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1062 

1063 with requests_session(token1) as requests: 

1064 host_request_id = requests.CreateHostRequest( 

1065 requests_pb2.CreateHostRequestReq( 

1066 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text() 

1067 ) 

1068 ).host_request_id 

1069 moderator.approve_host_request(host_request_id) 

1070 

1071 with requests_session(token2) as requests: 

1072 requests.RespondHostRequest( 

1073 requests_pb2.RespondHostRequestReq( 

1074 host_request_id=host_request_id, 

1075 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1076 text="Test request", 

1077 ) 

1078 ) 

1079 

1080 with session_scope() as session: 

1081 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1082 

1083 timewarp.advance(MISSED_MESSAGES_DELAY) 

1084 send_request_notifications(empty_pb2.Empty()) 

1085 process_jobs() 

1086 assert _count_queued_emails() == 0 

1087 

1088 timewarp.advance(MISSED_MESSAGES_DELAY_WITH_PUSH) 

1089 send_request_notifications(empty_pb2.Empty()) 

1090 process_jobs() 

1091 assert _count_queued_emails() == 1 

1092 

1093 

1094def test_send_message_notifications_seen(db, moderator, timewarp: Timewarp): 

1095 user1, token1 = generate_user() 

1096 user2, token2 = generate_user() 

1097 

1098 make_friends(user1, user2) 

1099 

1100 send_message_notifications(empty_pb2.Empty()) 

1101 

1102 # should find no jobs, since there's no messages 

1103 with session_scope() as session: 

1104 assert ( 

1105 session.execute( 

1106 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

1107 ).scalar_one() 

1108 == 0 

1109 ) 

1110 

1111 with conversations_session(token1) as c: 

1112 group_chat_id = c.CreateGroupChat( 

1113 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]) 

1114 ).group_chat_id 

1115 moderator.approve_group_chat(group_chat_id) 

1116 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1")) 

1117 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2")) 

1118 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3")) 

1119 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4")) 

1120 

1121 # user 2 now marks those messages as seen 

1122 with conversations_session(token2) as c: 

1123 m_id = c.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id)).latest_message.message_id 

1124 c.MarkLastSeenGroupChat( 

1125 conversations_pb2.MarkLastSeenGroupChatReq(group_chat_id=group_chat_id, last_seen_message_id=m_id) 

1126 ) 

1127 

1128 send_message_notifications(empty_pb2.Empty()) 

1129 

1130 # no emails sent out 

1131 with session_scope() as session: 

1132 assert ( 

1133 session.execute( 

1134 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

1135 ).scalar_one() 

1136 == 0 

1137 ) 

1138 

1139 timewarp.advance(timedelta(minutes=30)) 

1140 

1141 # still shouldn't generate emails as user2 has seen all messages 

1142 send_message_notifications(empty_pb2.Empty()) 

1143 

1144 with session_scope() as session: 

1145 assert ( 

1146 session.execute( 

1147 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

1148 ).scalar_one() 

1149 == 0 

1150 ) 

1151 

1152 

1153def test_send_onboarding_emails(db): 

1154 # needs to get first onboarding email 

1155 user1, token1 = generate_user(onboarding_emails_sent=0, last_onboarding_email_sent=None, complete_profile=False) 

1156 

1157 send_onboarding_emails(empty_pb2.Empty()) 

1158 process_jobs() 

1159 

1160 with session_scope() as session: 

1161 assert ( 

1162 session.execute( 

1163 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

1164 ).scalar_one() 

1165 == 1 

1166 ) 

1167 

1168 # needs to get second onboarding email, but not yet 

1169 user2, token2 = generate_user( 

1170 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=6), complete_profile=False 

1171 ) 

1172 

1173 send_onboarding_emails(empty_pb2.Empty()) 

1174 process_jobs() 

1175 

1176 with session_scope() as session: 

1177 assert ( 

1178 session.execute( 

1179 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

1180 ).scalar_one() 

1181 == 1 

1182 ) 

1183 

1184 # needs to get second onboarding email 

1185 user3, token3 = generate_user( 

1186 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=8), complete_profile=False 

1187 ) 

1188 

1189 send_onboarding_emails(empty_pb2.Empty()) 

1190 process_jobs() 

1191 

1192 with session_scope() as session: 

1193 assert ( 

1194 session.execute( 

1195 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email") 

1196 ).scalar_one() 

1197 == 2 

1198 ) 

1199 

1200 

1201def test_send_reference_reminders(db): 

1202 # need to test: 

1203 # case 1: bidirectional (no emails) 

1204 # case 2: host left ref (surfer needs an email) 

1205 # case 3: surfer left ref (host needs an email) 

1206 # case 4: neither left ref (host & surfer need an email) 

1207 # case 5: neither left ref, but host blocked surfer, so neither should get an email 

1208 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email) 

1209 

1210 send_reference_reminders(empty_pb2.Empty()) 

1211 

1212 # case 1: bidirectional (no emails) 

1213 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1") 

1214 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2") 

1215 

1216 # case 2: host left ref (surfer needs an email) 

1217 # host 

1218 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3") 

1219 # surfer 

1220 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4") 

1221 

1222 # case 3: surfer left ref (host needs an email) 

1223 # host 

1224 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5") 

1225 # surfer 

1226 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6") 

1227 

1228 # case 4: neither left ref (host & surfer need an email) 

1229 # surfer 

1230 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7") 

1231 # host 

1232 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8") 

1233 

1234 # case 5: neither left ref, but host blocked surfer, so neither should get an email 

1235 # surfer 

1236 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9") 

1237 # host 

1238 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10") 

1239 

1240 make_user_block(user9, user10) 

1241 

1242 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email) 

1243 # host 

1244 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11") 

1245 # surfer 

1246 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12") 

1247 

1248 with session_scope() as session: 

1249 # note that create_host_reference creates a host request whose age is one day older than the timedelta here 

1250 

1251 # case 1: bidirectional (no emails) 

1252 ref1, hr1 = create_host_reference(session, user2.id, user1.id, timedelta(days=7), surfing=True) 

1253 create_host_reference(session, user1.id, user2.id, timedelta(days=7), host_request_id=hr1) 

1254 

1255 # case 2: host left ref (surfer needs an email) 

1256 ref2, hr2 = create_host_reference(session, user3.id, user4.id, timedelta(days=11), surfing=False) 

1257 

1258 # case 3: surfer left ref (host needs an email) 

1259 ref3, hr3 = create_host_reference(session, user6.id, user5.id, timedelta(days=9), surfing=True) 

1260 

1261 # case 4: neither left ref (host & surfer need an email) 

1262 hr4 = create_host_request(session, user7.id, user8.id, timedelta(days=4)) 

1263 

1264 # case 5: neither left ref, but host blocked surfer, so neither should get an email 

1265 hr5 = create_host_request(session, user9.id, user10.id, timedelta(days=7)) 

1266 

1267 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email) 

1268 hr6 = create_host_request(session, user12.id, user11.id, timedelta(days=6), surfer_reason_didnt_meetup="") 

1269 

1270 expected_emails = [ 

1271 ( 

1272 "user11@couchers.org.invalid", 

1273 "[TEST] You have 14 days to write a reference for User 12", 

1274 ("from when you hosted them", "/leave-reference/hosted/"), 

1275 ), 

1276 ( 

1277 "user4@couchers.org.invalid", 

1278 "[TEST] You have 3 days to write a reference for User 3", 

1279 ("from when you surfed with them", "/leave-reference/surfed/"), 

1280 ), 

1281 ( 

1282 "user5@couchers.org.invalid", 

1283 "[TEST] You have 7 days to write a reference for User 6", 

1284 ("from when you hosted them", "/leave-reference/hosted/"), 

1285 ), 

1286 ( 

1287 "user7@couchers.org.invalid", 

1288 "[TEST] You have 14 days to write a reference for User 8", 

1289 ("from when you surfed with them", "/leave-reference/surfed/"), 

1290 ), 

1291 ( 

1292 "user8@couchers.org.invalid", 

1293 "[TEST] You have 14 days to write a reference for User 7", 

1294 ("from when you hosted them", "/leave-reference/hosted/"), 

1295 ), 

1296 ] 

1297 

1298 send_reference_reminders(empty_pb2.Empty()) 

1299 

1300 while process_job(): 

1301 pass 

1302 

1303 with session_scope() as session: 

1304 emails = [ 

1305 (email.recipient, email.subject, email.plain, email.html) 

1306 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all() 

1307 ] 

1308 

1309 actual_addresses_and_subjects = [email[:2] for email in emails] 

1310 expected_addresses_and_subjects = [email[:2] for email in expected_emails] 

1311 

1312 print(actual_addresses_and_subjects) 

1313 print(expected_addresses_and_subjects) 

1314 

1315 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1316 

1317 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails): 

1318 for find in search_strings: 

1319 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't" 

1320 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't" 

1321 

1322 

1323def test_send_reference_reminders_public_trip_offer(db): 

1324 """An offer reverses initiator/recipient, so the reminders have to be picked by the stay role.""" 

1325 surfer, _ = generate_user(email="surfer@couchers.org.invalid", name="Surfer") 

1326 host, _ = generate_user(email="host@couchers.org.invalid", name="Host") 

1327 

1328 with session_scope() as session: 

1329 create_host_request(session, surfer.id, host.id, timedelta(days=4), is_offer=True) 

1330 

1331 send_reference_reminders(empty_pb2.Empty()) 

1332 

1333 while process_job(): 

1334 pass 

1335 

1336 with session_scope() as session: 

1337 emails = { 

1338 email.recipient: (email.subject, email.plain) for email in session.execute(select(Email)).scalars().all() 

1339 } 

1340 

1341 assert set(emails) == {"surfer@couchers.org.invalid", "host@couchers.org.invalid"} 

1342 surfer_subject, surfer_plain = emails["surfer@couchers.org.invalid"] 

1343 assert surfer_subject == "[TEST] You have 14 days to write a reference for Host" 

1344 assert "from when you surfed with them" in surfer_plain 

1345 host_subject, host_plain = emails["host@couchers.org.invalid"] 

1346 assert host_subject == "[TEST] You have 14 days to write a reference for Surfer" 

1347 assert "from when you hosted them" in host_plain 

1348 

1349 

1350def test_send_host_request_reminders(db, moderator): 

1351 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1") 

1352 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2") 

1353 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3") 

1354 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4") 

1355 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5") 

1356 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6") 

1357 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7") 

1358 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8") 

1359 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9") 

1360 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10") 

1361 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11") 

1362 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12") 

1363 user13, token13 = generate_user(email="user13@couchers.org.invalid", name="User 13") 

1364 user14, token14 = generate_user(email="user14@couchers.org.invalid", name="User 14") 

1365 

1366 with session_scope() as session: 

1367 # case 1: pending, future, interval elapsed => notify 

1368 hr1 = create_host_request_by_date( 

1369 session=session, 

1370 surfer_user_id=user1.id, 

1371 host_user_id=user2.id, 

1372 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1), 

1373 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2), 

1374 status=HostRequestStatus.pending, 

1375 host_sent_request_reminders=0, 

1376 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1377 ) 

1378 

1379 # case 2: max reminders reached => do not notify 

1380 hr2 = create_host_request_by_date( 

1381 session=session, 

1382 surfer_user_id=user3.id, 

1383 host_user_id=user4.id, 

1384 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1), 

1385 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2), 

1386 status=HostRequestStatus.pending, 

1387 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS, 

1388 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1389 ) 

1390 

1391 # case 3: interval not yet elapsed => do not notify 

1392 hr3 = create_host_request_by_date( 

1393 session=session, 

1394 surfer_user_id=user5.id, 

1395 host_user_id=user6.id, 

1396 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1), 

1397 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2), 

1398 status=HostRequestStatus.pending, 

1399 host_sent_request_reminders=0, 

1400 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL + timedelta(hours=1), 

1401 ) 

1402 

1403 # case 4: start date is today => do not notify 

1404 hr4 = create_host_request_by_date( 

1405 session=session, 

1406 surfer_user_id=user7.id, 

1407 host_user_id=user8.id, 

1408 from_date=today(), 

1409 to_date=today() + timedelta(days=2), 

1410 status=HostRequestStatus.pending, 

1411 host_sent_request_reminders=0, 

1412 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1413 ) 

1414 

1415 # case 5: from_date in the past => do not notify 

1416 hr5 = create_host_request_by_date( 

1417 session=session, 

1418 surfer_user_id=user9.id, 

1419 host_user_id=user10.id, 

1420 from_date=today() - timedelta(days=1), 

1421 to_date=today() + timedelta(days=1), 

1422 status=HostRequestStatus.pending, 

1423 host_sent_request_reminders=0, 

1424 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1425 ) 

1426 

1427 # case 6: non-pending status => do not notify 

1428 hr6 = create_host_request_by_date( 

1429 session=session, 

1430 surfer_user_id=user11.id, 

1431 host_user_id=user12.id, 

1432 from_date=today() + timedelta(days=3), 

1433 to_date=today() + timedelta(days=4), 

1434 status=HostRequestStatus.accepted, 

1435 host_sent_request_reminders=0, 

1436 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1437 ) 

1438 

1439 # case 7: host already sent a message => do not notify 

1440 hr7 = create_host_request_by_date( 

1441 session=session, 

1442 surfer_user_id=user13.id, 

1443 host_user_id=user14.id, 

1444 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1), 

1445 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2), 

1446 status=HostRequestStatus.pending, 

1447 host_sent_request_reminders=0, 

1448 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1449 ) 

1450 

1451 msg = Message( 

1452 conversation_id=hr7, 

1453 author_id=user14.id, 

1454 text="Looking forward to hosting you!", 

1455 message_type=MessageType.text, 

1456 ) 

1457 msg.time = now() 

1458 session.add(msg) 

1459 

1460 # Approve host requests so they're visible for notifications 

1461 moderator.approve_host_request(hr1) 

1462 moderator.approve_host_request(hr2) 

1463 moderator.approve_host_request(hr3) 

1464 moderator.approve_host_request(hr4) 

1465 moderator.approve_host_request(hr5) 

1466 moderator.approve_host_request(hr6) 

1467 moderator.approve_host_request(hr7) 

1468 

1469 send_host_request_reminders(empty_pb2.Empty()) 

1470 

1471 while process_job(): 

1472 pass 

1473 

1474 with session_scope() as session: 

1475 emails = [ 

1476 (email.recipient, email.subject, email.plain, email.html) 

1477 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all() 

1478 ] 

1479 

1480 expected_emails = [ 

1481 ( 

1482 "user2@couchers.org.invalid", 

1483 "[TEST] You have a pending host request from User 1", 

1484 ("User 1", "is waiting for your response"), 

1485 ) 

1486 ] 

1487 

1488 actual_addresses_and_subjects = [email[:2] for email in emails] 

1489 expected_addresses_and_subjects = [email[:2] for email in expected_emails] 

1490 

1491 print(actual_addresses_and_subjects) 

1492 print(expected_addresses_and_subjects) 

1493 

1494 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1495 

1496 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails): 

1497 for find in search_strings: 

1498 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't" 

1499 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't" 

1500 

1501 

1502def test_add_users_to_email_list(db, feature_flags): 

1503 feature_flags.set("listmonk_enabled", True) 

1504 config.LISTMONK_BASE_URL = "https://example.com" 

1505 config.LISTMONK_API_USERNAME = "test_user" 

1506 config.LISTMONK_API_KEY = "dummy_api_key" 

1507 config.LISTMONK_LIST_ID = 6 

1508 

1509 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls: 

1510 mock_session_cls.return_value.post.return_value.status_code = 200 

1511 add_users_to_email_list(empty_pb2.Empty()) 

1512 mock_session_cls.return_value.post.assert_not_called() 

1513 

1514 generate_user(in_sync_with_newsletter=False, email="testing1@couchers.invalid", name="Tester1", id=15) 

1515 generate_user(in_sync_with_newsletter=True, email="testing2@couchers.invalid", name="Tester2") 

1516 generate_user(in_sync_with_newsletter=False, email="testing3@couchers.invalid", name="Tester3 von test", id=17) 

1517 generate_user( 

1518 in_sync_with_newsletter=False, email="testing4@couchers.invalid", name="Tester4", opt_out_of_newsletter=True 

1519 ) 

1520 

1521 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls: 

1522 mock_sess = mock_session_cls.return_value 

1523 mock_sess.post.return_value.status_code = 200 

1524 add_users_to_email_list(empty_pb2.Empty()) 

1525 mock_sess.post.assert_has_calls( 

1526 [ 

1527 call( 

1528 "https://example.com/api/subscribers", 

1529 json={ 

1530 "email": "testing1@couchers.invalid", 

1531 "name": "Tester1", 

1532 "lists": [6], 

1533 "preconfirm_subscriptions": True, 

1534 "attribs": {"couchers_user_id": 15}, 

1535 "status": "enabled", 

1536 }, 

1537 timeout=10, 

1538 ), 

1539 call( 

1540 "https://example.com/api/subscribers", 

1541 json={ 

1542 "email": "testing3@couchers.invalid", 

1543 "name": "Tester3 von test", 

1544 "lists": [6], 

1545 "preconfirm_subscriptions": True, 

1546 "attribs": {"couchers_user_id": 17}, 

1547 "status": "enabled", 

1548 }, 

1549 timeout=10, 

1550 ), 

1551 ], 

1552 any_order=True, 

1553 ) 

1554 

1555 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls: 

1556 mock_session_cls.return_value.post.return_value.status_code = 200 

1557 add_users_to_email_list(empty_pb2.Empty()) 

1558 mock_session_cls.return_value.post.assert_not_called() 

1559 

1560 

1561def test_update_recommendation_scores(db): 

1562 generate_user(complete_profile=True) 

1563 generate_user(hosting_status=HostingStatus.can_host) 

1564 generate_user() 

1565 

1566 def scores(): 

1567 with session_scope() as session: 

1568 return session.execute(select(User.id, User.recommendation_score).order_by(User.id)).all() 

1569 

1570 update_recommendation_scores(empty_pb2.Empty()) 

1571 first = scores() 

1572 update_recommendation_scores(empty_pb2.Empty()) 

1573 # search applies noise per viewer, so the stored score itself should be deterministic 

1574 assert scores() == first 

1575 

1576 

1577def test_update_badges(db, push_collector: PushCollector): 

1578 user1, _ = generate_user(last_donated=None) 

1579 user2, _ = generate_user(last_donated=None) 

1580 user3, _ = generate_user(last_donated=None) 

1581 user4, _ = generate_user(phone="+15555555555", phone_verification_verified=func.now(), last_donated=None) 

1582 user5, _ = generate_user(phone="+15555555556", phone_verification_verified=func.now(), last_donated=None) 

1583 user6, _ = generate_user(last_donated=None) 

1584 

1585 with session_scope() as session: 

1586 session.add(UserBadge(user_id=user5.id, badge_id="board_member")) 

1587 session.add( 

1588 PostalVerificationAttempt( 

1589 user_id=user6.id, 

1590 address_line_1="123 Main St", 

1591 city="Test City", 

1592 country_code="US", 

1593 status=PostalVerificationStatus.succeeded, 

1594 verification_code="ABC123", 

1595 postcard_sent_at=func.now(), 

1596 verified_at=func.now(), 

1597 ) 

1598 ) 

1599 

1600 update_badges(empty_pb2.Empty()) 

1601 process_jobs() 

1602 

1603 with session_scope() as session: 

1604 badge_tuples = session.execute( 

1605 select(UserBadge.user_id, UserBadge.badge_id).order_by(UserBadge.user_id.asc(), UserBadge.id.asc()) 

1606 ).all() 

1607 

1608 expected = [ 

1609 (user1.id, "founder"), 

1610 (user1.id, "board_member"), 

1611 (user2.id, "founder"), 

1612 (user2.id, "board_member"), 

1613 (user4.id, "phone_verified"), 

1614 (user5.id, "phone_verified"), 

1615 (user6.id, "postal_verified"), 

1616 ] 

1617 

1618 assert badge_tuples == expected # type: ignore[comparison-overlap] 

1619 

1620 print(push_collector.by_user) 

1621 

1622 push = push_collector.pop_for_user(user1.id, last=False) 

1623 assert push.content.title == "New profile badge: Founder" 

1624 assert push.content.body == "The Founder badge was added to your profile." 

1625 

1626 push = push_collector.pop_for_user(user1.id, last=True) 

1627 assert push.content.title == "New profile badge: Board Member" 

1628 assert push.content.body == "The Board Member badge was added to your profile." 

1629 

1630 push = push_collector.pop_for_user(user2.id, last=False) 

1631 assert push.content.title == "New profile badge: Founder" 

1632 assert push.content.body == "The Founder badge was added to your profile." 

1633 

1634 push = push_collector.pop_for_user(user2.id, last=True) 

1635 assert push.content.title == "New profile badge: Board Member" 

1636 assert push.content.body == "The Board Member badge was added to your profile." 

1637 

1638 push = push_collector.pop_for_user(user4.id, last=True) 

1639 assert push.content.title == "New profile badge: Verified Phone" 

1640 assert push.content.body == "The Verified Phone badge was added to your profile." 

1641 

1642 push = push_collector.pop_for_user(user5.id, last=False) 

1643 assert push.content.title == "Profile badge removed" 

1644 assert push.content.body == "The Board Member badge was removed from your profile." 

1645 

1646 push = push_collector.pop_for_user(user5.id, last=True) 

1647 assert push.content.title == "New profile badge: Verified Phone" 

1648 assert push.content.body == "The Verified Phone badge was added to your profile." 

1649 

1650 

1651def test_update_badges_awards_moderator_to_superuser(db): 

1652 """The show_moderator_badge flag defaults on, so superusers are awarded the moderator badge.""" 

1653 superuser, _ = generate_user(is_superuser=True, last_donated=None) 

1654 

1655 update_badges(empty_pb2.Empty()) 

1656 

1657 with session_scope() as session: 

1658 assert ( 

1659 session.execute( 

1660 select(func.count()) 

1661 .select_from(UserBadge) 

1662 .where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator") 

1663 ).scalar() 

1664 == 1 

1665 ) 

1666 

1667 

1668def test_update_badges_skips_moderator_when_flag_off(db, monkeypatch): 

1669 """With show_moderator_badge forced off, superusers are not awarded the moderator badge.""" 

1670 # force show_moderator_badge off for everyone (force rule with no coverage applies globally) 

1671 monkeypatch.setattr(experimentation, "_initialized", True) 

1672 monkeypatch.setattr( 

1673 experimentation, 

1674 "_state", 

1675 {"features": {"show_moderator_badge": {"defaultValue": True, "rules": [{"force": False}]}}, "savedGroups": {}}, 

1676 ) 

1677 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "") 

1678 

1679 superuser, _ = generate_user(is_superuser=True, last_donated=None) 

1680 

1681 update_badges(empty_pb2.Empty()) 

1682 

1683 with session_scope() as session: 

1684 assert ( 

1685 session.execute( 

1686 select(func.count()) 

1687 .select_from(UserBadge) 

1688 .where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator") 

1689 ).scalar() 

1690 == 0 

1691 ) 

1692 

1693 

1694def test_send_request_notifications_blocked_users_no_notification(db, moderator, timewarp: Timewarp): 

1695 """ 

1696 Regression test: send_request_notifications should not send notifications 

1697 when the host and surfer are not visible to each other (e.g., one blocked the other). 

1698 """ 

1699 user1, token1 = generate_user() 

1700 user2, token2 = generate_user() 

1701 

1702 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1703 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1704 

1705 # Create a host request 

1706 with requests_session(token1) as requests: 

1707 host_request_id = requests.CreateHostRequest( 

1708 requests_pb2.CreateHostRequestReq( 

1709 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text() 

1710 ) 

1711 ).host_request_id 

1712 moderator.approve_host_request(host_request_id) 

1713 

1714 with session_scope() as session: 

1715 # delete send_email BackgroundJob created by CreateHostRequest 

1716 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1717 

1718 # Now user2 (host) blocks user1 (surfer) 

1719 make_user_block(user2, user1) 

1720 

1721 timewarp.advance(MISSED_MESSAGES_DELAY) 

1722 

1723 # check send_request_notifications does NOT create background job because users are blocked 

1724 send_request_notifications(empty_pb2.Empty()) 

1725 process_jobs() 

1726 

1727 # Should be 0 emails because the host blocked the surfer 

1728 assert _count_queued_emails() == 0, "No notification email should be sent when host has blocked surfer" 

1729 

1730 # Also test the reverse direction: surfer sends message to host, host should not get notification 

1731 # First unblock 

1732 with session_scope() as session: 

1733 session.execute(delete(UserBlock).execution_options(synchronize_session=False)) 

1734 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1735 

1736 # Host responds 

1737 with requests_session(token2) as requests: 

1738 requests.RespondHostRequest( 

1739 requests_pb2.RespondHostRequestReq( 

1740 host_request_id=host_request_id, 

1741 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1742 text="Accepting your request", 

1743 ) 

1744 ) 

1745 

1746 with session_scope() as session: 

1747 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1748 

1749 # Now user1 (surfer) blocks user2 (host) 

1750 make_user_block(user1, user2) 

1751 

1752 timewarp.advance(MISSED_MESSAGES_DELAY) 

1753 

1754 # check send_request_notifications does NOT create background job 

1755 send_request_notifications(empty_pb2.Empty()) 

1756 process_jobs() 

1757 

1758 # Should be 0 emails because the surfer blocked the host 

1759 assert _count_queued_emails() == 0, "No notification email should be sent when surfer has blocked host" 

1760 

1761 

1762def test_send_host_request_reminders_blocked_users_no_notification(db, moderator): 

1763 """ 

1764 send_host_request_reminders should not send notifications when the host and surfer are not visible to each other 

1765 (e.g., one blocked the other). 

1766 """ 

1767 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1") 

1768 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2") 

1769 

1770 with session_scope() as session: 

1771 # Create a pending host request where the host has not replied 

1772 hr = create_host_request_by_date( 

1773 session=session, 

1774 surfer_user_id=user1.id, 

1775 host_user_id=user2.id, 

1776 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1), 

1777 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2), 

1778 status=HostRequestStatus.pending, 

1779 host_sent_request_reminders=0, 

1780 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1781 ) 

1782 

1783 # Approve the host request so it's visible for notifications 

1784 moderator.approve_host_request(hr) 

1785 

1786 # Verify that without blocking, a reminder would be sent 

1787 send_host_request_reminders(empty_pb2.Empty()) 

1788 

1789 while process_job(): 

1790 pass 

1791 

1792 with session_scope() as session: 

1793 emails = session.execute(select(Email)).scalars().all() 

1794 assert len(emails) == 1, "Expected 1 reminder email before blocking" 

1795 

1796 # Clean up emails and background jobs 

1797 session.execute(delete(Email).execution_options(synchronize_session=False)) 

1798 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1799 

1800 # Reset the reminder counter so we can test again 

1801 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr)).scalar_one() 

1802 host_request.recipient_sent_request_reminders = 0 

1803 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL 

1804 

1805 # Now have the host block the surfer 

1806 make_user_block(user2, user1) 

1807 

1808 send_host_request_reminders(empty_pb2.Empty()) 

1809 

1810 while process_job(): 1810 ↛ 1811line 1810 didn't jump to line 1811 because the condition on line 1810 was never true

1811 pass 

1812 

1813 with session_scope() as session: 

1814 emails = session.execute(select(Email)).scalars().all() 

1815 assert len(emails) == 0, "No reminder email should be sent when host has blocked surfer" 

1816 

1817 

1818def test_send_message_notifications_blocked_users_no_notification(db, moderator, timewarp: Timewarp): 

1819 """ 

1820 Regression test: send_message_notifications should not send notifications 

1821 for messages from users who are blocked by the recipient. 

1822 """ 

1823 user1, token1 = generate_user() 

1824 user2, token2 = generate_user() 

1825 

1826 make_friends(user1, user2) 

1827 

1828 # Create a group chat and send messages 

1829 with conversations_session(token1) as c: 

1830 group_chat_id = c.CreateGroupChat( 

1831 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]) 

1832 ).group_chat_id 

1833 

1834 # Approve the group chat so it's visible for notifications 

1835 moderator.approve_group_chat(group_chat_id) 

1836 

1837 with conversations_session(token1) as c: 

1838 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1")) 

1839 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2")) 

1840 

1841 # Verify that without blocking, a notification would be sent 

1842 with session_scope() as session: 

1843 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1844 

1845 timewarp.advance(MISSED_MESSAGES_DELAY) 

1846 

1847 send_message_notifications(empty_pb2.Empty()) 

1848 process_jobs() 

1849 

1850 assert _count_queued_emails() == 1, "Expected 1 notification email before blocking" 

1851 

1852 with session_scope() as session: 

1853 # Clean up 

1854 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1855 

1856 # Reset the notification state so user2 will receive notifications for old messages again 

1857 with session_scope() as session: 

1858 u2 = session.execute(select(User).where(User.id == user2.id)).scalar_one() 

1859 u2.last_notified_message_id = 0 

1860 

1861 # Now have user2 block user1 

1862 make_user_block(user2, user1) 

1863 

1864 # The existing messages from user1 should now NOT trigger notifications 

1865 # since user2 has blocked user1 

1866 with session_scope() as session: 

1867 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

1868 

1869 send_message_notifications(empty_pb2.Empty()) 

1870 process_jobs() 

1871 

1872 assert _count_queued_emails() == 0, "No notification email should be sent when recipient has blocked sender" 

1873 

1874 

1875def test_update_badges_volunteers(db, push_collector: PushCollector): 

1876 """Test that volunteer and past_volunteer badges are automatically granted based on Volunteer model.""" 

1877 # Create 6 users - users 1 and 2 get founder/board_member badges from static_badges 

1878 user1, _ = generate_user(last_donated=None) 

1879 user2, _ = generate_user(last_donated=None) 

1880 user3, _ = generate_user(last_donated=None) 

1881 user4, _ = generate_user(last_donated=None) 

1882 user5, _ = generate_user(last_donated=None) 

1883 user6, _ = generate_user(last_donated=None) 

1884 

1885 with session_scope() as session: 

1886 # user3: active volunteer (stopped_volunteering is null) 

1887 session.add( 

1888 make_volunteer( 

1889 user_id=user3.id, 

1890 role="Developer", 

1891 started_volunteering=date(2020, 1, 1), 

1892 stopped_volunteering=None, 

1893 ) 

1894 ) 

1895 

1896 # user4: past volunteer (stopped_volunteering is set) 

1897 session.add( 

1898 make_volunteer( 

1899 user_id=user4.id, 

1900 role="Designer", 

1901 started_volunteering=date(2020, 1, 1), 

1902 stopped_volunteering=date(2023, 6, 1), 

1903 ) 

1904 ) 

1905 

1906 # user5: has old volunteer badge that should be removed (not a volunteer anymore) 

1907 session.add(UserBadge(user_id=user5.id, badge_id="volunteer")) 

1908 

1909 # user6: has old past_volunteer badge that should be removed 

1910 session.add(UserBadge(user_id=user6.id, badge_id="past_volunteer")) 

1911 

1912 update_badges(empty_pb2.Empty()) 

1913 process_jobs() 

1914 

1915 with session_scope() as session: 

1916 # Check user3 has volunteer badge 

1917 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all() 

1918 assert "volunteer" in user3_badges 

1919 assert "past_volunteer" not in user3_badges 

1920 

1921 # Check user4 has past_volunteer badge 

1922 user4_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user4.id)).scalars().all() 

1923 assert "past_volunteer" in user4_badges 

1924 assert "volunteer" not in user4_badges 

1925 

1926 # Check user5 lost the volunteer badge (not in Volunteer table) 

1927 user5_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user5.id)).scalars().all() 

1928 assert "volunteer" not in user5_badges 

1929 

1930 # Check user6 lost the past_volunteer badge (not in Volunteer table) 

1931 user6_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user6.id)).scalars().all() 

1932 assert "past_volunteer" not in user6_badges 

1933 

1934 # Check notifications for volunteer badge users 

1935 push = push_collector.pop_for_user(user3.id, last=True) 

1936 assert push.content.title == "New profile badge: Active Volunteer" 

1937 assert push.content.body == "The Active Volunteer badge was added to your profile." 

1938 

1939 push = push_collector.pop_for_user(user4.id, last=True) 

1940 assert push.content.title == "New profile badge: Past Volunteer" 

1941 assert push.content.body == "The Past Volunteer badge was added to your profile." 

1942 

1943 push = push_collector.pop_for_user(user5.id, last=True) 

1944 assert push.content.title == "Profile badge removed" 

1945 assert push.content.body == "The Active Volunteer badge was removed from your profile." 

1946 

1947 push = push_collector.pop_for_user(user6.id, last=True) 

1948 assert push.content.title == "Profile badge removed" 

1949 assert push.content.body == "The Past Volunteer badge was removed from your profile." 

1950 

1951 

1952def test_update_badges_volunteer_status_change(db, push_collector: PushCollector): 

1953 """Test that badge is updated when volunteer status changes from active to past.""" 

1954 # Create users - users 1 and 2 get founder/board_member badges from static_badges 

1955 user1, _ = generate_user(last_donated=None) 

1956 user2, _ = generate_user(last_donated=None) 

1957 user3, _ = generate_user(last_donated=None) 

1958 

1959 with session_scope() as session: 

1960 # user3: start as active volunteer 

1961 session.add( 

1962 make_volunteer( 

1963 user_id=user3.id, 

1964 role="Developer", 

1965 started_volunteering=date(2020, 1, 1), 

1966 stopped_volunteering=None, 

1967 show_on_team_page=True, 

1968 ) 

1969 ) 

1970 

1971 update_badges(empty_pb2.Empty()) 

1972 process_jobs() 

1973 

1974 with session_scope() as session: 

1975 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all() 

1976 assert "volunteer" in user3_badges 

1977 assert "past_volunteer" not in user3_badges 

1978 

1979 push = push_collector.pop_for_user(user3.id, last=True) 

1980 assert push.content.title == "New profile badge: Active Volunteer" 

1981 assert push.content.body == "The Active Volunteer badge was added to your profile." 

1982 

1983 # Now change the volunteer to past volunteer 

1984 with session_scope() as session: 

1985 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == user3.id)).scalar_one() 

1986 volunteer.stopped_volunteering = date(2023, 12, 1) 

1987 

1988 update_badges(empty_pb2.Empty()) 

1989 process_jobs() 

1990 

1991 with session_scope() as session: 

1992 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all() 

1993 assert "volunteer" not in user3_badges 

1994 assert "past_volunteer" in user3_badges 

1995 

1996 # Check both badges were updated 

1997 push = push_collector.pop_for_user(user3.id, last=False) 

1998 assert push.content.title == "Profile badge removed" 

1999 assert push.content.body == "The Active Volunteer badge was removed from your profile." 

2000 

2001 push = push_collector.pop_for_user(user3.id, last=True) 

2002 assert push.content.title == "New profile badge: Past Volunteer" 

2003 assert push.content.body == "The Past Volunteer badge was added to your profile." 

2004 

2005 

2006def test_send_message_notifications_empty_unseen_simple(monkeypatch): 

2007 class DummyUser: 

2008 id = 1 

2009 is_visible = True 

2010 last_notified_message_id = 0 

2011 

2012 class FirstResult: 

2013 def scalars(self): 

2014 return self 

2015 

2016 def unique(self): 

2017 return [DummyUser()] 

2018 

2019 class SecondResult: 

2020 def all(self): 

2021 return [] 

2022 

2023 class DummySession: 

2024 def __init__(self): 

2025 self.calls = 0 

2026 

2027 def execute(self, *a, **k): 

2028 self.calls += 1 

2029 return FirstResult() if self.calls == 1 else SecondResult() 

2030 

2031 def commit(self): 

2032 pass 

2033 

2034 def flush(self): 

2035 pass 

2036 

2037 def fake_session_scope(): 

2038 class Ctx: 

2039 def __enter__(self): 

2040 return DummySession() 

2041 

2042 def __exit__(self, exc_type, exc, tb): 

2043 pass 

2044 

2045 return Ctx() 

2046 

2047 monkeypatch.setattr(handlers, "session_scope", fake_session_scope) 

2048 

2049 handlers.send_message_notifications(Empty())