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

718 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 11:04 +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 UserBadge, 

51 UserBlock, 

52 Volunteer, 

53) 

54from couchers.proto import conversations_pb2, requests_pb2 

55from couchers.utils import now, today 

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

57from tests.fixtures.misc import PushCollector, process_jobs 

58from tests.fixtures.sessions import conversations_session, requests_session 

59from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date 

60from tests.test_requests import valid_request_text 

61 

62 

63def now_5_min_in_future() -> datetime: 

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

65 

66 

67@pytest.fixture(autouse=True) 

68def _(testconfig): 

69 pass 

70 

71 

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

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

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

75 assert string_to_check in metrics_string 

76 

77 

78def test_email_job(db): 

79 with session_scope() as session: 

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

81 

82 def mock_print_dev_email( 

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

84 ): 

85 assert sender_name == "sender_name" 

86 assert sender_email == "sender_email" 

87 assert recipient == "recipient" 

88 assert subject == "subject" 

89 assert plain == "plain" 

90 assert html == "html" 

91 return print_dev_email( 

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

93 ) 

94 

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

96 process_job() 

97 

98 with session_scope() as session: 

99 assert ( 

100 session.execute( 

101 select(func.count()) 

102 .select_from(BackgroundJob) 

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

104 ).scalar_one() 

105 == 1 

106 ) 

107 assert ( 

108 session.execute( 

109 select(func.count()) 

110 .select_from(BackgroundJob) 

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

112 ).scalar_one() 

113 == 0 

114 ) 

115 

116 

117def test_purge_login_tokens(db): 

118 user, api_token = generate_user() 

119 

120 with session_scope() as session: 

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

122 session.add(login_token) 

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

124 

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

126 process_job() 

127 

128 with session_scope() as session: 

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

130 

131 with session_scope() as session: 

132 assert ( 

133 session.execute( 

134 select(func.count()) 

135 .select_from(BackgroundJob) 

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

137 ).scalar_one() 

138 == 1 

139 ) 

140 assert ( 

141 session.execute( 

142 select(func.count()) 

143 .select_from(BackgroundJob) 

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

145 ).scalar_one() 

146 == 0 

147 ) 

148 

149 

150def test_purge_password_reset_tokens(db): 

151 user, api_token = generate_user() 

152 

153 with session_scope() as session: 

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

155 session.add(password_reset_token) 

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

157 

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

159 process_job() 

160 

161 with session_scope() as session: 

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

163 

164 with session_scope() as session: 

165 assert ( 

166 session.execute( 

167 select(func.count()) 

168 .select_from(BackgroundJob) 

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

170 ).scalar_one() 

171 == 1 

172 ) 

173 assert ( 

174 session.execute( 

175 select(func.count()) 

176 .select_from(BackgroundJob) 

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

178 ).scalar_one() 

179 == 0 

180 ) 

181 

182 

183def test_purge_account_deletion_tokens(db): 

184 user, api_token = generate_user() 

185 user2, api_token2 = generate_user() 

186 user3, api_token3 = generate_user() 

187 

188 with session_scope() as session: 

189 """ 

190 3 cases: 

191 1) Token is valid 

192 2) Token expired but account retrievable 

193 3) Account is irretrievable (and expired) 

194 """ 

195 account_deletion_tokens = [ 

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

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

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

199 ] 

200 for token in account_deletion_tokens: 

201 session.add(token) 

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

203 

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

205 process_job() 

206 

207 with session_scope() as session: 

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

209 

210 with session_scope() as session: 

211 assert ( 

212 session.execute( 

213 select(func.count()) 

214 .select_from(BackgroundJob) 

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

216 ).scalar_one() 

217 == 1 

218 ) 

219 assert ( 

220 session.execute( 

221 select(func.count()) 

222 .select_from(BackgroundJob) 

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

224 ).scalar_one() 

225 == 0 

226 ) 

227 

228 

229def test_enforce_community_memberships(db): 

230 with session_scope() as session: 

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

232 process_job() 

233 

234 with session_scope() as session: 

235 assert ( 

236 session.execute( 

237 select(func.count()) 

238 .select_from(BackgroundJob) 

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

240 ).scalar_one() 

241 == 1 

242 ) 

243 assert ( 

244 session.execute( 

245 select(func.count()) 

246 .select_from(BackgroundJob) 

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

248 ).scalar_one() 

249 == 0 

250 ) 

251 

252 

253def test_refresh_materialized_views(db): 

254 with session_scope() as session: 

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

256 

257 process_job() 

258 

259 with session_scope() as session: 

260 assert ( 

261 session.execute( 

262 select(func.count()) 

263 .select_from(BackgroundJob) 

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

265 ).scalar_one() 

266 == 1 

267 ) 

268 assert ( 

269 session.execute( 

270 select(func.count()) 

271 .select_from(BackgroundJob) 

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

273 ).scalar_one() 

274 == 0 

275 ) 

276 

277 

278def test_service_jobs(db): 

279 with session_scope() as session: 

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

281 

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

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

284 class HitSleep(Exception): 

285 pass 

286 

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

288 def raising_sleep(seconds): 

289 raise HitSleep() 

290 

291 with pytest.raises(HitSleep): 

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

293 service_jobs() 

294 

295 with session_scope() as session: 

296 assert ( 

297 session.execute( 

298 select(func.count()) 

299 .select_from(BackgroundJob) 

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

301 ).scalar_one() 

302 == 1 

303 ) 

304 assert ( 

305 session.execute( 

306 select(func.count()) 

307 .select_from(BackgroundJob) 

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

309 ).scalar_one() 

310 == 0 

311 ) 

312 

313 

314def test_scheduler(db, monkeypatch): 

315 def purge_login_tokens(payload: empty_pb2.Empty): 

316 return 

317 

318 def send_message_notifications(payload: empty_pb2.Empty): 

319 return 

320 

321 MOCK_JOBS = { 

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

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

324 } 

325 

326 current_time = 0 

327 end_time = 70 

328 

329 class EndOfTime(Exception): 

330 pass 

331 

332 def mock_monotonic(): 

333 return current_time 

334 

335 def mock_sleep(seconds): 

336 nonlocal current_time 

337 current_time += seconds 

338 if current_time > end_time: 

339 raise EndOfTime() 

340 

341 realized_schedule = [] 

342 

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

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

345 _run_job_and_schedule(sched, job, frequency) 

346 

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

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

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

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

351 

352 with pytest.raises(EndOfTime): 

353 run_scheduler() 

354 

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

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

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

358 

359 assert realized_schedule_indices == [ 

360 (0.0, 0), 

361 (0.0, 1), 

362 (7.0, 0), 

363 (11.0, 1), 

364 (14.0, 0), 

365 (21.0, 0), 

366 (22.0, 1), 

367 (28.0, 0), 

368 (33.0, 1), 

369 (35.0, 0), 

370 (42.0, 0), 

371 (44.0, 1), 

372 (49.0, 0), 

373 (55.0, 1), 

374 (56.0, 0), 

375 (63.0, 0), 

376 (66.0, 1), 

377 (70.0, 0), 

378 ] 

379 

380 with session_scope() as session: 

381 assert ( 

382 session.execute( 

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

384 ).scalar_one() 

385 == 18 

386 ) 

387 assert ( 

388 session.execute( 

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

390 ).scalar_one() 

391 == 0 

392 ) 

393 

394 

395def test_job_retry(db): 

396 called_count = 0 

397 

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

399 nonlocal called_count 

400 called_count += 1 

401 raise Exception() 

402 

403 with session_scope() as session: 

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

405 

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

407 "mock_job": Job(mock_job), 

408 } 

409 create_prometheus_server(port=8000) 

410 

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

412 new_config = config.copy() 

413 new_config["IN_TEST"] = False 

414 

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

416 process_job() 

417 with session_scope() as session: 

418 assert ( 

419 session.execute( 

420 select(func.count()) 

421 .select_from(BackgroundJob) 

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

423 ).scalar_one() 

424 == 1 

425 ) 

426 assert ( 

427 session.execute( 

428 select(func.count()) 

429 .select_from(BackgroundJob) 

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

431 ).scalar_one() 

432 == 0 

433 ) 

434 

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

436 process_job() 

437 with session_scope() as session: 

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

439 process_job() 

440 with session_scope() as session: 

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

442 process_job() 

443 with session_scope() as session: 

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

445 process_job() 

446 

447 with session_scope() as session: 

448 assert ( 

449 session.execute( 

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

451 ).scalar_one() 

452 == 1 

453 ) 

454 assert ( 

455 session.execute( 

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

457 ).scalar_one() 

458 == 0 

459 ) 

460 

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

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

463 

464 

465def test_no_jobs_no_problem(db): 

466 with session_scope() as session: 

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

468 

469 assert not process_job() 

470 

471 with session_scope() as session: 

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

473 

474 

475def test_send_message_notifications_basic(db, moderator): 

476 user1, token1 = generate_user() 

477 user2, token2 = generate_user() 

478 user3, token3 = generate_user() 

479 

480 make_friends(user1, user2) 

481 make_friends(user1, user3) 

482 make_friends(user2, user3) 

483 

484 send_message_notifications(empty_pb2.Empty()) 

485 process_jobs() 

486 

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

488 with session_scope() as session: 

489 assert ( 

490 session.execute( 

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

492 ).scalar_one() 

493 == 0 

494 ) 

495 

496 with conversations_session(token1) as c: 

497 group_chat_id1 = c.CreateGroupChat( 

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

499 ).group_chat_id 

500 moderator.approve_group_chat(group_chat_id1) 

501 

502 with conversations_session(token1) as c: 

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

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

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

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

507 

508 with conversations_session(token3) as c: 

509 group_chat_id2 = c.CreateGroupChat( 

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

511 ).group_chat_id 

512 moderator.approve_group_chat(group_chat_id2) 

513 

514 with conversations_session(token3) as c: 

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

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

517 

518 send_message_notifications(empty_pb2.Empty()) 

519 process_jobs() 

520 

521 # no emails sent out 

522 with session_scope() as session: 

523 assert ( 

524 session.execute( 

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

526 ).scalar_one() 

527 == 0 

528 ) 

529 

530 # this should generate emails for both user2 and user3 

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

532 send_message_notifications(empty_pb2.Empty()) 

533 process_jobs() 

534 

535 with session_scope() as session: 

536 assert ( 

537 session.execute( 

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

539 ).scalar_one() 

540 == 2 

541 ) 

542 # delete them all 

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

544 

545 # shouldn't generate any more emails 

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

547 send_message_notifications(empty_pb2.Empty()) 

548 process_jobs() 

549 

550 with session_scope() as session: 

551 assert ( 

552 session.execute( 

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

554 ).scalar_one() 

555 == 0 

556 ) 

557 

558 

559def test_send_message_notifications_muted(db, moderator): 

560 user1, token1 = generate_user() 

561 user2, token2 = generate_user() 

562 user3, token3 = generate_user() 

563 

564 make_friends(user1, user2) 

565 make_friends(user1, user3) 

566 make_friends(user2, user3) 

567 

568 send_message_notifications(empty_pb2.Empty()) 

569 process_jobs() 

570 

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

572 with session_scope() as session: 

573 assert ( 

574 session.execute( 

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

576 ).scalar_one() 

577 == 0 

578 ) 

579 

580 with conversations_session(token1) as c: 

581 group_chat_id = c.CreateGroupChat( 

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

583 ).group_chat_id 

584 moderator.approve_group_chat(group_chat_id) 

585 

586 with conversations_session(token3) as c: 

587 # mute it for user 3 

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

589 

590 with conversations_session(token1) as c: 

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

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

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

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

595 

596 with conversations_session(token3) as c: 

597 group_chat_id = c.CreateGroupChat( 

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

599 ).group_chat_id 

600 moderator.approve_group_chat(group_chat_id) 

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

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

603 

604 send_message_notifications(empty_pb2.Empty()) 

605 process_jobs() 

606 

607 # no emails sent out 

608 with session_scope() as session: 

609 assert ( 

610 session.execute( 

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

612 ).scalar_one() 

613 == 0 

614 ) 

615 

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

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

618 send_message_notifications(empty_pb2.Empty()) 

619 process_jobs() 

620 

621 with session_scope() as session: 

622 assert ( 

623 session.execute( 

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

625 ).scalar_one() 

626 == 1 

627 ) 

628 # delete them all 

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

630 

631 # shouldn't generate any more emails 

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

633 send_message_notifications(empty_pb2.Empty()) 

634 process_jobs() 

635 

636 with session_scope() as session: 

637 assert ( 

638 session.execute( 

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

640 ).scalar_one() 

641 == 0 

642 ) 

643 

644 

645def test_send_request_notifications_host_request(db, moderator): 

646 user1, token1 = generate_user() 

647 user2, token2 = generate_user() 

648 

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

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

651 

652 send_request_notifications(empty_pb2.Empty()) 

653 process_jobs() 

654 

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

656 with session_scope() as session: 

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

658 

659 with requests_session(token1) as requests: 

660 host_request_id = requests.CreateHostRequest( 

661 requests_pb2.CreateHostRequestReq( 

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

663 ) 

664 ).host_request_id 

665 moderator.approve_host_request(host_request_id) 

666 

667 with session_scope() as session: 

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

669 

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

671 # notified about via host_request__create — no missed_messages email 

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

673 send_request_notifications(empty_pb2.Empty()) 

674 process_jobs() 

675 assert ( 

676 session.execute( 

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

678 ).scalar_one() 

679 == 0 

680 ) 

681 

682 # test that responding to host request creates email 

683 with requests_session(token2) as requests: 

684 requests.RespondHostRequest( 

685 requests_pb2.RespondHostRequestReq( 

686 host_request_id=host_request_id, 

687 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

688 text="Test request", 

689 ) 

690 ) 

691 

692 with session_scope() as session: 

693 # delete send_email BackgroundJob created by RespondHostRequest 

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

695 

696 # check send_request_notifications successfully creates background job 

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

698 send_request_notifications(empty_pb2.Empty()) 

699 process_jobs() 

700 assert ( 

701 session.execute( 

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

703 ).scalar_one() 

704 == 1 

705 ) 

706 

707 # delete all BackgroundJobs 

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

709 

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

711 send_request_notifications(empty_pb2.Empty()) 

712 process_jobs() 

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

714 assert ( 

715 session.execute( 

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

717 ).scalar_one() 

718 == 0 

719 ) 

720 

721 

722def test_send_request_notifications_host_request_with_followup(db, moderator): 

723 """ 

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

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

726 creation message alone would be skipped). 

727 """ 

728 user1, token1 = generate_user() 

729 user2, token2 = generate_user() 

730 

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

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

733 

734 with requests_session(token1) as requests: 

735 host_request_id = requests.CreateHostRequest( 

736 requests_pb2.CreateHostRequestReq( 

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

738 ) 

739 ).host_request_id 

740 moderator.approve_host_request(host_request_id) 

741 

742 # surfer sends a follow-up message 

743 with requests_session(token1) as requests: 

744 requests.SendHostRequestMessage( 

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

746 ) 

747 

748 with session_scope() as session: 

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

750 

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

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

753 send_request_notifications(empty_pb2.Empty()) 

754 process_jobs() 

755 assert ( 

756 session.execute( 

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

758 ).scalar_one() 

759 == 1 

760 ) 

761 

762 

763def test_send_request_notifications_two_requests_one_with_followup(db, moderator): 

764 """ 

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

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

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

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

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

770 materialized before the loop begins. 

771 """ 

772 user1, token1 = generate_user() 

773 user2, token2 = generate_user() 

774 user3, token3 = generate_user() 

775 

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

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

778 

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

780 with requests_session(token1) as requests: 

781 host_request_a = requests.CreateHostRequest( 

782 requests_pb2.CreateHostRequestReq( 

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

784 ) 

785 ).host_request_id 

786 moderator.approve_host_request(host_request_a) 

787 

788 with requests_session(token1) as requests: 

789 requests.SendHostRequestMessage( 

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

791 ) 

792 

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

794 with requests_session(token3) as requests: 

795 host_request_b = requests.CreateHostRequest( 

796 requests_pb2.CreateHostRequestReq( 

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

798 ) 

799 ).host_request_id 

800 moderator.approve_host_request(host_request_b) 

801 

802 with session_scope() as session: 

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

804 

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

806 # not request B (creation only, skipped) 

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

808 send_request_notifications(empty_pb2.Empty()) 

809 process_jobs() 

810 assert ( 

811 session.execute( 

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

813 ).scalar_one() 

814 == 1 

815 ) 

816 

817 

818def test_send_message_notifications_seen(db, moderator): 

819 user1, token1 = generate_user() 

820 user2, token2 = generate_user() 

821 

822 make_friends(user1, user2) 

823 

824 send_message_notifications(empty_pb2.Empty()) 

825 

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

827 with session_scope() as session: 

828 assert ( 

829 session.execute( 

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

831 ).scalar_one() 

832 == 0 

833 ) 

834 

835 with conversations_session(token1) as c: 

836 group_chat_id = c.CreateGroupChat( 

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

838 ).group_chat_id 

839 moderator.approve_group_chat(group_chat_id) 

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

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

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

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

844 

845 # user 2 now marks those messages as seen 

846 with conversations_session(token2) as c: 

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

848 c.MarkLastSeenGroupChat( 

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

850 ) 

851 

852 send_message_notifications(empty_pb2.Empty()) 

853 

854 # no emails sent out 

855 with session_scope() as session: 

856 assert ( 

857 session.execute( 

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

859 ).scalar_one() 

860 == 0 

861 ) 

862 

863 def now_30_min_in_future(): 

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

865 

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

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

868 send_message_notifications(empty_pb2.Empty()) 

869 

870 with session_scope() as session: 

871 assert ( 

872 session.execute( 

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

874 ).scalar_one() 

875 == 0 

876 ) 

877 

878 

879def test_send_onboarding_emails(db): 

880 # needs to get first onboarding email 

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

882 

883 send_onboarding_emails(empty_pb2.Empty()) 

884 process_jobs() 

885 

886 with session_scope() as session: 

887 assert ( 

888 session.execute( 

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

890 ).scalar_one() 

891 == 1 

892 ) 

893 

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

895 user2, token2 = generate_user( 

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

897 ) 

898 

899 send_onboarding_emails(empty_pb2.Empty()) 

900 process_jobs() 

901 

902 with session_scope() as session: 

903 assert ( 

904 session.execute( 

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

906 ).scalar_one() 

907 == 1 

908 ) 

909 

910 # needs to get second onboarding email 

911 user3, token3 = generate_user( 

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

913 ) 

914 

915 send_onboarding_emails(empty_pb2.Empty()) 

916 process_jobs() 

917 

918 with session_scope() as session: 

919 assert ( 

920 session.execute( 

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

922 ).scalar_one() 

923 == 2 

924 ) 

925 

926 

927def test_send_reference_reminders(db): 

928 # need to test: 

929 # case 1: bidirectional (no emails) 

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

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

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

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

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

935 

936 send_reference_reminders(empty_pb2.Empty()) 

937 

938 # case 1: bidirectional (no emails) 

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

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

941 

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

943 # host 

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

945 # surfer 

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

947 

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

949 # host 

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

951 # surfer 

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

953 

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

955 # surfer 

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

957 # host 

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

959 

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

961 # surfer 

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

963 # host 

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

965 

966 make_user_block(user9, user10) 

967 

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

969 # host 

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

971 # surfer 

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

973 

974 with session_scope() as session: 

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

976 

977 # case 1: bidirectional (no emails) 

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

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

980 

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

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

983 

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

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

986 

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

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

989 

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

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

992 

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

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

995 

996 expected_emails = [ 

997 ( 

998 "user11@couchers.org.invalid", 

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

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

1001 ), 

1002 ( 

1003 "user4@couchers.org.invalid", 

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

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

1006 ), 

1007 ( 

1008 "user5@couchers.org.invalid", 

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

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

1011 ), 

1012 ( 

1013 "user7@couchers.org.invalid", 

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

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

1016 ), 

1017 ( 

1018 "user8@couchers.org.invalid", 

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

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

1021 ), 

1022 ] 

1023 

1024 send_reference_reminders(empty_pb2.Empty()) 

1025 

1026 while process_job(): 

1027 pass 

1028 

1029 with session_scope() as session: 

1030 emails = [ 

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

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

1033 ] 

1034 

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

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

1037 

1038 print(actual_addresses_and_subjects) 

1039 print(expected_addresses_and_subjects) 

1040 

1041 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1042 

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

1044 for find in search_strings: 

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

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

1047 

1048 

1049def test_send_host_request_reminders(db, moderator): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1064 

1065 with session_scope() as session: 

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

1067 hr1 = create_host_request_by_date( 

1068 session=session, 

1069 surfer_user_id=user1.id, 

1070 host_user_id=user2.id, 

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

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

1073 status=HostRequestStatus.pending, 

1074 host_sent_request_reminders=0, 

1075 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1076 ) 

1077 

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

1079 hr2 = create_host_request_by_date( 

1080 session=session, 

1081 surfer_user_id=user3.id, 

1082 host_user_id=user4.id, 

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

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

1085 status=HostRequestStatus.pending, 

1086 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS, 

1087 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1088 ) 

1089 

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

1091 hr3 = create_host_request_by_date( 

1092 session=session, 

1093 surfer_user_id=user5.id, 

1094 host_user_id=user6.id, 

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

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

1097 status=HostRequestStatus.pending, 

1098 host_sent_request_reminders=0, 

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

1100 ) 

1101 

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

1103 hr4 = create_host_request_by_date( 

1104 session=session, 

1105 surfer_user_id=user7.id, 

1106 host_user_id=user8.id, 

1107 from_date=today(), 

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

1109 status=HostRequestStatus.pending, 

1110 host_sent_request_reminders=0, 

1111 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1112 ) 

1113 

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

1115 hr5 = create_host_request_by_date( 

1116 session=session, 

1117 surfer_user_id=user9.id, 

1118 host_user_id=user10.id, 

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

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

1121 status=HostRequestStatus.pending, 

1122 host_sent_request_reminders=0, 

1123 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1124 ) 

1125 

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

1127 hr6 = create_host_request_by_date( 

1128 session=session, 

1129 surfer_user_id=user11.id, 

1130 host_user_id=user12.id, 

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

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

1133 status=HostRequestStatus.accepted, 

1134 host_sent_request_reminders=0, 

1135 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1136 ) 

1137 

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

1139 hr7 = create_host_request_by_date( 

1140 session=session, 

1141 surfer_user_id=user13.id, 

1142 host_user_id=user14.id, 

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

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

1145 status=HostRequestStatus.pending, 

1146 host_sent_request_reminders=0, 

1147 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1148 ) 

1149 

1150 msg = Message( 

1151 conversation_id=hr7, 

1152 author_id=user14.id, 

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

1154 message_type=MessageType.text, 

1155 ) 

1156 msg.time = now() 

1157 session.add(msg) 

1158 

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

1160 moderator.approve_host_request(hr1) 

1161 moderator.approve_host_request(hr2) 

1162 moderator.approve_host_request(hr3) 

1163 moderator.approve_host_request(hr4) 

1164 moderator.approve_host_request(hr5) 

1165 moderator.approve_host_request(hr6) 

1166 moderator.approve_host_request(hr7) 

1167 

1168 send_host_request_reminders(empty_pb2.Empty()) 

1169 

1170 while process_job(): 

1171 pass 

1172 

1173 with session_scope() as session: 

1174 emails = [ 

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

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

1177 ] 

1178 

1179 expected_emails = [ 

1180 ( 

1181 "user2@couchers.org.invalid", 

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

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

1184 ) 

1185 ] 

1186 

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

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

1189 

1190 print(actual_addresses_and_subjects) 

1191 print(expected_addresses_and_subjects) 

1192 

1193 assert actual_addresses_and_subjects == expected_addresses_and_subjects 

1194 

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

1196 for find in search_strings: 

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

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

1199 

1200 

1201def test_add_users_to_email_list(db): 

1202 new_config = config.copy() 

1203 new_config["LISTMONK_ENABLED"] = True 

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

1205 new_config["LISTMONK_API_USERNAME"] = "test_user" 

1206 new_config["LISTMONK_API_KEY"] = "dummy_api_key" 

1207 new_config["LISTMONK_LIST_ID"] = 6 

1208 

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

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

1211 add_users_to_email_list(empty_pb2.Empty()) 

1212 mock.assert_not_called() 

1213 

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

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

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

1217 generate_user( 

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

1219 ) 

1220 

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

1222 ret = mock.return_value 

1223 ret.status_code = 200 

1224 add_users_to_email_list(empty_pb2.Empty()) 

1225 mock.assert_has_calls( 

1226 [ 

1227 call( 

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

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

1230 json={ 

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

1232 "name": "Tester1", 

1233 "lists": [6], 

1234 "preconfirm_subscriptions": True, 

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

1236 "status": "enabled", 

1237 }, 

1238 timeout=10, 

1239 ), 

1240 call( 

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

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

1243 json={ 

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

1245 "name": "Tester3 von test", 

1246 "lists": [6], 

1247 "preconfirm_subscriptions": True, 

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

1249 "status": "enabled", 

1250 }, 

1251 timeout=10, 

1252 ), 

1253 ], 

1254 any_order=True, 

1255 ) 

1256 

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

1258 add_users_to_email_list(empty_pb2.Empty()) 

1259 mock.assert_not_called() 

1260 

1261 

1262def test_update_recommendation_scores(db): 

1263 update_recommendation_scores(empty_pb2.Empty()) 

1264 

1265 

1266def test_update_badges(db, push_collector: PushCollector): 

1267 user1, _ = generate_user(last_donated=None) 

1268 user2, _ = generate_user(last_donated=None) 

1269 user3, _ = generate_user(last_donated=None) 

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

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

1272 user6, _ = generate_user(last_donated=None) 

1273 

1274 with session_scope() as session: 

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

1276 

1277 update_badges(empty_pb2.Empty()) 

1278 process_jobs() 

1279 

1280 with session_scope() as session: 

1281 badge_tuples = session.execute( 

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

1283 ).all() 

1284 

1285 expected = [ 

1286 (user1.id, "founder"), 

1287 (user1.id, "board_member"), 

1288 (user2.id, "founder"), 

1289 (user2.id, "board_member"), 

1290 (user4.id, "phone_verified"), 

1291 (user5.id, "phone_verified"), 

1292 ] 

1293 

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

1295 

1296 print(push_collector.by_user) 

1297 

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

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

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

1301 

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

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

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

1305 

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

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

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

1309 

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

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

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

1313 

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

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

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

1317 

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

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

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

1321 

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

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

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

1325 

1326 

1327def test_send_request_notifications_blocked_users_no_notification(db, moderator): 

1328 """ 

1329 Regression test: send_request_notifications should not send notifications 

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

1331 """ 

1332 user1, token1 = generate_user() 

1333 user2, token2 = generate_user() 

1334 

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

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

1337 

1338 # Create a host request 

1339 with requests_session(token1) as requests: 

1340 host_request_id = requests.CreateHostRequest( 

1341 requests_pb2.CreateHostRequestReq( 

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

1343 ) 

1344 ).host_request_id 

1345 moderator.approve_host_request(host_request_id) 

1346 

1347 with session_scope() as session: 

1348 # delete send_email BackgroundJob created by CreateHostRequest 

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

1350 

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

1352 make_user_block(user2, user1) 

1353 

1354 with session_scope() as session: 

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

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

1357 send_request_notifications(empty_pb2.Empty()) 

1358 process_jobs() 

1359 

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

1361 assert ( 

1362 session.execute( 

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

1364 ).scalar_one() 

1365 == 0 

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

1367 

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

1369 # First unblock 

1370 with session_scope() as session: 

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

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

1373 

1374 # Host responds 

1375 with requests_session(token2) as requests: 

1376 requests.RespondHostRequest( 

1377 requests_pb2.RespondHostRequestReq( 

1378 host_request_id=host_request_id, 

1379 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1380 text="Accepting your request", 

1381 ) 

1382 ) 

1383 

1384 with session_scope() as session: 

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

1386 

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

1388 make_user_block(user1, user2) 

1389 

1390 with session_scope() as session: 

1391 # check send_request_notifications does NOT create background job 

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

1393 send_request_notifications(empty_pb2.Empty()) 

1394 process_jobs() 

1395 

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

1397 assert ( 

1398 session.execute( 

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

1400 ).scalar_one() 

1401 == 0 

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

1403 

1404 

1405def test_send_host_request_reminders_blocked_users_no_notification(db, moderator): 

1406 """ 

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

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

1409 """ 

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

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

1412 

1413 with session_scope() as session: 

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

1415 hr = create_host_request_by_date( 

1416 session=session, 

1417 surfer_user_id=user1.id, 

1418 host_user_id=user2.id, 

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

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

1421 status=HostRequestStatus.pending, 

1422 host_sent_request_reminders=0, 

1423 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL, 

1424 ) 

1425 

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

1427 moderator.approve_host_request(hr) 

1428 

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

1430 send_host_request_reminders(empty_pb2.Empty()) 

1431 

1432 while process_job(): 

1433 pass 

1434 

1435 with session_scope() as session: 

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

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

1438 

1439 # Clean up emails and background jobs 

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

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

1442 

1443 # Reset the reminder counter so we can test again 

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

1445 host_request.recipient_sent_request_reminders = 0 

1446 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL 

1447 

1448 # Now have the host block the surfer 

1449 make_user_block(user2, user1) 

1450 

1451 send_host_request_reminders(empty_pb2.Empty()) 

1452 

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

1454 pass 

1455 

1456 with session_scope() as session: 

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

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

1459 

1460 

1461def test_send_message_notifications_blocked_users_no_notification(db, moderator): 

1462 """ 

1463 Regression test: send_message_notifications should not send notifications 

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

1465 """ 

1466 user1, token1 = generate_user() 

1467 user2, token2 = generate_user() 

1468 

1469 make_friends(user1, user2) 

1470 

1471 # Create a group chat and send messages 

1472 with conversations_session(token1) as c: 

1473 group_chat_id = c.CreateGroupChat( 

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

1475 ).group_chat_id 

1476 

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

1478 moderator.approve_group_chat(group_chat_id) 

1479 

1480 with conversations_session(token1) as c: 

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

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

1483 

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

1485 with session_scope() as session: 

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

1487 

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

1489 send_message_notifications(empty_pb2.Empty()) 

1490 process_jobs() 

1491 

1492 with session_scope() as session: 

1493 email_job_count = session.execute( 

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

1495 ).scalar_one() 

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

1497 

1498 # Clean up 

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

1500 

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

1502 with session_scope() as session: 

1503 from couchers.models import User 

1504 

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

1506 u2.last_notified_message_id = 0 

1507 

1508 # Now have user2 block user1 

1509 make_user_block(user2, user1) 

1510 

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

1512 # since user2 has blocked user1 

1513 with session_scope() as session: 

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

1515 

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

1517 send_message_notifications(empty_pb2.Empty()) 

1518 process_jobs() 

1519 

1520 with session_scope() as session: 

1521 email_job_count = session.execute( 

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

1523 ).scalar_one() 

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

1525 

1526 

1527def test_update_badges_volunteers(db, push_collector: PushCollector): 

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

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

1530 user1, _ = generate_user(last_donated=None) 

1531 user2, _ = generate_user(last_donated=None) 

1532 user3, _ = generate_user(last_donated=None) 

1533 user4, _ = generate_user(last_donated=None) 

1534 user5, _ = generate_user(last_donated=None) 

1535 user6, _ = generate_user(last_donated=None) 

1536 

1537 with session_scope() as session: 

1538 # user3: active volunteer (stopped_volunteering is null) 

1539 session.add( 

1540 make_volunteer( 

1541 user_id=user3.id, 

1542 role="Developer", 

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

1544 stopped_volunteering=None, 

1545 ) 

1546 ) 

1547 

1548 # user4: past volunteer (stopped_volunteering is set) 

1549 session.add( 

1550 make_volunteer( 

1551 user_id=user4.id, 

1552 role="Designer", 

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

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

1555 ) 

1556 ) 

1557 

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

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

1560 

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

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

1563 

1564 update_badges(empty_pb2.Empty()) 

1565 process_jobs() 

1566 

1567 with session_scope() as session: 

1568 # Check user3 has volunteer badge 

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

1570 assert "volunteer" in user3_badges 

1571 assert "past_volunteer" not in user3_badges 

1572 

1573 # Check user4 has past_volunteer badge 

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

1575 assert "past_volunteer" in user4_badges 

1576 assert "volunteer" not in user4_badges 

1577 

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

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

1580 assert "volunteer" not in user5_badges 

1581 

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

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

1584 assert "past_volunteer" not in user6_badges 

1585 

1586 # Check notifications for volunteer badge users 

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

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

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

1590 

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

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

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

1594 

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

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

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

1598 

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

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

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

1602 

1603 

1604def test_update_badges_volunteer_status_change(db, push_collector: PushCollector): 

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

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

1607 user1, _ = generate_user(last_donated=None) 

1608 user2, _ = generate_user(last_donated=None) 

1609 user3, _ = generate_user(last_donated=None) 

1610 

1611 with session_scope() as session: 

1612 # user3: start as active volunteer 

1613 session.add( 

1614 make_volunteer( 

1615 user_id=user3.id, 

1616 role="Developer", 

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

1618 stopped_volunteering=None, 

1619 show_on_team_page=True, 

1620 ) 

1621 ) 

1622 

1623 update_badges(empty_pb2.Empty()) 

1624 process_jobs() 

1625 

1626 with session_scope() as session: 

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

1628 assert "volunteer" in user3_badges 

1629 assert "past_volunteer" not in user3_badges 

1630 

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

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

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

1634 

1635 # Now change the volunteer to past volunteer 

1636 with session_scope() as session: 

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

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

1639 

1640 update_badges(empty_pb2.Empty()) 

1641 process_jobs() 

1642 

1643 with session_scope() as session: 

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

1645 assert "volunteer" not in user3_badges 

1646 assert "past_volunteer" in user3_badges 

1647 

1648 # Check both badges were updated 

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

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

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

1652 

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

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

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

1656 

1657 

1658def test_send_message_notifications_empty_unseen_simple(monkeypatch): 

1659 class DummyUser: 

1660 id = 1 

1661 is_visible = True 

1662 last_notified_message_id = 0 

1663 

1664 class FirstResult: 

1665 def scalars(self): 

1666 return self 

1667 

1668 def unique(self): 

1669 return [DummyUser()] 

1670 

1671 class SecondResult: 

1672 def all(self): 

1673 return [] 

1674 

1675 class DummySession: 

1676 def __init__(self): 

1677 self.calls = 0 

1678 

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

1680 self.calls += 1 

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

1682 

1683 def commit(self): 

1684 pass 

1685 

1686 def flush(self): 

1687 pass 

1688 

1689 def fake_session_scope(): 

1690 class Ctx: 

1691 def __enter__(self): 

1692 return DummySession() 

1693 

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

1695 pass 

1696 

1697 return Ctx() 

1698 

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

1700 

1701 handlers.send_message_notifications(Empty())