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

734 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +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 import experimentation 

14from couchers.config import config 

15from couchers.constants import HOST_REQUEST_MAX_REMINDERS, HOST_REQUEST_REMINDER_INTERVAL 

16from couchers.crypto import urlsafe_secure_token 

17from couchers.db import session_scope 

18from couchers.email.dev import print_dev_email 

19from couchers.email.queuing import queue_email 

20from couchers.jobs import handlers 

21from couchers.jobs.definitions import Job 

22from couchers.jobs.enqueue import queue_job 

23from couchers.jobs.handlers import ( 

24 add_users_to_email_list, 

25 enforce_community_membership, 

26 purge_account_deletion_tokens, 

27 purge_login_tokens, 

28 purge_password_reset_tokens, 

29 send_host_request_reminders, 

30 send_message_notifications, 

31 send_onboarding_emails, 

32 send_reference_reminders, 

33 send_request_notifications, 

34 update_badges, 

35 update_recommendation_scores, 

36) 

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

38from couchers.materialized_views import refresh_materialized_views 

39from couchers.metrics import create_prometheus_server 

40from couchers.models import ( 

41 AccountDeletionToken, 

42 BackgroundJob, 

43 BackgroundJobState, 

44 Email, 

45 HostRequest, 

46 HostRequestStatus, 

47 LoginToken, 

48 Message, 

49 MessageType, 

50 PasswordResetToken, 

51 User, 

52 UserBadge, 

53 UserBlock, 

54 Volunteer, 

55) 

56from couchers.proto import conversations_pb2, requests_pb2 

57from couchers.proto.internal import jobs_pb2 

58from couchers.utils import now, today 

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

60from tests.fixtures.misc import PushCollector, process_jobs 

61from tests.fixtures.sessions import conversations_session, requests_session 

62from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date 

63from tests.test_requests import valid_request_text 

64 

65 

66def now_5_min_in_future() -> datetime: 

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

68 

69 

70@pytest.fixture(autouse=True) 

71def _(testconfig): 

72 pass 

73 

74 

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

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

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

78 assert string_to_check in metrics_string 

79 

80 

81def test_email_job(db): 

82 with session_scope() as session: 

83 queue_email( 

84 session, 

85 jobs_pb2.SendEmailPayload( 

86 sender_name="sender_name", 

87 sender_email="sender_email", 

88 recipient="recipient", 

89 subject="subject", 

90 plain="plain", 

91 html="html", 

92 ), 

93 ) 

94 

95 def mock_print_dev_email(payload): 

96 assert payload.sender_name == "sender_name" 

97 assert payload.sender_email == "sender_email" 

98 assert payload.recipient == "recipient" 

99 assert payload.subject == "subject" 

100 assert payload.plain == "plain" 

101 assert payload.html == "html" 

102 return print_dev_email(payload) 

103 

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

105 process_job() 

106 

107 with session_scope() as session: 

108 assert ( 

109 session.execute( 

110 select(func.count()) 

111 .select_from(BackgroundJob) 

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

113 ).scalar_one() 

114 == 1 

115 ) 

116 assert ( 

117 session.execute( 

118 select(func.count()) 

119 .select_from(BackgroundJob) 

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

121 ).scalar_one() 

122 == 0 

123 ) 

124 

125 

126def test_purge_login_tokens(db): 

127 user, api_token = generate_user() 

128 

129 with session_scope() as session: 

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

131 session.add(login_token) 

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

133 

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

135 process_job() 

136 

137 with session_scope() as session: 

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

139 

140 with session_scope() as session: 

141 assert ( 

142 session.execute( 

143 select(func.count()) 

144 .select_from(BackgroundJob) 

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

146 ).scalar_one() 

147 == 1 

148 ) 

149 assert ( 

150 session.execute( 

151 select(func.count()) 

152 .select_from(BackgroundJob) 

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

154 ).scalar_one() 

155 == 0 

156 ) 

157 

158 

159def test_purge_password_reset_tokens(db): 

160 user, api_token = generate_user() 

161 

162 with session_scope() as session: 

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

164 session.add(password_reset_token) 

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

166 

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

168 process_job() 

169 

170 with session_scope() as session: 

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

172 

173 with session_scope() as session: 

174 assert ( 

175 session.execute( 

176 select(func.count()) 

177 .select_from(BackgroundJob) 

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

179 ).scalar_one() 

180 == 1 

181 ) 

182 assert ( 

183 session.execute( 

184 select(func.count()) 

185 .select_from(BackgroundJob) 

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

187 ).scalar_one() 

188 == 0 

189 ) 

190 

191 

192def test_purge_account_deletion_tokens(db): 

193 user, api_token = generate_user() 

194 user2, api_token2 = generate_user() 

195 user3, api_token3 = generate_user() 

196 

197 with session_scope() as session: 

198 """ 

199 3 cases: 

200 1) Token is valid 

201 2) Token expired but account retrievable 

202 3) Account is irretrievable (and expired) 

203 """ 

204 account_deletion_tokens = [ 

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

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

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

208 ] 

209 for token in account_deletion_tokens: 

210 session.add(token) 

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

212 

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

214 process_job() 

215 

216 with session_scope() as session: 

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

218 

219 with session_scope() as session: 

220 assert ( 

221 session.execute( 

222 select(func.count()) 

223 .select_from(BackgroundJob) 

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

225 ).scalar_one() 

226 == 1 

227 ) 

228 assert ( 

229 session.execute( 

230 select(func.count()) 

231 .select_from(BackgroundJob) 

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

233 ).scalar_one() 

234 == 0 

235 ) 

236 

237 

238def test_enforce_community_memberships(db): 

239 with session_scope() as session: 

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

241 process_job() 

242 

243 with session_scope() as session: 

244 assert ( 

245 session.execute( 

246 select(func.count()) 

247 .select_from(BackgroundJob) 

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

249 ).scalar_one() 

250 == 1 

251 ) 

252 assert ( 

253 session.execute( 

254 select(func.count()) 

255 .select_from(BackgroundJob) 

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

257 ).scalar_one() 

258 == 0 

259 ) 

260 

261 

262def test_refresh_materialized_views(db): 

263 with session_scope() as session: 

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

265 

266 process_job() 

267 

268 with session_scope() as session: 

269 assert ( 

270 session.execute( 

271 select(func.count()) 

272 .select_from(BackgroundJob) 

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

274 ).scalar_one() 

275 == 1 

276 ) 

277 assert ( 

278 session.execute( 

279 select(func.count()) 

280 .select_from(BackgroundJob) 

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

282 ).scalar_one() 

283 == 0 

284 ) 

285 

286 

287def test_service_jobs(db): 

288 with session_scope() as session: 

289 queue_email( 

290 session, 

291 jobs_pb2.SendEmailPayload( 

292 sender_name="sender_name", 

293 sender_email="sender_email", 

294 recipient="recipient", 

295 subject="subject", 

296 plain="plain", 

297 html="html", 

298 ), 

299 ) 

300 

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

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

303 class HitSleep(Exception): 

304 pass 

305 

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

307 def raising_sleep(seconds): 

308 raise HitSleep() 

309 

310 with pytest.raises(HitSleep): 

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

312 service_jobs() 

313 

314 with session_scope() as session: 

315 assert ( 

316 session.execute( 

317 select(func.count()) 

318 .select_from(BackgroundJob) 

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

320 ).scalar_one() 

321 == 1 

322 ) 

323 assert ( 

324 session.execute( 

325 select(func.count()) 

326 .select_from(BackgroundJob) 

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

328 ).scalar_one() 

329 == 0 

330 ) 

331 

332 

333def test_scheduler(db, monkeypatch): 

334 def purge_login_tokens(payload: empty_pb2.Empty): 

335 return 

336 

337 def send_message_notifications(payload: empty_pb2.Empty): 

338 return 

339 

340 MOCK_JOBS = { 

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

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

343 } 

344 

345 current_time = 0 

346 end_time = 70 

347 

348 class EndOfTime(Exception): 

349 pass 

350 

351 def mock_monotonic(): 

352 return current_time 

353 

354 def mock_sleep(seconds): 

355 nonlocal current_time 

356 current_time += seconds 

357 if current_time > end_time: 

358 raise EndOfTime() 

359 

360 realized_schedule = [] 

361 

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

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

364 _run_job_and_schedule(sched, job, frequency) 

365 

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

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

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

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

370 

371 with pytest.raises(EndOfTime): 

372 run_scheduler() 

373 

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

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

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

377 

378 assert realized_schedule_indices == [ 

379 (0.0, 0), 

380 (0.0, 1), 

381 (7.0, 0), 

382 (11.0, 1), 

383 (14.0, 0), 

384 (21.0, 0), 

385 (22.0, 1), 

386 (28.0, 0), 

387 (33.0, 1), 

388 (35.0, 0), 

389 (42.0, 0), 

390 (44.0, 1), 

391 (49.0, 0), 

392 (55.0, 1), 

393 (56.0, 0), 

394 (63.0, 0), 

395 (66.0, 1), 

396 (70.0, 0), 

397 ] 

398 

399 with session_scope() as session: 

400 assert ( 

401 session.execute( 

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

403 ).scalar_one() 

404 == 18 

405 ) 

406 assert ( 

407 session.execute( 

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

409 ).scalar_one() 

410 == 0 

411 ) 

412 

413 

414def test_job_retry(db): 

415 called_count = 0 

416 

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

418 nonlocal called_count 

419 called_count += 1 

420 raise Exception() 

421 

422 with session_scope() as session: 

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

424 

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

426 "mock_job": Job(mock_job), 

427 } 

428 create_prometheus_server(port=8000) 

429 

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

431 new_config = config.copy() 

432 new_config.IN_TEST = False 

433 

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

435 process_job() 

436 with session_scope() as session: 

437 assert ( 

438 session.execute( 

439 select(func.count()) 

440 .select_from(BackgroundJob) 

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

442 ).scalar_one() 

443 == 1 

444 ) 

445 assert ( 

446 session.execute( 

447 select(func.count()) 

448 .select_from(BackgroundJob) 

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

450 ).scalar_one() 

451 == 0 

452 ) 

453 

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

455 process_job() 

456 with session_scope() as session: 

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

458 process_job() 

459 with session_scope() as session: 

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

461 process_job() 

462 with session_scope() as session: 

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

464 process_job() 

465 

466 with session_scope() as session: 

467 assert ( 

468 session.execute( 

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

470 ).scalar_one() 

471 == 1 

472 ) 

473 assert ( 

474 session.execute( 

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

476 ).scalar_one() 

477 == 0 

478 ) 

479 

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

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

482 

483 

484def test_no_jobs_no_problem(db): 

485 with session_scope() as session: 

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

487 

488 assert not process_job() 

489 

490 with session_scope() as session: 

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

492 

493 

494def test_send_message_notifications_basic(db, moderator): 

495 user1, token1 = generate_user() 

496 user2, token2 = generate_user() 

497 user3, token3 = generate_user() 

498 

499 make_friends(user1, user2) 

500 make_friends(user1, user3) 

501 make_friends(user2, user3) 

502 

503 send_message_notifications(empty_pb2.Empty()) 

504 process_jobs() 

505 

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

507 with session_scope() as session: 

508 assert ( 

509 session.execute( 

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

511 ).scalar_one() 

512 == 0 

513 ) 

514 

515 with conversations_session(token1) as c: 

516 group_chat_id1 = c.CreateGroupChat( 

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

518 ).group_chat_id 

519 moderator.approve_group_chat(group_chat_id1) 

520 

521 with conversations_session(token1) as c: 

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

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

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

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

526 

527 with conversations_session(token3) as c: 

528 group_chat_id2 = c.CreateGroupChat( 

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

530 ).group_chat_id 

531 moderator.approve_group_chat(group_chat_id2) 

532 

533 with conversations_session(token3) as c: 

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

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

536 

537 send_message_notifications(empty_pb2.Empty()) 

538 process_jobs() 

539 

540 # no emails sent out 

541 with session_scope() as session: 

542 assert ( 

543 session.execute( 

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

545 ).scalar_one() 

546 == 0 

547 ) 

548 

549 # this should generate emails for both user2 and user3 

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

551 send_message_notifications(empty_pb2.Empty()) 

552 process_jobs() 

553 

554 with session_scope() as session: 

555 assert ( 

556 session.execute( 

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

558 ).scalar_one() 

559 == 2 

560 ) 

561 # delete them all 

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

563 

564 # shouldn't generate any more emails 

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

566 send_message_notifications(empty_pb2.Empty()) 

567 process_jobs() 

568 

569 with session_scope() as session: 

570 assert ( 

571 session.execute( 

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

573 ).scalar_one() 

574 == 0 

575 ) 

576 

577 

578def test_send_message_notifications_muted(db, moderator): 

579 user1, token1 = generate_user() 

580 user2, token2 = generate_user() 

581 user3, token3 = generate_user() 

582 

583 make_friends(user1, user2) 

584 make_friends(user1, user3) 

585 make_friends(user2, user3) 

586 

587 send_message_notifications(empty_pb2.Empty()) 

588 process_jobs() 

589 

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

591 with session_scope() as session: 

592 assert ( 

593 session.execute( 

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

595 ).scalar_one() 

596 == 0 

597 ) 

598 

599 with conversations_session(token1) as c: 

600 group_chat_id = c.CreateGroupChat( 

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

602 ).group_chat_id 

603 moderator.approve_group_chat(group_chat_id) 

604 

605 with conversations_session(token3) as c: 

606 # mute it for user 3 

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

608 

609 with conversations_session(token1) as c: 

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

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

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

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

614 

615 with conversations_session(token3) as c: 

616 group_chat_id = c.CreateGroupChat( 

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

618 ).group_chat_id 

619 moderator.approve_group_chat(group_chat_id) 

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

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

622 

623 send_message_notifications(empty_pb2.Empty()) 

624 process_jobs() 

625 

626 # no emails sent out 

627 with session_scope() as session: 

628 assert ( 

629 session.execute( 

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

631 ).scalar_one() 

632 == 0 

633 ) 

634 

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

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

637 send_message_notifications(empty_pb2.Empty()) 

638 process_jobs() 

639 

640 with session_scope() as session: 

641 assert ( 

642 session.execute( 

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

644 ).scalar_one() 

645 == 1 

646 ) 

647 # delete them all 

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

649 

650 # shouldn't generate any more emails 

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

652 send_message_notifications(empty_pb2.Empty()) 

653 process_jobs() 

654 

655 with session_scope() as session: 

656 assert ( 

657 session.execute( 

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

659 ).scalar_one() 

660 == 0 

661 ) 

662 

663 

664def test_send_request_notifications_host_request(db, moderator): 

665 user1, token1 = generate_user() 

666 user2, token2 = generate_user() 

667 

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

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

670 

671 send_request_notifications(empty_pb2.Empty()) 

672 process_jobs() 

673 

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

675 with session_scope() as session: 

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

677 

678 with requests_session(token1) as requests: 

679 host_request_id = requests.CreateHostRequest( 

680 requests_pb2.CreateHostRequestReq( 

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

682 ) 

683 ).host_request_id 

684 moderator.approve_host_request(host_request_id) 

685 

686 with session_scope() as session: 

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

688 

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

690 # notified about via host_request__create — no missed_messages email 

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

692 send_request_notifications(empty_pb2.Empty()) 

693 process_jobs() 

694 assert ( 

695 session.execute( 

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

697 ).scalar_one() 

698 == 0 

699 ) 

700 

701 # test that responding to host request creates email 

702 with requests_session(token2) as requests: 

703 requests.RespondHostRequest( 

704 requests_pb2.RespondHostRequestReq( 

705 host_request_id=host_request_id, 

706 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

707 text="Test request", 

708 ) 

709 ) 

710 

711 with session_scope() as session: 

712 # delete send_email BackgroundJob created by RespondHostRequest 

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

714 

715 # check send_request_notifications successfully creates background job 

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

717 send_request_notifications(empty_pb2.Empty()) 

718 process_jobs() 

719 assert ( 

720 session.execute( 

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

722 ).scalar_one() 

723 == 1 

724 ) 

725 

726 # delete all BackgroundJobs 

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

728 

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

730 send_request_notifications(empty_pb2.Empty()) 

731 process_jobs() 

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

733 assert ( 

734 session.execute( 

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

736 ).scalar_one() 

737 == 0 

738 ) 

739 

740 

741def test_send_request_notifications_host_request_with_followup(db, moderator): 

742 """ 

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

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

745 creation message alone would be skipped). 

746 """ 

747 user1, token1 = generate_user() 

748 user2, token2 = generate_user() 

749 

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

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

752 

753 with requests_session(token1) as requests: 

754 host_request_id = requests.CreateHostRequest( 

755 requests_pb2.CreateHostRequestReq( 

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

757 ) 

758 ).host_request_id 

759 moderator.approve_host_request(host_request_id) 

760 

761 # surfer sends a follow-up message 

762 with requests_session(token1) as requests: 

763 requests.SendHostRequestMessage( 

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

765 ) 

766 

767 with session_scope() as session: 

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

769 

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

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

772 send_request_notifications(empty_pb2.Empty()) 

773 process_jobs() 

774 assert ( 

775 session.execute( 

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

777 ).scalar_one() 

778 == 1 

779 ) 

780 

781 

782def test_send_request_notifications_two_requests_one_with_followup(db, moderator): 

783 """ 

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

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

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

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

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

789 materialized before the loop begins. 

790 """ 

791 user1, token1 = generate_user() 

792 user2, token2 = generate_user() 

793 user3, token3 = generate_user() 

794 

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

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

797 

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

799 with requests_session(token1) as requests: 

800 host_request_a = requests.CreateHostRequest( 

801 requests_pb2.CreateHostRequestReq( 

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

803 ) 

804 ).host_request_id 

805 moderator.approve_host_request(host_request_a) 

806 

807 with requests_session(token1) as requests: 

808 requests.SendHostRequestMessage( 

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

810 ) 

811 

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

813 with requests_session(token3) as requests: 

814 host_request_b = requests.CreateHostRequest( 

815 requests_pb2.CreateHostRequestReq( 

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

817 ) 

818 ).host_request_id 

819 moderator.approve_host_request(host_request_b) 

820 

821 with session_scope() as session: 

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

823 

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

825 # not request B (creation only, skipped) 

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

827 send_request_notifications(empty_pb2.Empty()) 

828 process_jobs() 

829 assert ( 

830 session.execute( 

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

832 ).scalar_one() 

833 == 1 

834 ) 

835 

836 

837def test_send_message_notifications_seen(db, moderator): 

838 user1, token1 = generate_user() 

839 user2, token2 = generate_user() 

840 

841 make_friends(user1, user2) 

842 

843 send_message_notifications(empty_pb2.Empty()) 

844 

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

846 with session_scope() as session: 

847 assert ( 

848 session.execute( 

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

850 ).scalar_one() 

851 == 0 

852 ) 

853 

854 with conversations_session(token1) as c: 

855 group_chat_id = c.CreateGroupChat( 

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

857 ).group_chat_id 

858 moderator.approve_group_chat(group_chat_id) 

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

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

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

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

863 

864 # user 2 now marks those messages as seen 

865 with conversations_session(token2) as c: 

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

867 c.MarkLastSeenGroupChat( 

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

869 ) 

870 

871 send_message_notifications(empty_pb2.Empty()) 

872 

873 # no emails sent out 

874 with session_scope() as session: 

875 assert ( 

876 session.execute( 

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

878 ).scalar_one() 

879 == 0 

880 ) 

881 

882 def now_30_min_in_future(): 

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

884 

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

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

887 send_message_notifications(empty_pb2.Empty()) 

888 

889 with session_scope() as session: 

890 assert ( 

891 session.execute( 

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

893 ).scalar_one() 

894 == 0 

895 ) 

896 

897 

898def test_send_onboarding_emails(db): 

899 # needs to get first onboarding email 

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

901 

902 send_onboarding_emails(empty_pb2.Empty()) 

903 process_jobs() 

904 

905 with session_scope() as session: 

906 assert ( 

907 session.execute( 

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

909 ).scalar_one() 

910 == 1 

911 ) 

912 

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

914 user2, token2 = generate_user( 

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

916 ) 

917 

918 send_onboarding_emails(empty_pb2.Empty()) 

919 process_jobs() 

920 

921 with session_scope() as session: 

922 assert ( 

923 session.execute( 

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

925 ).scalar_one() 

926 == 1 

927 ) 

928 

929 # needs to get second onboarding email 

930 user3, token3 = generate_user( 

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

932 ) 

933 

934 send_onboarding_emails(empty_pb2.Empty()) 

935 process_jobs() 

936 

937 with session_scope() as session: 

938 assert ( 

939 session.execute( 

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

941 ).scalar_one() 

942 == 2 

943 ) 

944 

945 

946def test_send_reference_reminders(db): 

947 # need to test: 

948 # case 1: bidirectional (no emails) 

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

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

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

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

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

954 

955 send_reference_reminders(empty_pb2.Empty()) 

956 

957 # case 1: bidirectional (no emails) 

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

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

960 

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

962 # host 

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

964 # surfer 

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

966 

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

968 # host 

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

970 # surfer 

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

972 

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

974 # surfer 

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

976 # host 

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

978 

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

980 # surfer 

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

982 # host 

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

984 

985 make_user_block(user9, user10) 

986 

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

988 # host 

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

990 # surfer 

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

992 

993 with session_scope() as session: 

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

995 

996 # case 1: bidirectional (no emails) 

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

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

999 

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

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

1002 

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

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

1005 

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

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

1008 

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

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

1011 

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

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

1014 

1015 expected_emails = [ 

1016 ( 

1017 "user11@couchers.org.invalid", 

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

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

1020 ), 

1021 ( 

1022 "user4@couchers.org.invalid", 

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

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

1025 ), 

1026 ( 

1027 "user5@couchers.org.invalid", 

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

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

1030 ), 

1031 ( 

1032 "user7@couchers.org.invalid", 

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

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

1035 ), 

1036 ( 

1037 "user8@couchers.org.invalid", 

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

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

1040 ), 

1041 ] 

1042 

1043 send_reference_reminders(empty_pb2.Empty()) 

1044 

1045 while process_job(): 

1046 pass 

1047 

1048 with session_scope() as session: 

1049 emails = [ 

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

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

1052 ] 

1053 

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

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

1056 

1057 print(actual_addresses_and_subjects) 

1058 print(expected_addresses_and_subjects) 

1059 

1060 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1061 

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

1063 for find in search_strings: 

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

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

1066 

1067 

1068def test_send_host_request_reminders(db, moderator): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1083 

1084 with session_scope() as session: 

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

1086 hr1 = create_host_request_by_date( 

1087 session=session, 

1088 surfer_user_id=user1.id, 

1089 host_user_id=user2.id, 

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

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

1092 status=HostRequestStatus.pending, 

1093 host_sent_request_reminders=0, 

1094 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1095 ) 

1096 

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

1098 hr2 = create_host_request_by_date( 

1099 session=session, 

1100 surfer_user_id=user3.id, 

1101 host_user_id=user4.id, 

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

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

1104 status=HostRequestStatus.pending, 

1105 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS, 

1106 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1107 ) 

1108 

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

1110 hr3 = create_host_request_by_date( 

1111 session=session, 

1112 surfer_user_id=user5.id, 

1113 host_user_id=user6.id, 

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

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

1116 status=HostRequestStatus.pending, 

1117 host_sent_request_reminders=0, 

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

1119 ) 

1120 

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

1122 hr4 = create_host_request_by_date( 

1123 session=session, 

1124 surfer_user_id=user7.id, 

1125 host_user_id=user8.id, 

1126 from_date=today(), 

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

1128 status=HostRequestStatus.pending, 

1129 host_sent_request_reminders=0, 

1130 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1131 ) 

1132 

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

1134 hr5 = create_host_request_by_date( 

1135 session=session, 

1136 surfer_user_id=user9.id, 

1137 host_user_id=user10.id, 

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

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

1140 status=HostRequestStatus.pending, 

1141 host_sent_request_reminders=0, 

1142 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1143 ) 

1144 

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

1146 hr6 = create_host_request_by_date( 

1147 session=session, 

1148 surfer_user_id=user11.id, 

1149 host_user_id=user12.id, 

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

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

1152 status=HostRequestStatus.accepted, 

1153 host_sent_request_reminders=0, 

1154 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1155 ) 

1156 

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

1158 hr7 = create_host_request_by_date( 

1159 session=session, 

1160 surfer_user_id=user13.id, 

1161 host_user_id=user14.id, 

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

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

1164 status=HostRequestStatus.pending, 

1165 host_sent_request_reminders=0, 

1166 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1167 ) 

1168 

1169 msg = Message( 

1170 conversation_id=hr7, 

1171 author_id=user14.id, 

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

1173 message_type=MessageType.text, 

1174 ) 

1175 msg.time = now() 

1176 session.add(msg) 

1177 

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

1179 moderator.approve_host_request(hr1) 

1180 moderator.approve_host_request(hr2) 

1181 moderator.approve_host_request(hr3) 

1182 moderator.approve_host_request(hr4) 

1183 moderator.approve_host_request(hr5) 

1184 moderator.approve_host_request(hr6) 

1185 moderator.approve_host_request(hr7) 

1186 

1187 send_host_request_reminders(empty_pb2.Empty()) 

1188 

1189 while process_job(): 

1190 pass 

1191 

1192 with session_scope() as session: 

1193 emails = [ 

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

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

1196 ] 

1197 

1198 expected_emails = [ 

1199 ( 

1200 "user2@couchers.org.invalid", 

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

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

1203 ) 

1204 ] 

1205 

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

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

1208 

1209 print(actual_addresses_and_subjects) 

1210 print(expected_addresses_and_subjects) 

1211 

1212 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1213 

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

1215 for find in search_strings: 

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

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

1218 

1219 

1220def test_add_users_to_email_list(db, feature_flags): 

1221 feature_flags.set("listmonk_enabled", True) 

1222 new_config = config.copy() 

1223 new_config.LISTMONK_BASE_URL = "https://example.com" 

1224 new_config.LISTMONK_API_USERNAME = "test_user" 

1225 new_config.LISTMONK_API_KEY = "dummy_api_key" 

1226 new_config.LISTMONK_LIST_ID = 6 

1227 

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

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

1230 mock_session_cls.return_value.post.return_value.status_code = 200 

1231 add_users_to_email_list(empty_pb2.Empty()) 

1232 mock_session_cls.return_value.post.assert_not_called() 

1233 

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

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

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

1237 generate_user( 

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

1239 ) 

1240 

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

1242 mock_sess = mock_session_cls.return_value 

1243 mock_sess.post.return_value.status_code = 200 

1244 add_users_to_email_list(empty_pb2.Empty()) 

1245 mock_sess.post.assert_has_calls( 

1246 [ 

1247 call( 

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

1249 json={ 

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

1251 "name": "Tester1", 

1252 "lists": [6], 

1253 "preconfirm_subscriptions": True, 

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

1255 "status": "enabled", 

1256 }, 

1257 timeout=10, 

1258 ), 

1259 call( 

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

1261 json={ 

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

1263 "name": "Tester3 von test", 

1264 "lists": [6], 

1265 "preconfirm_subscriptions": True, 

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

1267 "status": "enabled", 

1268 }, 

1269 timeout=10, 

1270 ), 

1271 ], 

1272 any_order=True, 

1273 ) 

1274 

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

1276 mock_session_cls.return_value.post.return_value.status_code = 200 

1277 add_users_to_email_list(empty_pb2.Empty()) 

1278 mock_session_cls.return_value.post.assert_not_called() 

1279 

1280 

1281def test_update_recommendation_scores(db): 

1282 update_recommendation_scores(empty_pb2.Empty()) 

1283 

1284 

1285def test_update_badges(db, push_collector: PushCollector): 

1286 user1, _ = generate_user(last_donated=None) 

1287 user2, _ = generate_user(last_donated=None) 

1288 user3, _ = generate_user(last_donated=None) 

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

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

1291 user6, _ = generate_user(last_donated=None) 

1292 

1293 with session_scope() as session: 

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

1295 

1296 update_badges(empty_pb2.Empty()) 

1297 process_jobs() 

1298 

1299 with session_scope() as session: 

1300 badge_tuples = session.execute( 

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

1302 ).all() 

1303 

1304 expected = [ 

1305 (user1.id, "founder"), 

1306 (user1.id, "board_member"), 

1307 (user2.id, "founder"), 

1308 (user2.id, "board_member"), 

1309 (user4.id, "phone_verified"), 

1310 (user5.id, "phone_verified"), 

1311 ] 

1312 

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

1314 

1315 print(push_collector.by_user) 

1316 

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

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

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

1320 

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

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

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

1324 

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

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

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

1328 

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

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

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

1332 

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

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

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

1336 

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

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

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

1340 

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

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

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

1344 

1345 

1346def test_update_badges_awards_moderator_to_superuser(db): 

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

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

1349 

1350 update_badges(empty_pb2.Empty()) 

1351 

1352 with session_scope() as session: 

1353 assert ( 

1354 session.execute( 

1355 select(func.count()) 

1356 .select_from(UserBadge) 

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

1358 ).scalar() 

1359 == 1 

1360 ) 

1361 

1362 

1363def test_update_badges_skips_moderator_when_flag_off(db, monkeypatch): 

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

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

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

1367 monkeypatch.setattr( 

1368 experimentation, 

1369 "_state", 

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

1371 ) 

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

1373 

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

1375 

1376 update_badges(empty_pb2.Empty()) 

1377 

1378 with session_scope() as session: 

1379 assert ( 

1380 session.execute( 

1381 select(func.count()) 

1382 .select_from(UserBadge) 

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

1384 ).scalar() 

1385 == 0 

1386 ) 

1387 

1388 

1389def test_send_request_notifications_blocked_users_no_notification(db, moderator): 

1390 """ 

1391 Regression test: send_request_notifications should not send notifications 

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

1393 """ 

1394 user1, token1 = generate_user() 

1395 user2, token2 = generate_user() 

1396 

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

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

1399 

1400 # Create a host request 

1401 with requests_session(token1) as requests: 

1402 host_request_id = requests.CreateHostRequest( 

1403 requests_pb2.CreateHostRequestReq( 

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

1405 ) 

1406 ).host_request_id 

1407 moderator.approve_host_request(host_request_id) 

1408 

1409 with session_scope() as session: 

1410 # delete send_email BackgroundJob created by CreateHostRequest 

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

1412 

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

1414 make_user_block(user2, user1) 

1415 

1416 with session_scope() as session: 

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

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

1419 send_request_notifications(empty_pb2.Empty()) 

1420 process_jobs() 

1421 

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

1423 assert ( 

1424 session.execute( 

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

1426 ).scalar_one() 

1427 == 0 

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

1429 

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

1431 # First unblock 

1432 with session_scope() as session: 

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

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

1435 

1436 # Host responds 

1437 with requests_session(token2) as requests: 

1438 requests.RespondHostRequest( 

1439 requests_pb2.RespondHostRequestReq( 

1440 host_request_id=host_request_id, 

1441 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1442 text="Accepting your request", 

1443 ) 

1444 ) 

1445 

1446 with session_scope() as session: 

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

1448 

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

1450 make_user_block(user1, user2) 

1451 

1452 with session_scope() as session: 

1453 # check send_request_notifications does NOT create background job 

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

1455 send_request_notifications(empty_pb2.Empty()) 

1456 process_jobs() 

1457 

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

1459 assert ( 

1460 session.execute( 

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

1462 ).scalar_one() 

1463 == 0 

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

1465 

1466 

1467def test_send_host_request_reminders_blocked_users_no_notification(db, moderator): 

1468 """ 

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

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

1471 """ 

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

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

1474 

1475 with session_scope() as session: 

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

1477 hr = create_host_request_by_date( 

1478 session=session, 

1479 surfer_user_id=user1.id, 

1480 host_user_id=user2.id, 

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

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

1483 status=HostRequestStatus.pending, 

1484 host_sent_request_reminders=0, 

1485 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1486 ) 

1487 

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

1489 moderator.approve_host_request(hr) 

1490 

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

1492 send_host_request_reminders(empty_pb2.Empty()) 

1493 

1494 while process_job(): 

1495 pass 

1496 

1497 with session_scope() as session: 

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

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

1500 

1501 # Clean up emails and background jobs 

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

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

1504 

1505 # Reset the reminder counter so we can test again 

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

1507 host_request.recipient_sent_request_reminders = 0 

1508 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL 

1509 

1510 # Now have the host block the surfer 

1511 make_user_block(user2, user1) 

1512 

1513 send_host_request_reminders(empty_pb2.Empty()) 

1514 

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

1516 pass 

1517 

1518 with session_scope() as session: 

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

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

1521 

1522 

1523def test_send_message_notifications_blocked_users_no_notification(db, moderator): 

1524 """ 

1525 Regression test: send_message_notifications should not send notifications 

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

1527 """ 

1528 user1, token1 = generate_user() 

1529 user2, token2 = generate_user() 

1530 

1531 make_friends(user1, user2) 

1532 

1533 # Create a group chat and send messages 

1534 with conversations_session(token1) as c: 

1535 group_chat_id = c.CreateGroupChat( 

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

1537 ).group_chat_id 

1538 

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

1540 moderator.approve_group_chat(group_chat_id) 

1541 

1542 with conversations_session(token1) as c: 

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

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

1545 

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

1547 with session_scope() as session: 

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

1549 

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

1551 send_message_notifications(empty_pb2.Empty()) 

1552 process_jobs() 

1553 

1554 with session_scope() as session: 

1555 email_job_count = session.execute( 

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

1557 ).scalar_one() 

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

1559 

1560 # Clean up 

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

1562 

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

1564 with session_scope() as session: 

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

1566 u2.last_notified_message_id = 0 

1567 

1568 # Now have user2 block user1 

1569 make_user_block(user2, user1) 

1570 

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

1572 # since user2 has blocked user1 

1573 with session_scope() as session: 

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

1575 

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

1577 send_message_notifications(empty_pb2.Empty()) 

1578 process_jobs() 

1579 

1580 with session_scope() as session: 

1581 email_job_count = session.execute( 

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

1583 ).scalar_one() 

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

1585 

1586 

1587def test_update_badges_volunteers(db, push_collector: PushCollector): 

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

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

1590 user1, _ = generate_user(last_donated=None) 

1591 user2, _ = generate_user(last_donated=None) 

1592 user3, _ = generate_user(last_donated=None) 

1593 user4, _ = generate_user(last_donated=None) 

1594 user5, _ = generate_user(last_donated=None) 

1595 user6, _ = generate_user(last_donated=None) 

1596 

1597 with session_scope() as session: 

1598 # user3: active volunteer (stopped_volunteering is null) 

1599 session.add( 

1600 make_volunteer( 

1601 user_id=user3.id, 

1602 role="Developer", 

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

1604 stopped_volunteering=None, 

1605 ) 

1606 ) 

1607 

1608 # user4: past volunteer (stopped_volunteering is set) 

1609 session.add( 

1610 make_volunteer( 

1611 user_id=user4.id, 

1612 role="Designer", 

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

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

1615 ) 

1616 ) 

1617 

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

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

1620 

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

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

1623 

1624 update_badges(empty_pb2.Empty()) 

1625 process_jobs() 

1626 

1627 with session_scope() as session: 

1628 # Check user3 has volunteer badge 

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

1630 assert "volunteer" in user3_badges 

1631 assert "past_volunteer" not in user3_badges 

1632 

1633 # Check user4 has past_volunteer badge 

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

1635 assert "past_volunteer" in user4_badges 

1636 assert "volunteer" not in user4_badges 

1637 

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

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

1640 assert "volunteer" not in user5_badges 

1641 

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

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

1644 assert "past_volunteer" not in user6_badges 

1645 

1646 # Check notifications for volunteer badge users 

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

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

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

1650 

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

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

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

1654 

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

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

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

1658 

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

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

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

1662 

1663 

1664def test_update_badges_volunteer_status_change(db, push_collector: PushCollector): 

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

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

1667 user1, _ = generate_user(last_donated=None) 

1668 user2, _ = generate_user(last_donated=None) 

1669 user3, _ = generate_user(last_donated=None) 

1670 

1671 with session_scope() as session: 

1672 # user3: start as active volunteer 

1673 session.add( 

1674 make_volunteer( 

1675 user_id=user3.id, 

1676 role="Developer", 

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

1678 stopped_volunteering=None, 

1679 show_on_team_page=True, 

1680 ) 

1681 ) 

1682 

1683 update_badges(empty_pb2.Empty()) 

1684 process_jobs() 

1685 

1686 with session_scope() as session: 

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

1688 assert "volunteer" in user3_badges 

1689 assert "past_volunteer" not in user3_badges 

1690 

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

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

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

1694 

1695 # Now change the volunteer to past volunteer 

1696 with session_scope() as session: 

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

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

1699 

1700 update_badges(empty_pb2.Empty()) 

1701 process_jobs() 

1702 

1703 with session_scope() as session: 

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

1705 assert "volunteer" not in user3_badges 

1706 assert "past_volunteer" in user3_badges 

1707 

1708 # Check both badges were updated 

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

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

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

1712 

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

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

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

1716 

1717 

1718def test_send_message_notifications_empty_unseen_simple(monkeypatch): 

1719 class DummyUser: 

1720 id = 1 

1721 is_visible = True 

1722 last_notified_message_id = 0 

1723 

1724 class FirstResult: 

1725 def scalars(self): 

1726 return self 

1727 

1728 def unique(self): 

1729 return [DummyUser()] 

1730 

1731 class SecondResult: 

1732 def all(self): 

1733 return [] 

1734 

1735 class DummySession: 

1736 def __init__(self): 

1737 self.calls = 0 

1738 

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

1740 self.calls += 1 

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

1742 

1743 def commit(self): 

1744 pass 

1745 

1746 def flush(self): 

1747 pass 

1748 

1749 def fake_session_scope(): 

1750 class Ctx: 

1751 def __enter__(self): 

1752 return DummySession() 

1753 

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

1755 pass 

1756 

1757 return Ctx() 

1758 

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

1760 

1761 handlers.send_message_notifications(Empty())