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

628 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-06 08:02 +0000

1from datetime import date, timedelta 

2from unittest.mock import call, patch 

3 

4import pytest 

5import requests 

6from google.protobuf import empty_pb2 

7from google.protobuf.empty_pb2 import Empty 

8from sqlalchemy.sql import delete, func 

9 

10import couchers.jobs.worker 

11from couchers.config import config 

12from couchers.constants import HOST_REQUEST_MAX_REMINDERS, HOST_REQUEST_REMINDER_INTERVAL 

13from couchers.crypto import urlsafe_secure_token 

14from couchers.db import session_scope 

15from couchers.email import queue_email 

16from couchers.email.dev import print_dev_email 

17from couchers.jobs import handlers 

18from couchers.jobs.enqueue import queue_job 

19from couchers.jobs.handlers import ( 

20 add_users_to_email_list, 

21 send_host_request_reminders, 

22 send_message_notifications, 

23 send_onboarding_emails, 

24 send_reference_reminders, 

25 send_request_notifications, 

26 update_badges, 

27 update_recommendation_scores, 

28) 

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

30from couchers.metrics import create_prometheus_server 

31from couchers.models import ( 

32 AccountDeletionToken, 

33 BackgroundJob, 

34 BackgroundJobState, 

35 Email, 

36 HostRequestStatus, 

37 LoginToken, 

38 Message, 

39 MessageType, 

40 PasswordResetToken, 

41 UserBadge, 

42 UserBlock, 

43 Volunteer, 

44) 

45from couchers.proto import conversations_pb2, requests_pb2 

46from couchers.sql import couchers_select as select 

47from couchers.utils import now, today 

48from tests.test_fixtures import ( # noqa 

49 auth_api_session, 

50 conversations_session, 

51 db, 

52 generate_user, 

53 make_friends, 

54 make_user_block, 

55 process_jobs, 

56 push_collector, 

57 requests_session, 

58 testconfig, 

59) 

60from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date 

61from tests.test_requests import valid_request_text 

62 

63 

64def now_5_min_in_future(): 

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

66 

67 

68@pytest.fixture(autouse=True) 

69def _(testconfig): 

70 pass 

71 

72 

73def _check_job_counter(job, status, attempt, exception): 

74 metrics_string = requests.get("http://localhost:8000").text 

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

76 assert string_to_check in metrics_string 

77 

78 

79def test_email_job(db): 

80 with session_scope() as session: 

81 queue_email(session, "sender_name", "sender_email", "recipient", "subject", "plain", "html") 

82 

83 def mock_print_dev_email( 

84 sender_name, sender_email, recipient, subject, plain, html, list_unsubscribe_header, source_data 

85 ): 

86 assert sender_name == "sender_name" 

87 assert sender_email == "sender_email" 

88 assert recipient == "recipient" 

89 assert subject == "subject" 

90 assert plain == "plain" 

91 assert html == "html" 

92 return print_dev_email( 

93 sender_name, sender_email, recipient, subject, plain, html, list_unsubscribe_header, source_data 

94 ) 

95 

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

97 process_job() 

98 

99 with session_scope() as session: 

100 assert ( 

101 session.execute( 

102 select(func.count()) 

103 .select_from(BackgroundJob) 

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

105 ).scalar_one() 

106 == 1 

107 ) 

108 assert ( 

109 session.execute( 

110 select(func.count()) 

111 .select_from(BackgroundJob) 

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

113 ).scalar_one() 

114 == 0 

115 ) 

116 

117 

118def test_purge_login_tokens(db): 

119 user, api_token = generate_user() 

120 

121 with session_scope() as session: 

122 login_token = LoginToken(token=urlsafe_secure_token(), user=user, expiry=now()) 

123 session.add(login_token) 

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

125 

126 queue_job(session, "purge_login_tokens", empty_pb2.Empty()) 

127 process_job() 

128 

129 with session_scope() as session: 

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

131 

132 with session_scope() as session: 

133 assert ( 

134 session.execute( 

135 select(func.count()) 

136 .select_from(BackgroundJob) 

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

138 ).scalar_one() 

139 == 1 

140 ) 

141 assert ( 

142 session.execute( 

143 select(func.count()) 

144 .select_from(BackgroundJob) 

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

146 ).scalar_one() 

147 == 0 

148 ) 

149 

150 

151def test_purge_password_reset_tokens(db): 

152 user, api_token = generate_user() 

153 

154 with session_scope() as session: 

155 password_reset_token = PasswordResetToken(token=urlsafe_secure_token(), user=user, expiry=now()) 

156 session.add(password_reset_token) 

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

158 

159 queue_job(session, "purge_password_reset_tokens", empty_pb2.Empty()) 

160 process_job() 

161 

162 with session_scope() as session: 

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

164 

165 with session_scope() as session: 

166 assert ( 

167 session.execute( 

168 select(func.count()) 

169 .select_from(BackgroundJob) 

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

171 ).scalar_one() 

172 == 1 

173 ) 

174 assert ( 

175 session.execute( 

176 select(func.count()) 

177 .select_from(BackgroundJob) 

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

179 ).scalar_one() 

180 == 0 

181 ) 

182 

183 

184def test_purge_account_deletion_tokens(db): 

185 user, api_token = generate_user() 

186 user2, api_token2 = generate_user() 

187 user3, api_token3 = generate_user() 

188 

189 with session_scope() as session: 

190 """ 

191 3 cases: 

192 1) Token is valid 

193 2) Token expired but account retrievable 

194 3) Account is irretrievable (and expired) 

195 """ 

196 account_deletion_tokens = [ 

197 AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() - timedelta(hours=2)), 

198 AccountDeletionToken(token=urlsafe_secure_token(), user=user2, expiry=now()), 

199 AccountDeletionToken(token=urlsafe_secure_token(), user=user3, expiry=now() + timedelta(hours=5)), 

200 ] 

201 for token in account_deletion_tokens: 

202 session.add(token) 

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

204 

205 queue_job(session, "purge_account_deletion_tokens", empty_pb2.Empty()) 

206 process_job() 

207 

208 with session_scope() as session: 

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

210 

211 with session_scope() as session: 

212 assert ( 

213 session.execute( 

214 select(func.count()) 

215 .select_from(BackgroundJob) 

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

217 ).scalar_one() 

218 == 1 

219 ) 

220 assert ( 

221 session.execute( 

222 select(func.count()) 

223 .select_from(BackgroundJob) 

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

225 ).scalar_one() 

226 == 0 

227 ) 

228 

229 

230def test_enforce_community_memberships(db): 

231 with session_scope() as session: 

232 queue_job(session, "enforce_community_membership", empty_pb2.Empty()) 

233 process_job() 

234 

235 with session_scope() as session: 

236 assert ( 

237 session.execute( 

238 select(func.count()) 

239 .select_from(BackgroundJob) 

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

241 ).scalar_one() 

242 == 1 

243 ) 

244 assert ( 

245 session.execute( 

246 select(func.count()) 

247 .select_from(BackgroundJob) 

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

249 ).scalar_one() 

250 == 0 

251 ) 

252 

253 

254def test_refresh_materialized_views(db): 

255 with session_scope() as session: 

256 queue_job(session, "refresh_materialized_views", empty_pb2.Empty()) 

257 

258 process_job() 

259 

260 with session_scope() as session: 

261 assert ( 

262 session.execute( 

263 select(func.count()) 

264 .select_from(BackgroundJob) 

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

266 ).scalar_one() 

267 == 1 

268 ) 

269 assert ( 

270 session.execute( 

271 select(func.count()) 

272 .select_from(BackgroundJob) 

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

274 ).scalar_one() 

275 == 0 

276 ) 

277 

278 

279def test_service_jobs(db): 

280 with session_scope() as session: 

281 queue_email(session, "sender_name", "sender_email", "recipient", "subject", "plain", "html") 

282 

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

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

285 class HitSleep(Exception): 

286 pass 

287 

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

289 def raising_sleep(seconds): 

290 raise HitSleep() 

291 

292 with pytest.raises(HitSleep): 

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

294 service_jobs() 

295 

296 with session_scope() as session: 

297 assert ( 

298 session.execute( 

299 select(func.count()) 

300 .select_from(BackgroundJob) 

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

302 ).scalar_one() 

303 == 1 

304 ) 

305 assert ( 

306 session.execute( 

307 select(func.count()) 

308 .select_from(BackgroundJob) 

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

310 ).scalar_one() 

311 == 0 

312 ) 

313 

314 

315def test_scheduler(db, monkeypatch): 

316 MOCK_SCHEDULE = [ 

317 ("purge_login_tokens", timedelta(seconds=7)), 

318 ("send_message_notifications", timedelta(seconds=11)), 

319 ] 

320 

321 current_time = 0 

322 end_time = 70 

323 

324 class EndOfTime(Exception): 

325 pass 

326 

327 def mock_monotonic(): 

328 nonlocal current_time 

329 return current_time 

330 

331 def mock_sleep(seconds): 

332 nonlocal current_time 

333 current_time += seconds 

334 if current_time > end_time: 

335 raise EndOfTime() 

336 

337 realized_schedule = [] 

338 

339 def mock_run_job_and_schedule(sched, schedule_id): 

340 nonlocal current_time 

341 realized_schedule.append((current_time, schedule_id)) 

342 _run_job_and_schedule(sched, schedule_id) 

343 

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

345 monkeypatch.setattr(couchers.jobs.worker, "SCHEDULE", MOCK_SCHEDULE) 

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

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

348 

349 with pytest.raises(EndOfTime): 

350 run_scheduler() 

351 

352 assert realized_schedule == [ 

353 (0.0, 0), 

354 (0.0, 1), 

355 (7.0, 0), 

356 (11.0, 1), 

357 (14.0, 0), 

358 (21.0, 0), 

359 (22.0, 1), 

360 (28.0, 0), 

361 (33.0, 1), 

362 (35.0, 0), 

363 (42.0, 0), 

364 (44.0, 1), 

365 (49.0, 0), 

366 (55.0, 1), 

367 (56.0, 0), 

368 (63.0, 0), 

369 (66.0, 1), 

370 (70.0, 0), 

371 ] 

372 

373 with session_scope() as session: 

374 assert ( 

375 session.execute( 

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

377 ).scalar_one() 

378 == 18 

379 ) 

380 assert ( 

381 session.execute( 

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

383 ).scalar_one() 

384 == 0 

385 ) 

386 

387 

388def test_job_retry(db): 

389 with session_scope() as session: 

390 queue_job(session, "mock_job", empty_pb2.Empty()) 

391 

392 called_count = 0 

393 

394 def mock_job(payload): 

395 nonlocal called_count 

396 called_count += 1 

397 raise Exception() 

398 

399 MOCK_JOBS = { 

400 "mock_job": (empty_pb2.Empty, mock_job), 

401 } 

402 create_prometheus_server(port=8000) 

403 

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

405 new_config = config.copy() 

406 new_config["IN_TEST"] = False 

407 

408 with patch("couchers.jobs.worker.config", new_config), patch("couchers.jobs.worker.JOBS", MOCK_JOBS): 

409 process_job() 

410 with session_scope() as session: 

411 assert ( 

412 session.execute( 

413 select(func.count()) 

414 .select_from(BackgroundJob) 

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

416 ).scalar_one() 

417 == 1 

418 ) 

419 assert ( 

420 session.execute( 

421 select(func.count()) 

422 .select_from(BackgroundJob) 

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

424 ).scalar_one() 

425 == 0 

426 ) 

427 

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

429 process_job() 

430 with session_scope() as session: 

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

432 process_job() 

433 with session_scope() as session: 

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

435 process_job() 

436 with session_scope() as session: 

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

438 process_job() 

439 

440 with session_scope() as session: 

441 assert ( 

442 session.execute( 

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

444 ).scalar_one() 

445 == 1 

446 ) 

447 assert ( 

448 session.execute( 

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

450 ).scalar_one() 

451 == 0 

452 ) 

453 

454 _check_job_counter("mock_job", "error", "4", "Exception") 

455 _check_job_counter("mock_job", "failed", "5", "Exception") 

456 

457 

458def test_no_jobs_no_problem(db): 

459 with session_scope() as session: 

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

461 

462 assert not process_job() 

463 

464 with session_scope() as session: 

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

466 

467 

468def test_send_message_notifications_basic(db): 

469 user1, token1 = generate_user() 

470 user2, token2 = generate_user() 

471 user3, token3 = generate_user() 

472 

473 make_friends(user1, user2) 

474 make_friends(user1, user3) 

475 make_friends(user2, user3) 

476 

477 send_message_notifications(empty_pb2.Empty()) 

478 process_jobs() 

479 

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

481 with session_scope() as session: 

482 assert ( 

483 session.execute( 

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

485 ).scalar_one() 

486 == 0 

487 ) 

488 

489 with conversations_session(token1) as c: 

490 group_chat_id = c.CreateGroupChat( 

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

492 ).group_chat_id 

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

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

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

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

497 

498 with conversations_session(token3) as c: 

499 group_chat_id = c.CreateGroupChat( 

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

501 ).group_chat_id 

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

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

504 

505 send_message_notifications(empty_pb2.Empty()) 

506 process_jobs() 

507 

508 # no emails sent out 

509 with session_scope() as session: 

510 assert ( 

511 session.execute( 

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

513 ).scalar_one() 

514 == 0 

515 ) 

516 

517 # this should generate emails for both user2 and user3 

518 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

519 send_message_notifications(empty_pb2.Empty()) 

520 process_jobs() 

521 

522 with session_scope() as session: 

523 assert ( 

524 session.execute( 

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

526 ).scalar_one() 

527 == 2 

528 ) 

529 # delete them all 

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

531 

532 # shouldn't generate any more emails 

533 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

534 send_message_notifications(empty_pb2.Empty()) 

535 process_jobs() 

536 

537 with session_scope() as session: 

538 assert ( 

539 session.execute( 

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

541 ).scalar_one() 

542 == 0 

543 ) 

544 

545 

546def test_send_message_notifications_muted(db): 

547 user1, token1 = generate_user() 

548 user2, token2 = generate_user() 

549 user3, token3 = generate_user() 

550 

551 make_friends(user1, user2) 

552 make_friends(user1, user3) 

553 make_friends(user2, user3) 

554 

555 send_message_notifications(empty_pb2.Empty()) 

556 process_jobs() 

557 

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

559 with session_scope() as session: 

560 assert ( 

561 session.execute( 

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

563 ).scalar_one() 

564 == 0 

565 ) 

566 

567 with conversations_session(token1) as c: 

568 group_chat_id = c.CreateGroupChat( 

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

570 ).group_chat_id 

571 

572 with conversations_session(token3) as c: 

573 # mute it for user 3 

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

575 

576 with conversations_session(token1) as c: 

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

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

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

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

581 

582 with conversations_session(token3) as c: 

583 group_chat_id = c.CreateGroupChat( 

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

585 ).group_chat_id 

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

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

588 

589 send_message_notifications(empty_pb2.Empty()) 

590 process_jobs() 

591 

592 # no emails sent out 

593 with session_scope() as session: 

594 assert ( 

595 session.execute( 

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

597 ).scalar_one() 

598 == 0 

599 ) 

600 

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

602 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

603 send_message_notifications(empty_pb2.Empty()) 

604 process_jobs() 

605 

606 with session_scope() as session: 

607 assert ( 

608 session.execute( 

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

610 ).scalar_one() 

611 == 1 

612 ) 

613 # delete them all 

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

615 

616 # shouldn't generate any more emails 

617 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

618 send_message_notifications(empty_pb2.Empty()) 

619 process_jobs() 

620 

621 with session_scope() as session: 

622 assert ( 

623 session.execute( 

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

625 ).scalar_one() 

626 == 0 

627 ) 

628 

629 

630def test_send_request_notifications_host_request(db): 

631 user1, token1 = generate_user() 

632 user2, token2 = generate_user() 

633 

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

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

636 

637 send_request_notifications(empty_pb2.Empty()) 

638 process_jobs() 

639 

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

641 with session_scope() as session: 

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

643 

644 # first test that sending host request creates email 

645 with requests_session(token1) as requests: 

646 host_request_id = requests.CreateHostRequest( 

647 requests_pb2.CreateHostRequestReq( 

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

649 ) 

650 ).host_request_id 

651 

652 with session_scope() as session: 

653 # delete send_email BackgroundJob created by CreateHostRequest 

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

655 

656 # check send_request_notifications successfully creates background job 

657 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

658 send_request_notifications(empty_pb2.Empty()) 

659 process_jobs() 

660 assert ( 

661 session.execute( 

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

663 ).scalar_one() 

664 == 1 

665 ) 

666 

667 # delete all BackgroundJobs 

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

669 

670 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

671 send_request_notifications(empty_pb2.Empty()) 

672 process_jobs() 

673 # should find no messages since host has already been notified 

674 assert ( 

675 session.execute( 

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

677 ).scalar_one() 

678 == 0 

679 ) 

680 

681 # then test that responding to host request creates email 

682 with requests_session(token2) as requests: 

683 requests.RespondHostRequest( 

684 requests_pb2.RespondHostRequestReq( 

685 host_request_id=host_request_id, 

686 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

687 text="Test request", 

688 ) 

689 ) 

690 

691 with session_scope() as session: 

692 # delete send_email BackgroundJob created by RespondHostRequest 

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

694 

695 # check send_request_notifications successfully creates background job 

696 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

697 send_request_notifications(empty_pb2.Empty()) 

698 process_jobs() 

699 assert ( 

700 session.execute( 

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

702 ).scalar_one() 

703 == 1 

704 ) 

705 

706 # delete all BackgroundJobs 

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

708 

709 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

710 send_request_notifications(empty_pb2.Empty()) 

711 process_jobs() 

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

713 assert ( 

714 session.execute( 

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

716 ).scalar_one() 

717 == 0 

718 ) 

719 

720 

721def test_send_message_notifications_seen(db): 

722 user1, token1 = generate_user() 

723 user2, token2 = generate_user() 

724 

725 make_friends(user1, user2) 

726 

727 send_message_notifications(empty_pb2.Empty()) 

728 

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

730 with session_scope() as session: 

731 assert ( 

732 session.execute( 

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

734 ).scalar_one() 

735 == 0 

736 ) 

737 

738 with conversations_session(token1) as c: 

739 group_chat_id = c.CreateGroupChat( 

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

741 ).group_chat_id 

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

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

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

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

746 

747 # user 2 now marks those messages as seen 

748 with conversations_session(token2) as c: 

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

750 c.MarkLastSeenGroupChat( 

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

752 ) 

753 

754 send_message_notifications(empty_pb2.Empty()) 

755 

756 # no emails sent out 

757 with session_scope() as session: 

758 assert ( 

759 session.execute( 

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

761 ).scalar_one() 

762 == 0 

763 ) 

764 

765 def now_30_min_in_future(): 

766 return now() + timedelta(minutes=30) 

767 

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

769 with patch("couchers.jobs.handlers.now", now_30_min_in_future): 

770 send_message_notifications(empty_pb2.Empty()) 

771 

772 with session_scope() as session: 

773 assert ( 

774 session.execute( 

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

776 ).scalar_one() 

777 == 0 

778 ) 

779 

780 

781def test_send_onboarding_emails(db): 

782 # needs to get first onboarding email 

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

784 

785 send_onboarding_emails(empty_pb2.Empty()) 

786 process_jobs() 

787 

788 with session_scope() as session: 

789 assert ( 

790 session.execute( 

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

792 ).scalar_one() 

793 == 1 

794 ) 

795 

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

797 user2, token2 = generate_user( 

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

799 ) 

800 

801 send_onboarding_emails(empty_pb2.Empty()) 

802 process_jobs() 

803 

804 with session_scope() as session: 

805 assert ( 

806 session.execute( 

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

808 ).scalar_one() 

809 == 1 

810 ) 

811 

812 # needs to get second onboarding email 

813 user3, token3 = generate_user( 

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

815 ) 

816 

817 send_onboarding_emails(empty_pb2.Empty()) 

818 process_jobs() 

819 

820 with session_scope() as session: 

821 assert ( 

822 session.execute( 

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

824 ).scalar_one() 

825 == 2 

826 ) 

827 

828 

829def test_send_reference_reminders(db): 

830 # need to test: 

831 # case 1: bidirectional (no emails) 

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

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

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

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

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

837 

838 send_reference_reminders(empty_pb2.Empty()) 

839 

840 # case 1: bidirectional (no emails) 

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

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

843 

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

845 # host 

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

847 # surfer 

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

849 

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

851 # host 

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

853 # surfer 

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

855 

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

857 # surfer 

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

859 # host 

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

861 

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

863 # surfer 

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

865 # host 

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

867 

868 make_user_block(user9, user10) 

869 

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

871 # host 

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

873 # surfer 

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

875 

876 with session_scope() as session: 

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

878 

879 # case 1: bidirectional (no emails) 

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

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

882 

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

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

885 

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

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

888 

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

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

891 

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

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

894 

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

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

897 

898 expected_emails = [ 

899 ( 

900 "user11@couchers.org.invalid", 

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

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

903 ), 

904 ( 

905 "user4@couchers.org.invalid", 

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

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

908 ), 

909 ( 

910 "user5@couchers.org.invalid", 

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

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

913 ), 

914 ( 

915 "user7@couchers.org.invalid", 

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

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

918 ), 

919 ( 

920 "user8@couchers.org.invalid", 

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

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

923 ), 

924 ] 

925 

926 send_reference_reminders(empty_pb2.Empty()) 

927 

928 while process_job(): 

929 pass 

930 

931 with session_scope() as session: 

932 emails = [ 

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

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

935 ] 

936 

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

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

939 

940 print(actual_addresses_and_subjects) 

941 print(expected_addresses_and_subjects) 

942 

943 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

944 

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

946 for find in search_strings: 

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

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

949 

950 

951def test_send_host_request_reminders(db): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

966 

967 with session_scope() as session: 

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

969 hr1 = create_host_request_by_date( 

970 session=session, 

971 surfer_user_id=user1.id, 

972 host_user_id=user2.id, 

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

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

975 status=HostRequestStatus.pending, 

976 host_sent_request_reminders=0, 

977 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

978 ) 

979 

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

981 hr2 = create_host_request_by_date( 

982 session=session, 

983 surfer_user_id=user3.id, 

984 host_user_id=user4.id, 

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

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

987 status=HostRequestStatus.pending, 

988 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS, 

989 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

990 ) 

991 

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

993 hr3 = create_host_request_by_date( 

994 session=session, 

995 surfer_user_id=user5.id, 

996 host_user_id=user6.id, 

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

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

999 status=HostRequestStatus.pending, 

1000 host_sent_request_reminders=0, 

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

1002 ) 

1003 

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

1005 hr4 = create_host_request_by_date( 

1006 session=session, 

1007 surfer_user_id=user7.id, 

1008 host_user_id=user8.id, 

1009 from_date=today(), 

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

1011 status=HostRequestStatus.pending, 

1012 host_sent_request_reminders=0, 

1013 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1014 ) 

1015 

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

1017 hr5 = create_host_request_by_date( 

1018 session=session, 

1019 surfer_user_id=user9.id, 

1020 host_user_id=user10.id, 

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

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

1023 status=HostRequestStatus.pending, 

1024 host_sent_request_reminders=0, 

1025 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1026 ) 

1027 

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

1029 hr6 = create_host_request_by_date( 

1030 session=session, 

1031 surfer_user_id=user11.id, 

1032 host_user_id=user12.id, 

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

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

1035 status=HostRequestStatus.accepted, 

1036 host_sent_request_reminders=0, 

1037 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1038 ) 

1039 

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

1041 hr7 = create_host_request_by_date( 

1042 session=session, 

1043 surfer_user_id=user13.id, 

1044 host_user_id=user14.id, 

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

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

1047 status=HostRequestStatus.pending, 

1048 host_sent_request_reminders=0, 

1049 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1050 ) 

1051 

1052 session.add( 

1053 Message( 

1054 time=now(), 

1055 conversation_id=hr7, 

1056 author_id=user14.id, 

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

1058 message_type=MessageType.text, 

1059 ) 

1060 ) 

1061 

1062 send_host_request_reminders(empty_pb2.Empty()) 

1063 

1064 while process_job(): 

1065 pass 

1066 

1067 with session_scope() as session: 

1068 emails = [ 

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

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

1071 ] 

1072 

1073 expected_emails = [ 

1074 ( 

1075 "user2@couchers.org.invalid", 

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

1077 ("Please respond to the request!", "User 1"), 

1078 ) 

1079 ] 

1080 

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

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

1083 

1084 print(actual_addresses_and_subjects) 

1085 print(expected_addresses_and_subjects) 

1086 

1087 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1088 

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

1090 for find in search_strings: 

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

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

1093 

1094 

1095def test_add_users_to_email_list(db): 

1096 new_config = config.copy() 

1097 new_config["LISTMONK_ENABLED"] = True 

1098 new_config["LISTMONK_BASE_URL"] = "https://example.com" 

1099 new_config["LISTMONK_API_USERNAME"] = "test_user" 

1100 new_config["LISTMONK_API_KEY"] = "dummy_api_key" 

1101 new_config["LISTMONK_LIST_ID"] = 6 

1102 

1103 with patch("couchers.jobs.handlers.config", new_config): 

1104 with patch("couchers.jobs.handlers.requests.post") as mock: 

1105 add_users_to_email_list(empty_pb2.Empty()) 

1106 mock.assert_not_called() 

1107 

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

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

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

1111 generate_user( 

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

1113 ) 

1114 

1115 with patch("couchers.jobs.handlers.requests.post") as mock: 

1116 ret = mock.return_value 

1117 ret.status_code = 200 

1118 add_users_to_email_list(empty_pb2.Empty()) 

1119 mock.assert_has_calls( 

1120 [ 

1121 call( 

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

1123 auth=("test_user", "dummy_api_key"), 

1124 json={ 

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

1126 "name": "Tester1", 

1127 "lists": [6], 

1128 "preconfirm_subscriptions": True, 

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

1130 "status": "enabled", 

1131 }, 

1132 timeout=10, 

1133 ), 

1134 call( 

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

1136 auth=("test_user", "dummy_api_key"), 

1137 json={ 

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

1139 "name": "Tester3 von test", 

1140 "lists": [6], 

1141 "preconfirm_subscriptions": True, 

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

1143 "status": "enabled", 

1144 }, 

1145 timeout=10, 

1146 ), 

1147 ], 

1148 any_order=True, 

1149 ) 

1150 

1151 with patch("couchers.jobs.handlers.requests.post") as mock: 

1152 add_users_to_email_list(empty_pb2.Empty()) 

1153 mock.assert_not_called() 

1154 

1155 

1156def test_update_recommendation_scores(db): 

1157 update_recommendation_scores(empty_pb2.Empty()) 

1158 

1159 

1160def test_update_badges(db, push_collector): 

1161 user1, _ = generate_user() 

1162 user2, _ = generate_user() 

1163 user3, _ = generate_user() 

1164 user4, _ = generate_user(phone="+15555555555", phone_verification_verified=func.now()) 

1165 user5, _ = generate_user(phone="+15555555556", phone_verification_verified=func.now()) 

1166 user6, _ = generate_user() 

1167 

1168 with session_scope() as session: 

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

1170 

1171 update_badges(empty_pb2.Empty()) 

1172 process_jobs() 

1173 

1174 with session_scope() as session: 

1175 badge_tuples = session.execute( 

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

1177 ).all() 

1178 

1179 expected = [ 

1180 (user1.id, "founder"), 

1181 (user1.id, "board_member"), 

1182 (user2.id, "founder"), 

1183 (user2.id, "board_member"), 

1184 (user4.id, "phone_verified"), 

1185 (user5.id, "phone_verified"), 

1186 ] 

1187 

1188 assert badge_tuples == expected 

1189 

1190 print(push_collector.pushes) 

1191 

1192 push_collector.assert_user_push_matches_fields( 

1193 user1.id, 

1194 ix=0, 

1195 title="The Founder badge was added to your profile", 

1196 body="Check out your profile to see the new badge!", 

1197 ) 

1198 push_collector.assert_user_push_matches_fields( 

1199 user1.id, 

1200 ix=1, 

1201 title="The Board Member badge was added to your profile", 

1202 body="Check out your profile to see the new badge!", 

1203 ) 

1204 push_collector.assert_user_push_matches_fields( 

1205 user2.id, 

1206 ix=0, 

1207 title="The Founder badge was added to your profile", 

1208 body="Check out your profile to see the new badge!", 

1209 ) 

1210 push_collector.assert_user_push_matches_fields( 

1211 user2.id, 

1212 ix=1, 

1213 title="The Board Member badge was added to your profile", 

1214 body="Check out your profile to see the new badge!", 

1215 ) 

1216 push_collector.assert_user_push_matches_fields( 

1217 user4.id, 

1218 ix=0, 

1219 title="The Verified Phone badge was added to your profile", 

1220 body="Check out your profile to see the new badge!", 

1221 ) 

1222 push_collector.assert_user_push_matches_fields( 

1223 user5.id, 

1224 ix=0, 

1225 title="The Board Member badge was removed from your profile", 

1226 body="You can see all your badges on your profile.", 

1227 ) 

1228 push_collector.assert_user_push_matches_fields( 

1229 user5.id, 

1230 ix=1, 

1231 title="The Verified Phone badge was added to your profile", 

1232 body="Check out your profile to see the new badge!", 

1233 ) 

1234 

1235 

1236def test_send_request_notifications_blocked_users_no_notification(db): 

1237 """ 

1238 Regression test: send_request_notifications should not send notifications 

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

1240 """ 

1241 user1, token1 = generate_user() 

1242 user2, token2 = generate_user() 

1243 

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

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

1246 

1247 # Create a host request 

1248 with requests_session(token1) as requests: 

1249 host_request_id = requests.CreateHostRequest( 

1250 requests_pb2.CreateHostRequestReq( 

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

1252 ) 

1253 ).host_request_id 

1254 

1255 with session_scope() as session: 

1256 # delete send_email BackgroundJob created by CreateHostRequest 

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

1258 

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

1260 make_user_block(user2, user1) 

1261 

1262 with session_scope() as session: 

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

1264 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

1265 send_request_notifications(empty_pb2.Empty()) 

1266 process_jobs() 

1267 

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

1269 assert ( 

1270 session.execute( 

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

1272 ).scalar_one() 

1273 == 0 

1274 ), "No notification email should be sent when host has blocked surfer" 

1275 

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

1277 # First unblock 

1278 with session_scope() as session: 

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

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

1281 

1282 # Host responds 

1283 with requests_session(token2) as requests: 

1284 requests.RespondHostRequest( 

1285 requests_pb2.RespondHostRequestReq( 

1286 host_request_id=host_request_id, 

1287 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1288 text="Accepting your request", 

1289 ) 

1290 ) 

1291 

1292 with session_scope() as session: 

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

1294 

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

1296 make_user_block(user1, user2) 

1297 

1298 with session_scope() as session: 

1299 # check send_request_notifications does NOT create background job 

1300 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

1301 send_request_notifications(empty_pb2.Empty()) 

1302 process_jobs() 

1303 

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

1305 assert ( 

1306 session.execute( 

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

1308 ).scalar_one() 

1309 == 0 

1310 ), "No notification email should be sent when surfer has blocked host" 

1311 

1312 

1313def test_send_host_request_reminders_blocked_users_no_notification(db): 

1314 """ 

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

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

1317 """ 

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

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

1320 

1321 with session_scope() as session: 

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

1323 hr = create_host_request_by_date( 

1324 session=session, 

1325 surfer_user_id=user1.id, 

1326 host_user_id=user2.id, 

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

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

1329 status=HostRequestStatus.pending, 

1330 host_sent_request_reminders=0, 

1331 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1332 ) 

1333 

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

1335 send_host_request_reminders(empty_pb2.Empty()) 

1336 

1337 while process_job(): 

1338 pass 

1339 

1340 with session_scope() as session: 

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

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

1343 

1344 # Clean up emails and background jobs 

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

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

1347 

1348 # Reset the reminder counter so we can test again 

1349 from couchers.models import HostRequest 

1350 

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

1352 host_request.host_sent_request_reminders = 0 

1353 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL 

1354 

1355 # Now have the host block the surfer 

1356 make_user_block(user2, user1) 

1357 

1358 send_host_request_reminders(empty_pb2.Empty()) 

1359 

1360 while process_job(): 

1361 pass 

1362 

1363 with session_scope() as session: 

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

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

1366 

1367 

1368def test_send_message_notifications_blocked_users_no_notification(db): 

1369 """ 

1370 Regression test: send_message_notifications should not send notifications 

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

1372 """ 

1373 user1, token1 = generate_user() 

1374 user2, token2 = generate_user() 

1375 

1376 make_friends(user1, user2) 

1377 

1378 # Create a group chat and send messages 

1379 with conversations_session(token1) as c: 

1380 group_chat_id = c.CreateGroupChat( 

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

1382 ).group_chat_id 

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

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

1385 

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

1387 with session_scope() as session: 

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

1389 

1390 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

1391 send_message_notifications(empty_pb2.Empty()) 

1392 process_jobs() 

1393 

1394 with session_scope() as session: 

1395 email_job_count = session.execute( 

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

1397 ).scalar_one() 

1398 assert email_job_count == 1, "Expected 1 notification email before blocking" 

1399 

1400 # Clean up 

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

1402 

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

1404 with session_scope() as session: 

1405 from couchers.models import User 

1406 

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

1408 u2.last_notified_message_id = 0 

1409 

1410 # Now have user2 block user1 

1411 make_user_block(user2, user1) 

1412 

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

1414 # since user2 has blocked user1 

1415 with session_scope() as session: 

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

1417 

1418 with patch("couchers.jobs.handlers.now", now_5_min_in_future): 

1419 send_message_notifications(empty_pb2.Empty()) 

1420 process_jobs() 

1421 

1422 with session_scope() as session: 

1423 email_job_count = session.execute( 

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

1425 ).scalar_one() 

1426 assert email_job_count == 0, "No notification email should be sent when recipient has blocked sender" 

1427 

1428 

1429def test_update_badges_volunteers(db, push_collector): 

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

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

1432 user1, _ = generate_user() 

1433 user2, _ = generate_user() 

1434 user3, _ = generate_user() 

1435 user4, _ = generate_user() 

1436 user5, _ = generate_user() 

1437 user6, _ = generate_user() 

1438 

1439 with session_scope() as session: 

1440 # user3: active volunteer (stopped_volunteering is null) 

1441 session.add( 

1442 Volunteer( 

1443 user_id=user3.id, 

1444 role="Developer", 

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

1446 stopped_volunteering=None, 

1447 ) 

1448 ) 

1449 

1450 # user4: past volunteer (stopped_volunteering is set) 

1451 session.add( 

1452 Volunteer( 

1453 user_id=user4.id, 

1454 role="Designer", 

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

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

1457 ) 

1458 ) 

1459 

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

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

1462 

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

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

1465 

1466 update_badges(empty_pb2.Empty()) 

1467 process_jobs() 

1468 

1469 with session_scope() as session: 

1470 # Check user3 has volunteer badge 

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

1472 assert "volunteer" in user3_badges 

1473 assert "past_volunteer" not in user3_badges 

1474 

1475 # Check user4 has past_volunteer badge 

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

1477 assert "past_volunteer" in user4_badges 

1478 assert "volunteer" not in user4_badges 

1479 

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

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

1482 assert "volunteer" not in user5_badges 

1483 

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

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

1486 assert "past_volunteer" not in user6_badges 

1487 

1488 # Check notifications for volunteer badge users 

1489 push_collector.assert_user_has_single_matching( 

1490 user3.id, 

1491 title="The Active Volunteer badge was added to your profile", 

1492 body="Check out your profile to see the new badge!", 

1493 ) 

1494 push_collector.assert_user_has_single_matching( 

1495 user4.id, 

1496 title="The Past Volunteer badge was added to your profile", 

1497 body="Check out your profile to see the new badge!", 

1498 ) 

1499 push_collector.assert_user_has_single_matching( 

1500 user5.id, 

1501 title="The Active Volunteer badge was removed from your profile", 

1502 body="You can see all your badges on your profile.", 

1503 ) 

1504 push_collector.assert_user_has_single_matching( 

1505 user6.id, 

1506 title="The Past Volunteer badge was removed from your profile", 

1507 body="You can see all your badges on your profile.", 

1508 ) 

1509 

1510 

1511def test_update_badges_volunteer_status_change(db, push_collector): 

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

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

1514 user1, _ = generate_user() 

1515 user2, _ = generate_user() 

1516 user3, _ = generate_user() 

1517 

1518 with session_scope() as session: 

1519 # user3: start as active volunteer 

1520 session.add( 

1521 Volunteer( 

1522 user_id=user3.id, 

1523 role="Developer", 

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

1525 stopped_volunteering=None, 

1526 ) 

1527 ) 

1528 

1529 update_badges(empty_pb2.Empty()) 

1530 process_jobs() 

1531 

1532 with session_scope() as session: 

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

1534 assert "volunteer" in user3_badges 

1535 assert "past_volunteer" not in user3_badges 

1536 

1537 push_collector.assert_user_has_single_matching( 

1538 user3.id, 

1539 title="The Active Volunteer badge was added to your profile", 

1540 body="Check out your profile to see the new badge!", 

1541 ) 

1542 

1543 # Now change the volunteer to past volunteer 

1544 with session_scope() as session: 

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

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

1547 

1548 update_badges(empty_pb2.Empty()) 

1549 process_jobs() 

1550 

1551 with session_scope() as session: 

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

1553 assert "volunteer" not in user3_badges 

1554 assert "past_volunteer" in user3_badges 

1555 

1556 # Check both badges were updated 

1557 push_collector.assert_user_push_matches_fields( 

1558 user3.id, 

1559 ix=1, 

1560 title="The Active Volunteer badge was removed from your profile", 

1561 body="You can see all your badges on your profile.", 

1562 ) 

1563 push_collector.assert_user_push_matches_fields( 

1564 user3.id, 

1565 ix=2, 

1566 title="The Past Volunteer badge was added to your profile", 

1567 body="Check out your profile to see the new badge!", 

1568 ) 

1569 

1570 

1571def test_send_message_notifications_empty_unseen_simple(monkeypatch): 

1572 class DummyUser: 

1573 id = 1 

1574 is_visible = True 

1575 last_notified_message_id = 0 

1576 

1577 class FirstResult: 

1578 def scalars(self): 

1579 return self 

1580 

1581 def unique(self): 

1582 return [DummyUser()] 

1583 

1584 class SecondResult: 

1585 def all(self): 

1586 return [] 

1587 

1588 class DummySession: 

1589 def __init__(self): 

1590 self.calls = 0 

1591 

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

1593 self.calls += 1 

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

1595 

1596 def commit(self): 

1597 pass 

1598 

1599 def flush(self): 

1600 pass 

1601 

1602 def fake_session_scope(): 

1603 class Ctx: 

1604 def __enter__(self): 

1605 return DummySession() 

1606 

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

1608 pass 

1609 

1610 return Ctx() 

1611 

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

1613 

1614 handlers.send_message_notifications(Empty())