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

717 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 09:44 +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.dev import print_dev_email 

18from couchers.email.queuing import queue_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 User, 

51 UserBadge, 

52 UserBlock, 

53 Volunteer, 

54) 

55from couchers.proto import conversations_pb2, requests_pb2 

56from couchers.utils import now, today 

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

58from tests.fixtures.misc import PushCollector, process_jobs 

59from tests.fixtures.sessions import conversations_session, requests_session 

60from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date 

61from tests.test_requests import valid_request_text 

62 

63 

64def now_5_min_in_future() -> datetime: 

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

66 

67 

68@pytest.fixture(autouse=True) 

69def _(testconfig): 

70 pass 

71 

72 

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

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

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

76 assert string_to_check in metrics_string 

77 

78 

79def test_email_job(db): 

80 with session_scope() as session: 

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

82 

83 def mock_print_dev_email( 

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

85 ): 

86 assert sender_name == "sender_name" 

87 assert sender_email == "sender_email" 

88 assert recipient == "recipient" 

89 assert subject == "subject" 

90 assert plain == "plain" 

91 assert html == "html" 

92 return print_dev_email( 

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

94 ) 

95 

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

97 process_job() 

98 

99 with session_scope() as session: 

100 assert ( 

101 session.execute( 

102 select(func.count()) 

103 .select_from(BackgroundJob) 

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

105 ).scalar_one() 

106 == 1 

107 ) 

108 assert ( 

109 session.execute( 

110 select(func.count()) 

111 .select_from(BackgroundJob) 

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

113 ).scalar_one() 

114 == 0 

115 ) 

116 

117 

118def test_purge_login_tokens(db): 

119 user, api_token = generate_user() 

120 

121 with session_scope() as session: 

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

123 session.add(login_token) 

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

125 

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

127 process_job() 

128 

129 with session_scope() as session: 

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

131 

132 with session_scope() as session: 

133 assert ( 

134 session.execute( 

135 select(func.count()) 

136 .select_from(BackgroundJob) 

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

138 ).scalar_one() 

139 == 1 

140 ) 

141 assert ( 

142 session.execute( 

143 select(func.count()) 

144 .select_from(BackgroundJob) 

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

146 ).scalar_one() 

147 == 0 

148 ) 

149 

150 

151def test_purge_password_reset_tokens(db): 

152 user, api_token = generate_user() 

153 

154 with session_scope() as session: 

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

156 session.add(password_reset_token) 

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

158 

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

160 process_job() 

161 

162 with session_scope() as session: 

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

164 

165 with session_scope() as session: 

166 assert ( 

167 session.execute( 

168 select(func.count()) 

169 .select_from(BackgroundJob) 

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

171 ).scalar_one() 

172 == 1 

173 ) 

174 assert ( 

175 session.execute( 

176 select(func.count()) 

177 .select_from(BackgroundJob) 

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

179 ).scalar_one() 

180 == 0 

181 ) 

182 

183 

184def test_purge_account_deletion_tokens(db): 

185 user, api_token = generate_user() 

186 user2, api_token2 = generate_user() 

187 user3, api_token3 = generate_user() 

188 

189 with session_scope() as session: 

190 """ 

191 3 cases: 

192 1) Token is valid 

193 2) Token expired but account retrievable 

194 3) Account is irretrievable (and expired) 

195 """ 

196 account_deletion_tokens = [ 

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

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

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

200 ] 

201 for token in account_deletion_tokens: 

202 session.add(token) 

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

204 

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

206 process_job() 

207 

208 with session_scope() as session: 

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

210 

211 with session_scope() as session: 

212 assert ( 

213 session.execute( 

214 select(func.count()) 

215 .select_from(BackgroundJob) 

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

217 ).scalar_one() 

218 == 1 

219 ) 

220 assert ( 

221 session.execute( 

222 select(func.count()) 

223 .select_from(BackgroundJob) 

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

225 ).scalar_one() 

226 == 0 

227 ) 

228 

229 

230def test_enforce_community_memberships(db): 

231 with session_scope() as session: 

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

233 process_job() 

234 

235 with session_scope() as session: 

236 assert ( 

237 session.execute( 

238 select(func.count()) 

239 .select_from(BackgroundJob) 

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

241 ).scalar_one() 

242 == 1 

243 ) 

244 assert ( 

245 session.execute( 

246 select(func.count()) 

247 .select_from(BackgroundJob) 

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

249 ).scalar_one() 

250 == 0 

251 ) 

252 

253 

254def test_refresh_materialized_views(db): 

255 with session_scope() as session: 

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

257 

258 process_job() 

259 

260 with session_scope() as session: 

261 assert ( 

262 session.execute( 

263 select(func.count()) 

264 .select_from(BackgroundJob) 

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

266 ).scalar_one() 

267 == 1 

268 ) 

269 assert ( 

270 session.execute( 

271 select(func.count()) 

272 .select_from(BackgroundJob) 

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

274 ).scalar_one() 

275 == 0 

276 ) 

277 

278 

279def test_service_jobs(db): 

280 with session_scope() as session: 

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

282 

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

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

285 class HitSleep(Exception): 

286 pass 

287 

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

289 def raising_sleep(seconds): 

290 raise HitSleep() 

291 

292 with pytest.raises(HitSleep): 

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

294 service_jobs() 

295 

296 with session_scope() as session: 

297 assert ( 

298 session.execute( 

299 select(func.count()) 

300 .select_from(BackgroundJob) 

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

302 ).scalar_one() 

303 == 1 

304 ) 

305 assert ( 

306 session.execute( 

307 select(func.count()) 

308 .select_from(BackgroundJob) 

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

310 ).scalar_one() 

311 == 0 

312 ) 

313 

314 

315def test_scheduler(db, monkeypatch): 

316 def purge_login_tokens(payload: empty_pb2.Empty): 

317 return 

318 

319 def send_message_notifications(payload: empty_pb2.Empty): 

320 return 

321 

322 MOCK_JOBS = { 

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

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

325 } 

326 

327 current_time = 0 

328 end_time = 70 

329 

330 class EndOfTime(Exception): 

331 pass 

332 

333 def mock_monotonic(): 

334 return current_time 

335 

336 def mock_sleep(seconds): 

337 nonlocal current_time 

338 current_time += seconds 

339 if current_time > end_time: 

340 raise EndOfTime() 

341 

342 realized_schedule = [] 

343 

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

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

346 _run_job_and_schedule(sched, job, frequency) 

347 

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

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

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

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

352 

353 with pytest.raises(EndOfTime): 

354 run_scheduler() 

355 

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

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

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

359 

360 assert realized_schedule_indices == [ 

361 (0.0, 0), 

362 (0.0, 1), 

363 (7.0, 0), 

364 (11.0, 1), 

365 (14.0, 0), 

366 (21.0, 0), 

367 (22.0, 1), 

368 (28.0, 0), 

369 (33.0, 1), 

370 (35.0, 0), 

371 (42.0, 0), 

372 (44.0, 1), 

373 (49.0, 0), 

374 (55.0, 1), 

375 (56.0, 0), 

376 (63.0, 0), 

377 (66.0, 1), 

378 (70.0, 0), 

379 ] 

380 

381 with session_scope() as session: 

382 assert ( 

383 session.execute( 

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

385 ).scalar_one() 

386 == 18 

387 ) 

388 assert ( 

389 session.execute( 

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

391 ).scalar_one() 

392 == 0 

393 ) 

394 

395 

396def test_job_retry(db): 

397 called_count = 0 

398 

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

400 nonlocal called_count 

401 called_count += 1 

402 raise Exception() 

403 

404 with session_scope() as session: 

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

406 

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

408 "mock_job": Job(mock_job), 

409 } 

410 create_prometheus_server(port=8000) 

411 

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

413 new_config = config.copy() 

414 new_config["IN_TEST"] = False 

415 

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

417 process_job() 

418 with session_scope() as session: 

419 assert ( 

420 session.execute( 

421 select(func.count()) 

422 .select_from(BackgroundJob) 

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

424 ).scalar_one() 

425 == 1 

426 ) 

427 assert ( 

428 session.execute( 

429 select(func.count()) 

430 .select_from(BackgroundJob) 

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

432 ).scalar_one() 

433 == 0 

434 ) 

435 

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

437 process_job() 

438 with session_scope() as session: 

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

440 process_job() 

441 with session_scope() as session: 

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

443 process_job() 

444 with session_scope() as session: 

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

446 process_job() 

447 

448 with session_scope() as session: 

449 assert ( 

450 session.execute( 

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

452 ).scalar_one() 

453 == 1 

454 ) 

455 assert ( 

456 session.execute( 

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

458 ).scalar_one() 

459 == 0 

460 ) 

461 

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

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

464 

465 

466def test_no_jobs_no_problem(db): 

467 with session_scope() as session: 

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

469 

470 assert not process_job() 

471 

472 with session_scope() as session: 

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

474 

475 

476def test_send_message_notifications_basic(db, moderator): 

477 user1, token1 = generate_user() 

478 user2, token2 = generate_user() 

479 user3, token3 = generate_user() 

480 

481 make_friends(user1, user2) 

482 make_friends(user1, user3) 

483 make_friends(user2, user3) 

484 

485 send_message_notifications(empty_pb2.Empty()) 

486 process_jobs() 

487 

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

489 with session_scope() as session: 

490 assert ( 

491 session.execute( 

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

493 ).scalar_one() 

494 == 0 

495 ) 

496 

497 with conversations_session(token1) as c: 

498 group_chat_id1 = c.CreateGroupChat( 

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

500 ).group_chat_id 

501 moderator.approve_group_chat(group_chat_id1) 

502 

503 with conversations_session(token1) as c: 

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

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

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

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

508 

509 with conversations_session(token3) as c: 

510 group_chat_id2 = c.CreateGroupChat( 

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

512 ).group_chat_id 

513 moderator.approve_group_chat(group_chat_id2) 

514 

515 with conversations_session(token3) as c: 

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

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

518 

519 send_message_notifications(empty_pb2.Empty()) 

520 process_jobs() 

521 

522 # no emails sent out 

523 with session_scope() as session: 

524 assert ( 

525 session.execute( 

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

527 ).scalar_one() 

528 == 0 

529 ) 

530 

531 # this should generate emails for both user2 and user3 

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

533 send_message_notifications(empty_pb2.Empty()) 

534 process_jobs() 

535 

536 with session_scope() as session: 

537 assert ( 

538 session.execute( 

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

540 ).scalar_one() 

541 == 2 

542 ) 

543 # delete them all 

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

545 

546 # shouldn't generate any more emails 

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

548 send_message_notifications(empty_pb2.Empty()) 

549 process_jobs() 

550 

551 with session_scope() as session: 

552 assert ( 

553 session.execute( 

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

555 ).scalar_one() 

556 == 0 

557 ) 

558 

559 

560def test_send_message_notifications_muted(db, moderator): 

561 user1, token1 = generate_user() 

562 user2, token2 = generate_user() 

563 user3, token3 = generate_user() 

564 

565 make_friends(user1, user2) 

566 make_friends(user1, user3) 

567 make_friends(user2, user3) 

568 

569 send_message_notifications(empty_pb2.Empty()) 

570 process_jobs() 

571 

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

573 with session_scope() as session: 

574 assert ( 

575 session.execute( 

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

577 ).scalar_one() 

578 == 0 

579 ) 

580 

581 with conversations_session(token1) as c: 

582 group_chat_id = c.CreateGroupChat( 

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

584 ).group_chat_id 

585 moderator.approve_group_chat(group_chat_id) 

586 

587 with conversations_session(token3) as c: 

588 # mute it for user 3 

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

590 

591 with conversations_session(token1) as c: 

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

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

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

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

596 

597 with conversations_session(token3) as c: 

598 group_chat_id = c.CreateGroupChat( 

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

600 ).group_chat_id 

601 moderator.approve_group_chat(group_chat_id) 

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

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

604 

605 send_message_notifications(empty_pb2.Empty()) 

606 process_jobs() 

607 

608 # no emails sent out 

609 with session_scope() as session: 

610 assert ( 

611 session.execute( 

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

613 ).scalar_one() 

614 == 0 

615 ) 

616 

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

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

619 send_message_notifications(empty_pb2.Empty()) 

620 process_jobs() 

621 

622 with session_scope() as session: 

623 assert ( 

624 session.execute( 

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

626 ).scalar_one() 

627 == 1 

628 ) 

629 # delete them all 

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

631 

632 # shouldn't generate any more emails 

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

634 send_message_notifications(empty_pb2.Empty()) 

635 process_jobs() 

636 

637 with session_scope() as session: 

638 assert ( 

639 session.execute( 

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

641 ).scalar_one() 

642 == 0 

643 ) 

644 

645 

646def test_send_request_notifications_host_request(db, moderator): 

647 user1, token1 = generate_user() 

648 user2, token2 = generate_user() 

649 

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

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

652 

653 send_request_notifications(empty_pb2.Empty()) 

654 process_jobs() 

655 

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

657 with session_scope() as session: 

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

659 

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 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False)) 

670 

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

672 # notified about via host_request__create — no missed_messages email 

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

681 ) 

682 

683 # test that responding to host request creates email 

684 with requests_session(token2) as requests: 

685 requests.RespondHostRequest( 

686 requests_pb2.RespondHostRequestReq( 

687 host_request_id=host_request_id, 

688 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

689 text="Test request", 

690 ) 

691 ) 

692 

693 with session_scope() as session: 

694 # delete send_email BackgroundJob created by RespondHostRequest 

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

696 

697 # check send_request_notifications successfully creates background job 

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

699 send_request_notifications(empty_pb2.Empty()) 

700 process_jobs() 

701 assert ( 

702 session.execute( 

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

704 ).scalar_one() 

705 == 1 

706 ) 

707 

708 # delete all BackgroundJobs 

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

710 

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

712 send_request_notifications(empty_pb2.Empty()) 

713 process_jobs() 

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

715 assert ( 

716 session.execute( 

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

718 ).scalar_one() 

719 == 0 

720 ) 

721 

722 

723def test_send_request_notifications_host_request_with_followup(db, moderator): 

724 """ 

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

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

727 creation message alone would be skipped). 

728 """ 

729 user1, token1 = generate_user() 

730 user2, token2 = generate_user() 

731 

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

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

734 

735 with requests_session(token1) as requests: 

736 host_request_id = requests.CreateHostRequest( 

737 requests_pb2.CreateHostRequestReq( 

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

739 ) 

740 ).host_request_id 

741 moderator.approve_host_request(host_request_id) 

742 

743 # surfer sends a follow-up message 

744 with requests_session(token1) as requests: 

745 requests.SendHostRequestMessage( 

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

747 ) 

748 

749 with session_scope() as session: 

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

751 

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

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

754 send_request_notifications(empty_pb2.Empty()) 

755 process_jobs() 

756 assert ( 

757 session.execute( 

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

759 ).scalar_one() 

760 == 1 

761 ) 

762 

763 

764def test_send_request_notifications_two_requests_one_with_followup(db, moderator): 

765 """ 

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

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

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

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

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

771 materialized before the loop begins. 

772 """ 

773 user1, token1 = generate_user() 

774 user2, token2 = generate_user() 

775 user3, token3 = generate_user() 

776 

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

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

779 

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

781 with requests_session(token1) as requests: 

782 host_request_a = requests.CreateHostRequest( 

783 requests_pb2.CreateHostRequestReq( 

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

785 ) 

786 ).host_request_id 

787 moderator.approve_host_request(host_request_a) 

788 

789 with requests_session(token1) as requests: 

790 requests.SendHostRequestMessage( 

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

792 ) 

793 

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

795 with requests_session(token3) as requests: 

796 host_request_b = requests.CreateHostRequest( 

797 requests_pb2.CreateHostRequestReq( 

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

799 ) 

800 ).host_request_id 

801 moderator.approve_host_request(host_request_b) 

802 

803 with session_scope() as session: 

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

805 

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

807 # not request B (creation only, skipped) 

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

809 send_request_notifications(empty_pb2.Empty()) 

810 process_jobs() 

811 assert ( 

812 session.execute( 

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

814 ).scalar_one() 

815 == 1 

816 ) 

817 

818 

819def test_send_message_notifications_seen(db, moderator): 

820 user1, token1 = generate_user() 

821 user2, token2 = generate_user() 

822 

823 make_friends(user1, user2) 

824 

825 send_message_notifications(empty_pb2.Empty()) 

826 

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

828 with session_scope() as session: 

829 assert ( 

830 session.execute( 

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

832 ).scalar_one() 

833 == 0 

834 ) 

835 

836 with conversations_session(token1) as c: 

837 group_chat_id = c.CreateGroupChat( 

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

839 ).group_chat_id 

840 moderator.approve_group_chat(group_chat_id) 

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

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

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

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

845 

846 # user 2 now marks those messages as seen 

847 with conversations_session(token2) as c: 

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

849 c.MarkLastSeenGroupChat( 

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

851 ) 

852 

853 send_message_notifications(empty_pb2.Empty()) 

854 

855 # no emails sent out 

856 with session_scope() as session: 

857 assert ( 

858 session.execute( 

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

860 ).scalar_one() 

861 == 0 

862 ) 

863 

864 def now_30_min_in_future(): 

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

866 

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

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

869 send_message_notifications(empty_pb2.Empty()) 

870 

871 with session_scope() as session: 

872 assert ( 

873 session.execute( 

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

875 ).scalar_one() 

876 == 0 

877 ) 

878 

879 

880def test_send_onboarding_emails(db): 

881 # needs to get first onboarding email 

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

883 

884 send_onboarding_emails(empty_pb2.Empty()) 

885 process_jobs() 

886 

887 with session_scope() as session: 

888 assert ( 

889 session.execute( 

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

891 ).scalar_one() 

892 == 1 

893 ) 

894 

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

896 user2, token2 = generate_user( 

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

898 ) 

899 

900 send_onboarding_emails(empty_pb2.Empty()) 

901 process_jobs() 

902 

903 with session_scope() as session: 

904 assert ( 

905 session.execute( 

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

907 ).scalar_one() 

908 == 1 

909 ) 

910 

911 # needs to get second onboarding email 

912 user3, token3 = generate_user( 

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

914 ) 

915 

916 send_onboarding_emails(empty_pb2.Empty()) 

917 process_jobs() 

918 

919 with session_scope() as session: 

920 assert ( 

921 session.execute( 

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

923 ).scalar_one() 

924 == 2 

925 ) 

926 

927 

928def test_send_reference_reminders(db): 

929 # need to test: 

930 # case 1: bidirectional (no emails) 

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

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

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

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

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

936 

937 send_reference_reminders(empty_pb2.Empty()) 

938 

939 # case 1: bidirectional (no emails) 

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

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

942 

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

944 # host 

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

946 # surfer 

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

948 

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

950 # host 

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

952 # surfer 

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

954 

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

956 # surfer 

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

958 # host 

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

960 

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

962 # surfer 

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

964 # host 

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

966 

967 make_user_block(user9, user10) 

968 

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

970 # host 

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

972 # surfer 

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

974 

975 with session_scope() as session: 

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

977 

978 # case 1: bidirectional (no emails) 

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

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

981 

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

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

984 

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

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

987 

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

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

990 

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

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

993 

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

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

996 

997 expected_emails = [ 

998 ( 

999 "user11@couchers.org.invalid", 

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

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

1002 ), 

1003 ( 

1004 "user4@couchers.org.invalid", 

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

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

1007 ), 

1008 ( 

1009 "user5@couchers.org.invalid", 

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

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

1012 ), 

1013 ( 

1014 "user7@couchers.org.invalid", 

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

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

1017 ), 

1018 ( 

1019 "user8@couchers.org.invalid", 

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

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

1022 ), 

1023 ] 

1024 

1025 send_reference_reminders(empty_pb2.Empty()) 

1026 

1027 while process_job(): 

1028 pass 

1029 

1030 with session_scope() as session: 

1031 emails = [ 

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

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

1034 ] 

1035 

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

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

1038 

1039 print(actual_addresses_and_subjects) 

1040 print(expected_addresses_and_subjects) 

1041 

1042 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1043 

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

1045 for find in search_strings: 

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

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

1048 

1049 

1050def test_send_host_request_reminders(db, moderator): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1065 

1066 with session_scope() as session: 

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

1068 hr1 = create_host_request_by_date( 

1069 session=session, 

1070 surfer_user_id=user1.id, 

1071 host_user_id=user2.id, 

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

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

1074 status=HostRequestStatus.pending, 

1075 host_sent_request_reminders=0, 

1076 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1077 ) 

1078 

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

1080 hr2 = create_host_request_by_date( 

1081 session=session, 

1082 surfer_user_id=user3.id, 

1083 host_user_id=user4.id, 

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

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

1086 status=HostRequestStatus.pending, 

1087 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS, 

1088 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1089 ) 

1090 

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

1092 hr3 = create_host_request_by_date( 

1093 session=session, 

1094 surfer_user_id=user5.id, 

1095 host_user_id=user6.id, 

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

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

1098 status=HostRequestStatus.pending, 

1099 host_sent_request_reminders=0, 

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

1101 ) 

1102 

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

1104 hr4 = create_host_request_by_date( 

1105 session=session, 

1106 surfer_user_id=user7.id, 

1107 host_user_id=user8.id, 

1108 from_date=today(), 

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

1110 status=HostRequestStatus.pending, 

1111 host_sent_request_reminders=0, 

1112 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1113 ) 

1114 

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

1116 hr5 = create_host_request_by_date( 

1117 session=session, 

1118 surfer_user_id=user9.id, 

1119 host_user_id=user10.id, 

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

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

1122 status=HostRequestStatus.pending, 

1123 host_sent_request_reminders=0, 

1124 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1125 ) 

1126 

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

1128 hr6 = create_host_request_by_date( 

1129 session=session, 

1130 surfer_user_id=user11.id, 

1131 host_user_id=user12.id, 

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

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

1134 status=HostRequestStatus.accepted, 

1135 host_sent_request_reminders=0, 

1136 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1137 ) 

1138 

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

1140 hr7 = create_host_request_by_date( 

1141 session=session, 

1142 surfer_user_id=user13.id, 

1143 host_user_id=user14.id, 

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

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

1146 status=HostRequestStatus.pending, 

1147 host_sent_request_reminders=0, 

1148 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1149 ) 

1150 

1151 msg = Message( 

1152 conversation_id=hr7, 

1153 author_id=user14.id, 

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

1155 message_type=MessageType.text, 

1156 ) 

1157 msg.time = now() 

1158 session.add(msg) 

1159 

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

1161 moderator.approve_host_request(hr1) 

1162 moderator.approve_host_request(hr2) 

1163 moderator.approve_host_request(hr3) 

1164 moderator.approve_host_request(hr4) 

1165 moderator.approve_host_request(hr5) 

1166 moderator.approve_host_request(hr6) 

1167 moderator.approve_host_request(hr7) 

1168 

1169 send_host_request_reminders(empty_pb2.Empty()) 

1170 

1171 while process_job(): 

1172 pass 

1173 

1174 with session_scope() as session: 

1175 emails = [ 

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

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

1178 ] 

1179 

1180 expected_emails = [ 

1181 ( 

1182 "user2@couchers.org.invalid", 

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

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

1185 ) 

1186 ] 

1187 

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

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

1190 

1191 print(actual_addresses_and_subjects) 

1192 print(expected_addresses_and_subjects) 

1193 

1194 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1195 

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

1197 for find in search_strings: 

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

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

1200 

1201 

1202def test_add_users_to_email_list(db): 

1203 new_config = config.copy() 

1204 new_config["LISTMONK_ENABLED"] = True 

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

1206 new_config["LISTMONK_API_USERNAME"] = "test_user" 

1207 new_config["LISTMONK_API_KEY"] = "dummy_api_key" 

1208 new_config["LISTMONK_LIST_ID"] = 6 

1209 

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

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

1212 add_users_to_email_list(empty_pb2.Empty()) 

1213 mock.assert_not_called() 

1214 

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

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

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

1218 generate_user( 

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

1220 ) 

1221 

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

1223 ret = mock.return_value 

1224 ret.status_code = 200 

1225 add_users_to_email_list(empty_pb2.Empty()) 

1226 mock.assert_has_calls( 

1227 [ 

1228 call( 

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

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

1231 json={ 

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

1233 "name": "Tester1", 

1234 "lists": [6], 

1235 "preconfirm_subscriptions": True, 

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

1237 "status": "enabled", 

1238 }, 

1239 timeout=10, 

1240 ), 

1241 call( 

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

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

1244 json={ 

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

1246 "name": "Tester3 von test", 

1247 "lists": [6], 

1248 "preconfirm_subscriptions": True, 

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

1250 "status": "enabled", 

1251 }, 

1252 timeout=10, 

1253 ), 

1254 ], 

1255 any_order=True, 

1256 ) 

1257 

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

1259 add_users_to_email_list(empty_pb2.Empty()) 

1260 mock.assert_not_called() 

1261 

1262 

1263def test_update_recommendation_scores(db): 

1264 update_recommendation_scores(empty_pb2.Empty()) 

1265 

1266 

1267def test_update_badges(db, push_collector: PushCollector): 

1268 user1, _ = generate_user(last_donated=None) 

1269 user2, _ = generate_user(last_donated=None) 

1270 user3, _ = generate_user(last_donated=None) 

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

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

1273 user6, _ = generate_user(last_donated=None) 

1274 

1275 with session_scope() as session: 

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

1277 

1278 update_badges(empty_pb2.Empty()) 

1279 process_jobs() 

1280 

1281 with session_scope() as session: 

1282 badge_tuples = session.execute( 

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

1284 ).all() 

1285 

1286 expected = [ 

1287 (user1.id, "founder"), 

1288 (user1.id, "board_member"), 

1289 (user2.id, "founder"), 

1290 (user2.id, "board_member"), 

1291 (user4.id, "phone_verified"), 

1292 (user5.id, "phone_verified"), 

1293 ] 

1294 

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

1296 

1297 print(push_collector.by_user) 

1298 

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

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

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

1302 

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

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

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

1306 

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

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

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

1310 

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

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

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

1314 

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

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

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

1318 

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

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

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

1322 

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

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

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

1326 

1327 

1328def test_send_request_notifications_blocked_users_no_notification(db, moderator): 

1329 """ 

1330 Regression test: send_request_notifications should not send notifications 

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

1332 """ 

1333 user1, token1 = generate_user() 

1334 user2, token2 = generate_user() 

1335 

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

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

1338 

1339 # Create a host request 

1340 with requests_session(token1) as requests: 

1341 host_request_id = requests.CreateHostRequest( 

1342 requests_pb2.CreateHostRequestReq( 

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

1344 ) 

1345 ).host_request_id 

1346 moderator.approve_host_request(host_request_id) 

1347 

1348 with session_scope() as session: 

1349 # delete send_email BackgroundJob created by CreateHostRequest 

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

1351 

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

1353 make_user_block(user2, user1) 

1354 

1355 with session_scope() as session: 

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

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

1358 send_request_notifications(empty_pb2.Empty()) 

1359 process_jobs() 

1360 

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

1362 assert ( 

1363 session.execute( 

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

1365 ).scalar_one() 

1366 == 0 

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

1368 

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

1370 # First unblock 

1371 with session_scope() as session: 

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

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

1374 

1375 # Host responds 

1376 with requests_session(token2) as requests: 

1377 requests.RespondHostRequest( 

1378 requests_pb2.RespondHostRequestReq( 

1379 host_request_id=host_request_id, 

1380 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1381 text="Accepting your request", 

1382 ) 

1383 ) 

1384 

1385 with session_scope() as session: 

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

1387 

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

1389 make_user_block(user1, user2) 

1390 

1391 with session_scope() as session: 

1392 # check send_request_notifications does NOT create background job 

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

1394 send_request_notifications(empty_pb2.Empty()) 

1395 process_jobs() 

1396 

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

1398 assert ( 

1399 session.execute( 

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

1401 ).scalar_one() 

1402 == 0 

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

1404 

1405 

1406def test_send_host_request_reminders_blocked_users_no_notification(db, moderator): 

1407 """ 

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

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

1410 """ 

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

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

1413 

1414 with session_scope() as session: 

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

1416 hr = create_host_request_by_date( 

1417 session=session, 

1418 surfer_user_id=user1.id, 

1419 host_user_id=user2.id, 

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

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

1422 status=HostRequestStatus.pending, 

1423 host_sent_request_reminders=0, 

1424 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1425 ) 

1426 

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

1428 moderator.approve_host_request(hr) 

1429 

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

1431 send_host_request_reminders(empty_pb2.Empty()) 

1432 

1433 while process_job(): 

1434 pass 

1435 

1436 with session_scope() as session: 

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

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

1439 

1440 # Clean up emails and background jobs 

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

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

1443 

1444 # Reset the reminder counter so we can test again 

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

1446 host_request.recipient_sent_request_reminders = 0 

1447 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL 

1448 

1449 # Now have the host block the surfer 

1450 make_user_block(user2, user1) 

1451 

1452 send_host_request_reminders(empty_pb2.Empty()) 

1453 

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

1455 pass 

1456 

1457 with session_scope() as session: 

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

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

1460 

1461 

1462def test_send_message_notifications_blocked_users_no_notification(db, moderator): 

1463 """ 

1464 Regression test: send_message_notifications should not send notifications 

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

1466 """ 

1467 user1, token1 = generate_user() 

1468 user2, token2 = generate_user() 

1469 

1470 make_friends(user1, user2) 

1471 

1472 # Create a group chat and send messages 

1473 with conversations_session(token1) as c: 

1474 group_chat_id = c.CreateGroupChat( 

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

1476 ).group_chat_id 

1477 

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

1479 moderator.approve_group_chat(group_chat_id) 

1480 

1481 with conversations_session(token1) as c: 

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

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

1484 

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

1486 with session_scope() as session: 

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

1488 

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

1490 send_message_notifications(empty_pb2.Empty()) 

1491 process_jobs() 

1492 

1493 with session_scope() as session: 

1494 email_job_count = session.execute( 

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

1496 ).scalar_one() 

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

1498 

1499 # Clean up 

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

1501 

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

1503 with session_scope() as session: 

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

1505 u2.last_notified_message_id = 0 

1506 

1507 # Now have user2 block user1 

1508 make_user_block(user2, user1) 

1509 

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

1511 # since user2 has blocked user1 

1512 with session_scope() as session: 

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

1514 

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

1516 send_message_notifications(empty_pb2.Empty()) 

1517 process_jobs() 

1518 

1519 with session_scope() as session: 

1520 email_job_count = session.execute( 

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

1522 ).scalar_one() 

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

1524 

1525 

1526def test_update_badges_volunteers(db, push_collector: PushCollector): 

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

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

1529 user1, _ = generate_user(last_donated=None) 

1530 user2, _ = generate_user(last_donated=None) 

1531 user3, _ = generate_user(last_donated=None) 

1532 user4, _ = generate_user(last_donated=None) 

1533 user5, _ = generate_user(last_donated=None) 

1534 user6, _ = generate_user(last_donated=None) 

1535 

1536 with session_scope() as session: 

1537 # user3: active volunteer (stopped_volunteering is null) 

1538 session.add( 

1539 make_volunteer( 

1540 user_id=user3.id, 

1541 role="Developer", 

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

1543 stopped_volunteering=None, 

1544 ) 

1545 ) 

1546 

1547 # user4: past volunteer (stopped_volunteering is set) 

1548 session.add( 

1549 make_volunteer( 

1550 user_id=user4.id, 

1551 role="Designer", 

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

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

1554 ) 

1555 ) 

1556 

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

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

1559 

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

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

1562 

1563 update_badges(empty_pb2.Empty()) 

1564 process_jobs() 

1565 

1566 with session_scope() as session: 

1567 # Check user3 has volunteer badge 

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

1569 assert "volunteer" in user3_badges 

1570 assert "past_volunteer" not in user3_badges 

1571 

1572 # Check user4 has past_volunteer badge 

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

1574 assert "past_volunteer" in user4_badges 

1575 assert "volunteer" not in user4_badges 

1576 

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

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

1579 assert "volunteer" not in user5_badges 

1580 

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

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

1583 assert "past_volunteer" not in user6_badges 

1584 

1585 # Check notifications for volunteer badge users 

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

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

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

1589 

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

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

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

1593 

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

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

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

1597 

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

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

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

1601 

1602 

1603def test_update_badges_volunteer_status_change(db, push_collector: PushCollector): 

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

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

1606 user1, _ = generate_user(last_donated=None) 

1607 user2, _ = generate_user(last_donated=None) 

1608 user3, _ = generate_user(last_donated=None) 

1609 

1610 with session_scope() as session: 

1611 # user3: start as active volunteer 

1612 session.add( 

1613 make_volunteer( 

1614 user_id=user3.id, 

1615 role="Developer", 

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

1617 stopped_volunteering=None, 

1618 show_on_team_page=True, 

1619 ) 

1620 ) 

1621 

1622 update_badges(empty_pb2.Empty()) 

1623 process_jobs() 

1624 

1625 with session_scope() as session: 

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

1627 assert "volunteer" in user3_badges 

1628 assert "past_volunteer" not in user3_badges 

1629 

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

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

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

1633 

1634 # Now change the volunteer to past volunteer 

1635 with session_scope() as session: 

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

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

1638 

1639 update_badges(empty_pb2.Empty()) 

1640 process_jobs() 

1641 

1642 with session_scope() as session: 

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

1644 assert "volunteer" not in user3_badges 

1645 assert "past_volunteer" in user3_badges 

1646 

1647 # Check both badges were updated 

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

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

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

1651 

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

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

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

1655 

1656 

1657def test_send_message_notifications_empty_unseen_simple(monkeypatch): 

1658 class DummyUser: 

1659 id = 1 

1660 is_visible = True 

1661 last_notified_message_id = 0 

1662 

1663 class FirstResult: 

1664 def scalars(self): 

1665 return self 

1666 

1667 def unique(self): 

1668 return [DummyUser()] 

1669 

1670 class SecondResult: 

1671 def all(self): 

1672 return [] 

1673 

1674 class DummySession: 

1675 def __init__(self): 

1676 self.calls = 0 

1677 

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

1679 self.calls += 1 

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

1681 

1682 def commit(self): 

1683 pass 

1684 

1685 def flush(self): 

1686 pass 

1687 

1688 def fake_session_scope(): 

1689 class Ctx: 

1690 def __enter__(self): 

1691 return DummySession() 

1692 

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

1694 pass 

1695 

1696 return Ctx() 

1697 

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

1699 

1700 handlers.send_message_notifications(Empty())