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

687 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1from datetime import date, datetime, 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 sqlalchemy import select 

10from sqlalchemy.sql import delete, func 

11 

12import couchers.jobs.worker 

13from couchers.config import config 

14from couchers.constants import HOST_REQUEST_MAX_REMINDERS, HOST_REQUEST_REMINDER_INTERVAL 

15from couchers.crypto import urlsafe_secure_token 

16from couchers.db import session_scope 

17from couchers.email import queue_email 

18from couchers.email.dev import print_dev_email 

19from couchers.jobs import handlers 

20from couchers.jobs.definitions import Job 

21from couchers.jobs.enqueue import queue_job 

22from couchers.jobs.handlers import ( 

23 add_users_to_email_list, 

24 enforce_community_membership, 

25 purge_account_deletion_tokens, 

26 purge_login_tokens, 

27 purge_password_reset_tokens, 

28 send_host_request_reminders, 

29 send_message_notifications, 

30 send_onboarding_emails, 

31 send_reference_reminders, 

32 send_request_notifications, 

33 update_badges, 

34 update_recommendation_scores, 

35) 

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

37from couchers.materialized_views import refresh_materialized_views 

38from couchers.metrics import create_prometheus_server 

39from couchers.models import ( 

40 AccountDeletionToken, 

41 BackgroundJob, 

42 BackgroundJobState, 

43 Email, 

44 HostRequest, 

45 HostRequestStatus, 

46 LoginToken, 

47 Message, 

48 MessageType, 

49 PasswordResetToken, 

50 UserBadge, 

51 UserBlock, 

52 Volunteer, 

53) 

54from couchers.proto import conversations_pb2, requests_pb2 

55from couchers.utils import now, today 

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

57from tests.fixtures.misc import PushCollector, process_jobs 

58from tests.fixtures.sessions import conversations_session, requests_session 

59from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date 

60from tests.test_requests import valid_request_text 

61 

62 

63def now_5_min_in_future() -> datetime: 

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

65 

66 

67@pytest.fixture(autouse=True) 

68def _(testconfig): 

69 pass 

70 

71 

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

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

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

75 assert string_to_check in metrics_string 

76 

77 

78def test_email_job(db): 

79 with session_scope() as session: 

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

81 

82 def mock_print_dev_email( 

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

84 ): 

85 assert sender_name == "sender_name" 

86 assert sender_email == "sender_email" 

87 assert recipient == "recipient" 

88 assert subject == "subject" 

89 assert plain == "plain" 

90 assert html == "html" 

91 return print_dev_email( 

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

93 ) 

94 

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

96 process_job() 

97 

98 with session_scope() as session: 

99 assert ( 

100 session.execute( 

101 select(func.count()) 

102 .select_from(BackgroundJob) 

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

104 ).scalar_one() 

105 == 1 

106 ) 

107 assert ( 

108 session.execute( 

109 select(func.count()) 

110 .select_from(BackgroundJob) 

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

112 ).scalar_one() 

113 == 0 

114 ) 

115 

116 

117def test_purge_login_tokens(db): 

118 user, api_token = generate_user() 

119 

120 with session_scope() as session: 

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

122 session.add(login_token) 

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

124 

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

126 process_job() 

127 

128 with session_scope() as session: 

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

130 

131 with session_scope() as session: 

132 assert ( 

133 session.execute( 

134 select(func.count()) 

135 .select_from(BackgroundJob) 

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

137 ).scalar_one() 

138 == 1 

139 ) 

140 assert ( 

141 session.execute( 

142 select(func.count()) 

143 .select_from(BackgroundJob) 

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

145 ).scalar_one() 

146 == 0 

147 ) 

148 

149 

150def test_purge_password_reset_tokens(db): 

151 user, api_token = generate_user() 

152 

153 with session_scope() as session: 

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

155 session.add(password_reset_token) 

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

157 

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

159 process_job() 

160 

161 with session_scope() as session: 

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

163 

164 with session_scope() as session: 

165 assert ( 

166 session.execute( 

167 select(func.count()) 

168 .select_from(BackgroundJob) 

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

170 ).scalar_one() 

171 == 1 

172 ) 

173 assert ( 

174 session.execute( 

175 select(func.count()) 

176 .select_from(BackgroundJob) 

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

178 ).scalar_one() 

179 == 0 

180 ) 

181 

182 

183def test_purge_account_deletion_tokens(db): 

184 user, api_token = generate_user() 

185 user2, api_token2 = generate_user() 

186 user3, api_token3 = generate_user() 

187 

188 with session_scope() as session: 

189 """ 

190 3 cases: 

191 1) Token is valid 

192 2) Token expired but account retrievable 

193 3) Account is irretrievable (and expired) 

194 """ 

195 account_deletion_tokens = [ 

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

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

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

199 ] 

200 for token in account_deletion_tokens: 

201 session.add(token) 

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

203 

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

205 process_job() 

206 

207 with session_scope() as session: 

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

209 

210 with session_scope() as session: 

211 assert ( 

212 session.execute( 

213 select(func.count()) 

214 .select_from(BackgroundJob) 

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

216 ).scalar_one() 

217 == 1 

218 ) 

219 assert ( 

220 session.execute( 

221 select(func.count()) 

222 .select_from(BackgroundJob) 

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

224 ).scalar_one() 

225 == 0 

226 ) 

227 

228 

229def test_enforce_community_memberships(db): 

230 with session_scope() as session: 

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

232 process_job() 

233 

234 with session_scope() as session: 

235 assert ( 

236 session.execute( 

237 select(func.count()) 

238 .select_from(BackgroundJob) 

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

240 ).scalar_one() 

241 == 1 

242 ) 

243 assert ( 

244 session.execute( 

245 select(func.count()) 

246 .select_from(BackgroundJob) 

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

248 ).scalar_one() 

249 == 0 

250 ) 

251 

252 

253def test_refresh_materialized_views(db): 

254 with session_scope() as session: 

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

256 

257 process_job() 

258 

259 with session_scope() as session: 

260 assert ( 

261 session.execute( 

262 select(func.count()) 

263 .select_from(BackgroundJob) 

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

265 ).scalar_one() 

266 == 1 

267 ) 

268 assert ( 

269 session.execute( 

270 select(func.count()) 

271 .select_from(BackgroundJob) 

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

273 ).scalar_one() 

274 == 0 

275 ) 

276 

277 

278def test_service_jobs(db): 

279 with session_scope() as session: 

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

281 

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

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

284 class HitSleep(Exception): 

285 pass 

286 

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

288 def raising_sleep(seconds): 

289 raise HitSleep() 

290 

291 with pytest.raises(HitSleep): 

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

293 service_jobs() 

294 

295 with session_scope() as session: 

296 assert ( 

297 session.execute( 

298 select(func.count()) 

299 .select_from(BackgroundJob) 

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

301 ).scalar_one() 

302 == 1 

303 ) 

304 assert ( 

305 session.execute( 

306 select(func.count()) 

307 .select_from(BackgroundJob) 

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

309 ).scalar_one() 

310 == 0 

311 ) 

312 

313 

314def test_scheduler(db, monkeypatch): 

315 def purge_login_tokens(payload: empty_pb2.Empty): 

316 return 

317 

318 def send_message_notifications(payload: empty_pb2.Empty): 

319 return 

320 

321 MOCK_JOBS = { 

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

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

324 } 

325 

326 current_time = 0 

327 end_time = 70 

328 

329 class EndOfTime(Exception): 

330 pass 

331 

332 def mock_monotonic(): 

333 return current_time 

334 

335 def mock_sleep(seconds): 

336 nonlocal current_time 

337 current_time += seconds 

338 if current_time > end_time: 

339 raise EndOfTime() 

340 

341 realized_schedule = [] 

342 

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

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

345 _run_job_and_schedule(sched, job, frequency) 

346 

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

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

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

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

351 

352 with pytest.raises(EndOfTime): 

353 run_scheduler() 

354 

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

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

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

358 

359 assert realized_schedule_indices == [ 

360 (0.0, 0), 

361 (0.0, 1), 

362 (7.0, 0), 

363 (11.0, 1), 

364 (14.0, 0), 

365 (21.0, 0), 

366 (22.0, 1), 

367 (28.0, 0), 

368 (33.0, 1), 

369 (35.0, 0), 

370 (42.0, 0), 

371 (44.0, 1), 

372 (49.0, 0), 

373 (55.0, 1), 

374 (56.0, 0), 

375 (63.0, 0), 

376 (66.0, 1), 

377 (70.0, 0), 

378 ] 

379 

380 with session_scope() as session: 

381 assert ( 

382 session.execute( 

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

384 ).scalar_one() 

385 == 18 

386 ) 

387 assert ( 

388 session.execute( 

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

390 ).scalar_one() 

391 == 0 

392 ) 

393 

394 

395def test_job_retry(db): 

396 called_count = 0 

397 

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

399 nonlocal called_count 

400 called_count += 1 

401 raise Exception() 

402 

403 with session_scope() as session: 

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

405 

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

407 "mock_job": Job(mock_job), 

408 } 

409 create_prometheus_server(port=8000) 

410 

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

412 new_config = config.copy() 

413 new_config["IN_TEST"] = False 

414 

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

416 process_job() 

417 with session_scope() as session: 

418 assert ( 

419 session.execute( 

420 select(func.count()) 

421 .select_from(BackgroundJob) 

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

423 ).scalar_one() 

424 == 1 

425 ) 

426 assert ( 

427 session.execute( 

428 select(func.count()) 

429 .select_from(BackgroundJob) 

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

431 ).scalar_one() 

432 == 0 

433 ) 

434 

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

436 process_job() 

437 with session_scope() as session: 

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

439 process_job() 

440 with session_scope() as session: 

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

442 process_job() 

443 with session_scope() as session: 

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

445 process_job() 

446 

447 with session_scope() as session: 

448 assert ( 

449 session.execute( 

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

451 ).scalar_one() 

452 == 1 

453 ) 

454 assert ( 

455 session.execute( 

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

457 ).scalar_one() 

458 == 0 

459 ) 

460 

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

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

463 

464 

465def test_no_jobs_no_problem(db): 

466 with session_scope() as session: 

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

468 

469 assert not process_job() 

470 

471 with session_scope() as session: 

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

473 

474 

475def test_send_message_notifications_basic(db, moderator): 

476 user1, token1 = generate_user() 

477 user2, token2 = generate_user() 

478 user3, token3 = generate_user() 

479 

480 make_friends(user1, user2) 

481 make_friends(user1, user3) 

482 make_friends(user2, user3) 

483 

484 send_message_notifications(empty_pb2.Empty()) 

485 process_jobs() 

486 

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

488 with session_scope() as session: 

489 assert ( 

490 session.execute( 

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

492 ).scalar_one() 

493 == 0 

494 ) 

495 

496 with conversations_session(token1) as c: 

497 group_chat_id1 = c.CreateGroupChat( 

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

499 ).group_chat_id 

500 moderator.approve_group_chat(group_chat_id1) 

501 

502 with conversations_session(token1) as c: 

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

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

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

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

507 

508 with conversations_session(token3) as c: 

509 group_chat_id2 = c.CreateGroupChat( 

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

511 ).group_chat_id 

512 moderator.approve_group_chat(group_chat_id2) 

513 

514 with conversations_session(token3) as c: 

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

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

517 

518 send_message_notifications(empty_pb2.Empty()) 

519 process_jobs() 

520 

521 # no emails sent out 

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 == 0 

528 ) 

529 

530 # this should generate emails for both user2 and user3 

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

532 send_message_notifications(empty_pb2.Empty()) 

533 process_jobs() 

534 

535 with session_scope() as session: 

536 assert ( 

537 session.execute( 

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

539 ).scalar_one() 

540 == 2 

541 ) 

542 # delete them all 

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

544 

545 # shouldn't generate any more emails 

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

547 send_message_notifications(empty_pb2.Empty()) 

548 process_jobs() 

549 

550 with session_scope() as session: 

551 assert ( 

552 session.execute( 

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

554 ).scalar_one() 

555 == 0 

556 ) 

557 

558 

559def test_send_message_notifications_muted(db, moderator): 

560 user1, token1 = generate_user() 

561 user2, token2 = generate_user() 

562 user3, token3 = generate_user() 

563 

564 make_friends(user1, user2) 

565 make_friends(user1, user3) 

566 make_friends(user2, user3) 

567 

568 send_message_notifications(empty_pb2.Empty()) 

569 process_jobs() 

570 

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

572 with session_scope() as session: 

573 assert ( 

574 session.execute( 

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

576 ).scalar_one() 

577 == 0 

578 ) 

579 

580 with conversations_session(token1) as c: 

581 group_chat_id = c.CreateGroupChat( 

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

583 ).group_chat_id 

584 moderator.approve_group_chat(group_chat_id) 

585 

586 with conversations_session(token3) as c: 

587 # mute it for user 3 

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

589 

590 with conversations_session(token1) as c: 

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

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

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

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

595 

596 with conversations_session(token3) as c: 

597 group_chat_id = c.CreateGroupChat( 

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

599 ).group_chat_id 

600 moderator.approve_group_chat(group_chat_id) 

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

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

603 

604 send_message_notifications(empty_pb2.Empty()) 

605 process_jobs() 

606 

607 # no emails sent out 

608 with session_scope() as session: 

609 assert ( 

610 session.execute( 

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

612 ).scalar_one() 

613 == 0 

614 ) 

615 

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

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 == 1 

627 ) 

628 # delete them all 

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

630 

631 # shouldn't generate any more emails 

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

633 send_message_notifications(empty_pb2.Empty()) 

634 process_jobs() 

635 

636 with session_scope() as session: 

637 assert ( 

638 session.execute( 

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

640 ).scalar_one() 

641 == 0 

642 ) 

643 

644 

645def test_send_request_notifications_host_request(db, moderator): 

646 user1, token1 = generate_user() 

647 user2, token2 = generate_user() 

648 

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

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

651 

652 send_request_notifications(empty_pb2.Empty()) 

653 process_jobs() 

654 

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

656 with session_scope() as session: 

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

658 

659 # first test that sending host request creates email 

660 with requests_session(token1) as requests: 

661 host_request_id = requests.CreateHostRequest( 

662 requests_pb2.CreateHostRequestReq( 

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

664 ) 

665 ).host_request_id 

666 moderator.approve_host_request(host_request_id) 

667 

668 with session_scope() as session: 

669 # delete send_email BackgroundJob created by CreateHostRequest 

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

671 

672 # check send_request_notifications successfully creates background job 

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

674 send_request_notifications(empty_pb2.Empty()) 

675 process_jobs() 

676 assert ( 

677 session.execute( 

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

679 ).scalar_one() 

680 == 1 

681 ) 

682 

683 # delete all BackgroundJobs 

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

685 

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

687 send_request_notifications(empty_pb2.Empty()) 

688 process_jobs() 

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

690 assert ( 

691 session.execute( 

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

693 ).scalar_one() 

694 == 0 

695 ) 

696 

697 # then test that responding to host request creates email 

698 with requests_session(token2) as requests: 

699 requests.RespondHostRequest( 

700 requests_pb2.RespondHostRequestReq( 

701 host_request_id=host_request_id, 

702 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

703 text="Test request", 

704 ) 

705 ) 

706 

707 with session_scope() as session: 

708 # delete send_email BackgroundJob created by RespondHostRequest 

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

710 

711 # check send_request_notifications successfully creates background job 

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

713 send_request_notifications(empty_pb2.Empty()) 

714 process_jobs() 

715 assert ( 

716 session.execute( 

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

718 ).scalar_one() 

719 == 1 

720 ) 

721 

722 # delete all BackgroundJobs 

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

724 

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

726 send_request_notifications(empty_pb2.Empty()) 

727 process_jobs() 

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

729 assert ( 

730 session.execute( 

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

732 ).scalar_one() 

733 == 0 

734 ) 

735 

736 

737def test_send_message_notifications_seen(db, moderator): 

738 user1, token1 = generate_user() 

739 user2, token2 = generate_user() 

740 

741 make_friends(user1, user2) 

742 

743 send_message_notifications(empty_pb2.Empty()) 

744 

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

746 with session_scope() as session: 

747 assert ( 

748 session.execute( 

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

750 ).scalar_one() 

751 == 0 

752 ) 

753 

754 with conversations_session(token1) as c: 

755 group_chat_id = c.CreateGroupChat( 

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

757 ).group_chat_id 

758 moderator.approve_group_chat(group_chat_id) 

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

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

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

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

763 

764 # user 2 now marks those messages as seen 

765 with conversations_session(token2) as c: 

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

767 c.MarkLastSeenGroupChat( 

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

769 ) 

770 

771 send_message_notifications(empty_pb2.Empty()) 

772 

773 # no emails sent out 

774 with session_scope() as session: 

775 assert ( 

776 session.execute( 

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

778 ).scalar_one() 

779 == 0 

780 ) 

781 

782 def now_30_min_in_future(): 

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

784 

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

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

787 send_message_notifications(empty_pb2.Empty()) 

788 

789 with session_scope() as session: 

790 assert ( 

791 session.execute( 

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

793 ).scalar_one() 

794 == 0 

795 ) 

796 

797 

798def test_send_onboarding_emails(db): 

799 # needs to get first onboarding email 

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

801 

802 send_onboarding_emails(empty_pb2.Empty()) 

803 process_jobs() 

804 

805 with session_scope() as session: 

806 assert ( 

807 session.execute( 

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

809 ).scalar_one() 

810 == 1 

811 ) 

812 

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

814 user2, token2 = generate_user( 

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

816 ) 

817 

818 send_onboarding_emails(empty_pb2.Empty()) 

819 process_jobs() 

820 

821 with session_scope() as session: 

822 assert ( 

823 session.execute( 

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

825 ).scalar_one() 

826 == 1 

827 ) 

828 

829 # needs to get second onboarding email 

830 user3, token3 = generate_user( 

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

832 ) 

833 

834 send_onboarding_emails(empty_pb2.Empty()) 

835 process_jobs() 

836 

837 with session_scope() as session: 

838 assert ( 

839 session.execute( 

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

841 ).scalar_one() 

842 == 2 

843 ) 

844 

845 

846def test_send_reference_reminders(db): 

847 # need to test: 

848 # case 1: bidirectional (no emails) 

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

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

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

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

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

854 

855 send_reference_reminders(empty_pb2.Empty()) 

856 

857 # case 1: bidirectional (no emails) 

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

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

860 

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

862 # host 

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

864 # surfer 

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

866 

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

868 # host 

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

870 # surfer 

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

872 

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

874 # surfer 

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

876 # host 

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

878 

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

880 # surfer 

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

882 # host 

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

884 

885 make_user_block(user9, user10) 

886 

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

888 # host 

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

890 # surfer 

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

892 

893 with session_scope() as session: 

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

895 

896 # case 1: bidirectional (no emails) 

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

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

899 

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

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

902 

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

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

905 

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

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

908 

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

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

911 

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

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

914 

915 expected_emails = [ 

916 ( 

917 "user11@couchers.org.invalid", 

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

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

920 ), 

921 ( 

922 "user4@couchers.org.invalid", 

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

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

925 ), 

926 ( 

927 "user5@couchers.org.invalid", 

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

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

930 ), 

931 ( 

932 "user7@couchers.org.invalid", 

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

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

935 ), 

936 ( 

937 "user8@couchers.org.invalid", 

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

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

940 ), 

941 ] 

942 

943 send_reference_reminders(empty_pb2.Empty()) 

944 

945 while process_job(): 

946 pass 

947 

948 with session_scope() as session: 

949 emails = [ 

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

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

952 ] 

953 

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

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

956 

957 print(actual_addresses_and_subjects) 

958 print(expected_addresses_and_subjects) 

959 

960 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

961 

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

963 for find in search_strings: 

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

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

966 

967 

968def test_send_host_request_reminders(db, moderator): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

983 

984 with session_scope() as session: 

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

986 hr1 = create_host_request_by_date( 

987 session=session, 

988 surfer_user_id=user1.id, 

989 host_user_id=user2.id, 

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

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

992 status=HostRequestStatus.pending, 

993 host_sent_request_reminders=0, 

994 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

995 ) 

996 

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

998 hr2 = create_host_request_by_date( 

999 session=session, 

1000 surfer_user_id=user3.id, 

1001 host_user_id=user4.id, 

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

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

1004 status=HostRequestStatus.pending, 

1005 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS, 

1006 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1007 ) 

1008 

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

1010 hr3 = create_host_request_by_date( 

1011 session=session, 

1012 surfer_user_id=user5.id, 

1013 host_user_id=user6.id, 

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

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

1016 status=HostRequestStatus.pending, 

1017 host_sent_request_reminders=0, 

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

1019 ) 

1020 

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

1022 hr4 = create_host_request_by_date( 

1023 session=session, 

1024 surfer_user_id=user7.id, 

1025 host_user_id=user8.id, 

1026 from_date=today(), 

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

1028 status=HostRequestStatus.pending, 

1029 host_sent_request_reminders=0, 

1030 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1031 ) 

1032 

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

1034 hr5 = create_host_request_by_date( 

1035 session=session, 

1036 surfer_user_id=user9.id, 

1037 host_user_id=user10.id, 

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

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

1040 status=HostRequestStatus.pending, 

1041 host_sent_request_reminders=0, 

1042 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1043 ) 

1044 

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

1046 hr6 = create_host_request_by_date( 

1047 session=session, 

1048 surfer_user_id=user11.id, 

1049 host_user_id=user12.id, 

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

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

1052 status=HostRequestStatus.accepted, 

1053 host_sent_request_reminders=0, 

1054 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1055 ) 

1056 

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

1058 hr7 = create_host_request_by_date( 

1059 session=session, 

1060 surfer_user_id=user13.id, 

1061 host_user_id=user14.id, 

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

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

1064 status=HostRequestStatus.pending, 

1065 host_sent_request_reminders=0, 

1066 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1067 ) 

1068 

1069 msg = Message( 

1070 conversation_id=hr7, 

1071 author_id=user14.id, 

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

1073 message_type=MessageType.text, 

1074 ) 

1075 msg.time = now() 

1076 session.add(msg) 

1077 

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

1079 moderator.approve_host_request(hr1) 

1080 moderator.approve_host_request(hr2) 

1081 moderator.approve_host_request(hr3) 

1082 moderator.approve_host_request(hr4) 

1083 moderator.approve_host_request(hr5) 

1084 moderator.approve_host_request(hr6) 

1085 moderator.approve_host_request(hr7) 

1086 

1087 send_host_request_reminders(empty_pb2.Empty()) 

1088 

1089 while process_job(): 

1090 pass 

1091 

1092 with session_scope() as session: 

1093 emails = [ 

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

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

1096 ] 

1097 

1098 expected_emails = [ 

1099 ( 

1100 "user2@couchers.org.invalid", 

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

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

1103 ) 

1104 ] 

1105 

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

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

1108 

1109 print(actual_addresses_and_subjects) 

1110 print(expected_addresses_and_subjects) 

1111 

1112 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1113 

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

1115 for find in search_strings: 

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

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

1118 

1119 

1120def test_add_users_to_email_list(db): 

1121 new_config = config.copy() 

1122 new_config["LISTMONK_ENABLED"] = True 

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

1124 new_config["LISTMONK_API_USERNAME"] = "test_user" 

1125 new_config["LISTMONK_API_KEY"] = "dummy_api_key" 

1126 new_config["LISTMONK_LIST_ID"] = 6 

1127 

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

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

1130 add_users_to_email_list(empty_pb2.Empty()) 

1131 mock.assert_not_called() 

1132 

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

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

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

1136 generate_user( 

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

1138 ) 

1139 

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

1141 ret = mock.return_value 

1142 ret.status_code = 200 

1143 add_users_to_email_list(empty_pb2.Empty()) 

1144 mock.assert_has_calls( 

1145 [ 

1146 call( 

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

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

1149 json={ 

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

1151 "name": "Tester1", 

1152 "lists": [6], 

1153 "preconfirm_subscriptions": True, 

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

1155 "status": "enabled", 

1156 }, 

1157 timeout=10, 

1158 ), 

1159 call( 

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

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

1162 json={ 

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

1164 "name": "Tester3 von test", 

1165 "lists": [6], 

1166 "preconfirm_subscriptions": True, 

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

1168 "status": "enabled", 

1169 }, 

1170 timeout=10, 

1171 ), 

1172 ], 

1173 any_order=True, 

1174 ) 

1175 

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

1177 add_users_to_email_list(empty_pb2.Empty()) 

1178 mock.assert_not_called() 

1179 

1180 

1181def test_update_recommendation_scores(db): 

1182 update_recommendation_scores(empty_pb2.Empty()) 

1183 

1184 

1185def test_update_badges(db, push_collector: PushCollector): 

1186 user1, _ = generate_user(last_donated=None) 

1187 user2, _ = generate_user(last_donated=None) 

1188 user3, _ = generate_user(last_donated=None) 

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

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

1191 user6, _ = generate_user(last_donated=None) 

1192 

1193 with session_scope() as session: 

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

1195 

1196 update_badges(empty_pb2.Empty()) 

1197 process_jobs() 

1198 

1199 with session_scope() as session: 

1200 badge_tuples = session.execute( 

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

1202 ).all() 

1203 

1204 expected = [ 

1205 (user1.id, "founder"), 

1206 (user1.id, "board_member"), 

1207 (user2.id, "founder"), 

1208 (user2.id, "board_member"), 

1209 (user4.id, "phone_verified"), 

1210 (user5.id, "phone_verified"), 

1211 ] 

1212 

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

1214 

1215 print(push_collector.by_user) 

1216 

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

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

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

1220 

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

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

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

1224 

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

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

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

1228 

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

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

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

1232 

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

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

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

1236 

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

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

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

1240 

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

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

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

1244 

1245 

1246def test_send_request_notifications_blocked_users_no_notification(db, moderator): 

1247 """ 

1248 Regression test: send_request_notifications should not send notifications 

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

1250 """ 

1251 user1, token1 = generate_user() 

1252 user2, token2 = generate_user() 

1253 

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

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

1256 

1257 # Create a host request 

1258 with requests_session(token1) as requests: 

1259 host_request_id = requests.CreateHostRequest( 

1260 requests_pb2.CreateHostRequestReq( 

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

1262 ) 

1263 ).host_request_id 

1264 moderator.approve_host_request(host_request_id) 

1265 

1266 with session_scope() as session: 

1267 # delete send_email BackgroundJob created by CreateHostRequest 

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

1269 

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

1271 make_user_block(user2, user1) 

1272 

1273 with session_scope() as session: 

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

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

1276 send_request_notifications(empty_pb2.Empty()) 

1277 process_jobs() 

1278 

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

1280 assert ( 

1281 session.execute( 

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

1283 ).scalar_one() 

1284 == 0 

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

1286 

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

1288 # First unblock 

1289 with session_scope() as session: 

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

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

1292 

1293 # Host responds 

1294 with requests_session(token2) as requests: 

1295 requests.RespondHostRequest( 

1296 requests_pb2.RespondHostRequestReq( 

1297 host_request_id=host_request_id, 

1298 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1299 text="Accepting your request", 

1300 ) 

1301 ) 

1302 

1303 with session_scope() as session: 

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

1305 

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

1307 make_user_block(user1, user2) 

1308 

1309 with session_scope() as session: 

1310 # check send_request_notifications does NOT create background job 

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

1312 send_request_notifications(empty_pb2.Empty()) 

1313 process_jobs() 

1314 

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

1316 assert ( 

1317 session.execute( 

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

1319 ).scalar_one() 

1320 == 0 

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

1322 

1323 

1324def test_send_host_request_reminders_blocked_users_no_notification(db, moderator): 

1325 """ 

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

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

1328 """ 

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

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

1331 

1332 with session_scope() as session: 

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

1334 hr = create_host_request_by_date( 

1335 session=session, 

1336 surfer_user_id=user1.id, 

1337 host_user_id=user2.id, 

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

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

1340 status=HostRequestStatus.pending, 

1341 host_sent_request_reminders=0, 

1342 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1343 ) 

1344 

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

1346 moderator.approve_host_request(hr) 

1347 

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

1349 send_host_request_reminders(empty_pb2.Empty()) 

1350 

1351 while process_job(): 

1352 pass 

1353 

1354 with session_scope() as session: 

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

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

1357 

1358 # Clean up emails and background jobs 

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

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

1361 

1362 # Reset the reminder counter so we can test again 

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

1364 host_request.host_sent_request_reminders = 0 

1365 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL 

1366 

1367 # Now have the host block the surfer 

1368 make_user_block(user2, user1) 

1369 

1370 send_host_request_reminders(empty_pb2.Empty()) 

1371 

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

1373 pass 

1374 

1375 with session_scope() as session: 

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

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

1378 

1379 

1380def test_send_message_notifications_blocked_users_no_notification(db, moderator): 

1381 """ 

1382 Regression test: send_message_notifications should not send notifications 

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

1384 """ 

1385 user1, token1 = generate_user() 

1386 user2, token2 = generate_user() 

1387 

1388 make_friends(user1, user2) 

1389 

1390 # Create a group chat and send messages 

1391 with conversations_session(token1) as c: 

1392 group_chat_id = c.CreateGroupChat( 

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

1394 ).group_chat_id 

1395 

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

1397 moderator.approve_group_chat(group_chat_id) 

1398 

1399 with conversations_session(token1) as c: 

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

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

1402 

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

1404 with session_scope() as session: 

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

1406 

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

1408 send_message_notifications(empty_pb2.Empty()) 

1409 process_jobs() 

1410 

1411 with session_scope() as session: 

1412 email_job_count = session.execute( 

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

1414 ).scalar_one() 

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

1416 

1417 # Clean up 

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

1419 

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

1421 with session_scope() as session: 

1422 from couchers.models import User 

1423 

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

1425 u2.last_notified_message_id = 0 

1426 

1427 # Now have user2 block user1 

1428 make_user_block(user2, user1) 

1429 

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

1431 # since user2 has blocked user1 

1432 with session_scope() as session: 

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

1434 

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

1436 send_message_notifications(empty_pb2.Empty()) 

1437 process_jobs() 

1438 

1439 with session_scope() as session: 

1440 email_job_count = session.execute( 

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

1442 ).scalar_one() 

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

1444 

1445 

1446def test_update_badges_volunteers(db, push_collector: PushCollector): 

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

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

1449 user1, _ = generate_user(last_donated=None) 

1450 user2, _ = generate_user(last_donated=None) 

1451 user3, _ = generate_user(last_donated=None) 

1452 user4, _ = generate_user(last_donated=None) 

1453 user5, _ = generate_user(last_donated=None) 

1454 user6, _ = generate_user(last_donated=None) 

1455 

1456 with session_scope() as session: 

1457 # user3: active volunteer (stopped_volunteering is null) 

1458 session.add( 

1459 make_volunteer( 

1460 user_id=user3.id, 

1461 role="Developer", 

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

1463 stopped_volunteering=None, 

1464 ) 

1465 ) 

1466 

1467 # user4: past volunteer (stopped_volunteering is set) 

1468 session.add( 

1469 make_volunteer( 

1470 user_id=user4.id, 

1471 role="Designer", 

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

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

1474 ) 

1475 ) 

1476 

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

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

1479 

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

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

1482 

1483 update_badges(empty_pb2.Empty()) 

1484 process_jobs() 

1485 

1486 with session_scope() as session: 

1487 # Check user3 has volunteer badge 

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

1489 assert "volunteer" in user3_badges 

1490 assert "past_volunteer" not in user3_badges 

1491 

1492 # Check user4 has past_volunteer badge 

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

1494 assert "past_volunteer" in user4_badges 

1495 assert "volunteer" not in user4_badges 

1496 

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

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

1499 assert "volunteer" not in user5_badges 

1500 

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

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

1503 assert "past_volunteer" not in user6_badges 

1504 

1505 # Check notifications for volunteer badge users 

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

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

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

1509 

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

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

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

1513 

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

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

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

1517 

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

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

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

1521 

1522 

1523def test_update_badges_volunteer_status_change(db, push_collector: PushCollector): 

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

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

1526 user1, _ = generate_user(last_donated=None) 

1527 user2, _ = generate_user(last_donated=None) 

1528 user3, _ = generate_user(last_donated=None) 

1529 

1530 with session_scope() as session: 

1531 # user3: start as active volunteer 

1532 session.add( 

1533 make_volunteer( 

1534 user_id=user3.id, 

1535 role="Developer", 

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

1537 stopped_volunteering=None, 

1538 show_on_team_page=True, 

1539 ) 

1540 ) 

1541 

1542 update_badges(empty_pb2.Empty()) 

1543 process_jobs() 

1544 

1545 with session_scope() as session: 

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

1547 assert "volunteer" in user3_badges 

1548 assert "past_volunteer" not in user3_badges 

1549 

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

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

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

1553 

1554 # Now change the volunteer to past volunteer 

1555 with session_scope() as session: 

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

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

1558 

1559 update_badges(empty_pb2.Empty()) 

1560 process_jobs() 

1561 

1562 with session_scope() as session: 

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

1564 assert "volunteer" not in user3_badges 

1565 assert "past_volunteer" in user3_badges 

1566 

1567 # Check both badges were updated 

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

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

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

1571 

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

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

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

1575 

1576 

1577def test_send_message_notifications_empty_unseen_simple(monkeypatch): 

1578 class DummyUser: 

1579 id = 1 

1580 is_visible = True 

1581 last_notified_message_id = 0 

1582 

1583 class FirstResult: 

1584 def scalars(self): 

1585 return self 

1586 

1587 def unique(self): 

1588 return [DummyUser()] 

1589 

1590 class SecondResult: 

1591 def all(self): 

1592 return [] 

1593 

1594 class DummySession: 

1595 def __init__(self): 

1596 self.calls = 0 

1597 

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

1599 self.calls += 1 

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

1601 

1602 def commit(self): 

1603 pass 

1604 

1605 def flush(self): 

1606 pass 

1607 

1608 def fake_session_scope(): 

1609 class Ctx: 

1610 def __enter__(self): 

1611 return DummySession() 

1612 

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

1614 pass 

1615 

1616 return Ctx() 

1617 

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

1619 

1620 handlers.send_message_notifications(Empty())