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
« 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
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
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
63def now_5_min_in_future() -> datetime:
64 return now() + timedelta(minutes=5)
67@pytest.fixture(autouse=True)
68def _(testconfig):
69 pass
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
78def test_email_job(db):
79 with session_scope() as session:
80 queue_email(session, "sender_name", "sender_email", "recipient", "subject", "plain", "html")
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 )
95 with patch("couchers.jobs.handlers.print_dev_email", mock_print_dev_email):
96 process_job()
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 )
117def test_purge_login_tokens(db):
118 user, api_token = generate_user()
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
125 queue_job(session, job=purge_login_tokens, payload=empty_pb2.Empty())
126 process_job()
128 with session_scope() as session:
129 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 0
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 )
150def test_purge_password_reset_tokens(db):
151 user, api_token = generate_user()
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
158 queue_job(session, job=purge_password_reset_tokens, payload=empty_pb2.Empty())
159 process_job()
161 with session_scope() as session:
162 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 0
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 )
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()
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
204 queue_job(session, job=purge_account_deletion_tokens, payload=empty_pb2.Empty())
205 process_job()
207 with session_scope() as session:
208 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 1
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 )
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()
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 )
253def test_refresh_materialized_views(db):
254 with session_scope() as session:
255 queue_job(session, job=refresh_materialized_views, payload=empty_pb2.Empty())
257 process_job()
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 )
278def test_service_jobs(db):
279 with session_scope() as session:
280 queue_email(session, "sender_name", "sender_email", "recipient", "subject", "plain", "html")
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
287 # the mock `sleep` function that instead raises the aforementioned exception
288 def raising_sleep(seconds):
289 raise HitSleep()
291 with pytest.raises(HitSleep):
292 with patch("couchers.jobs.worker.sleep", raising_sleep):
293 service_jobs()
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 )
314def test_scheduler(db, monkeypatch):
315 def purge_login_tokens(payload: empty_pb2.Empty):
316 return
318 def send_message_notifications(payload: empty_pb2.Empty):
319 return
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 }
326 current_time = 0
327 end_time = 70
329 class EndOfTime(Exception):
330 pass
332 def mock_monotonic():
333 return current_time
335 def mock_sleep(seconds):
336 nonlocal current_time
337 current_time += seconds
338 if current_time > end_time:
339 raise EndOfTime()
341 realized_schedule = []
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)
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)
352 with pytest.raises(EndOfTime):
353 run_scheduler()
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]
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 ]
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 )
395def test_job_retry(db):
396 called_count = 0
398 def mock_job(payload: empty_pb2.Empty) -> None:
399 nonlocal called_count
400 called_count += 1
401 raise Exception()
403 with session_scope() as session:
404 queue_job(session, job=mock_job, payload=empty_pb2.Empty())
406 MOCK_JOBS: dict[str, Job[Any]] = {
407 "mock_job": Job(mock_job),
408 }
409 create_prometheus_server(port=8000)
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
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 )
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()
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 )
461 _check_job_counter("mock_job", "error", "4", "Exception")
462 _check_job_counter("mock_job", "failed", "5", "Exception")
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
469 assert not process_job()
471 with session_scope() as session:
472 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
475def test_send_message_notifications_basic(db, moderator):
476 user1, token1 = generate_user()
477 user2, token2 = generate_user()
478 user3, token3 = generate_user()
480 make_friends(user1, user2)
481 make_friends(user1, user3)
482 make_friends(user2, user3)
484 send_message_notifications(empty_pb2.Empty())
485 process_jobs()
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 )
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)
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"))
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)
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"))
518 send_message_notifications(empty_pb2.Empty())
519 process_jobs()
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 )
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()
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))
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()
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 )
559def test_send_message_notifications_muted(db, moderator):
560 user1, token1 = generate_user()
561 user2, token2 = generate_user()
562 user3, token3 = generate_user()
564 make_friends(user1, user2)
565 make_friends(user1, user3)
566 make_friends(user2, user3)
568 send_message_notifications(empty_pb2.Empty())
569 process_jobs()
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 )
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)
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))
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"))
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"))
604 send_message_notifications(empty_pb2.Empty())
605 process_jobs()
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 )
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()
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))
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()
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 )
645def test_send_request_notifications_host_request(db, moderator):
646 user1, token1 = generate_user()
647 user2, token2 = generate_user()
649 today_plus_2 = (today() + timedelta(days=2)).isoformat()
650 today_plus_3 = (today() + timedelta(days=3)).isoformat()
652 send_request_notifications(empty_pb2.Empty())
653 process_jobs()
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
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)
667 with session_scope() as session:
668 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
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 )
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 )
692 with session_scope() as session:
693 # delete send_email BackgroundJob created by RespondHostRequest
694 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
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 )
707 # delete all BackgroundJobs
708 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
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 )
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()
731 today_plus_2 = (today() + timedelta(days=2)).isoformat()
732 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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)
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 )
748 with session_scope() as session:
749 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
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 )
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()
776 today_plus_2 = (today() + timedelta(days=2)).isoformat()
777 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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)
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 )
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)
802 with session_scope() as session:
803 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
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 )
818def test_send_message_notifications_seen(db, moderator):
819 user1, token1 = generate_user()
820 user2, token2 = generate_user()
822 make_friends(user1, user2)
824 send_message_notifications(empty_pb2.Empty())
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 )
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"))
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 )
852 send_message_notifications(empty_pb2.Empty())
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 )
863 def now_30_min_in_future():
864 return now() + timedelta(minutes=30)
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())
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 )
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)
883 send_onboarding_emails(empty_pb2.Empty())
884 process_jobs()
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 )
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 )
899 send_onboarding_emails(empty_pb2.Empty())
900 process_jobs()
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 )
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 )
915 send_onboarding_emails(empty_pb2.Empty())
916 process_jobs()
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 )
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)
936 send_reference_reminders(empty_pb2.Empty())
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")
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")
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")
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")
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")
966 make_user_block(user9, user10)
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")
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
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)
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)
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)
987 # case 4: neither left ref (host & surfer need an email)
988 hr4 = create_host_request(session, user7.id, user8.id, timedelta(days=4))
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))
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="")
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 ]
1024 send_reference_reminders(empty_pb2.Empty())
1026 while process_job():
1027 pass
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 ]
1035 actual_addresses_and_subjects = [email[:2] for email in emails]
1036 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1038 print(actual_addresses_and_subjects)
1039 print(expected_addresses_and_subjects)
1041 assert actual_addresses_and_subjects == expected_addresses_and_subjects
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"
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")
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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)
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)
1168 send_host_request_reminders(empty_pb2.Empty())
1170 while process_job():
1171 pass
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 ]
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 ]
1187 actual_addresses_and_subjects = [email[:2] for email in emails]
1188 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1190 print(actual_addresses_and_subjects)
1191 print(expected_addresses_and_subjects)
1193 assert actual_addresses_and_subjects == expected_addresses_and_subjects
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"
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
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()
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 )
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 )
1257 with patch("couchers.jobs.handlers.requests.post") as mock:
1258 add_users_to_email_list(empty_pb2.Empty())
1259 mock.assert_not_called()
1262def test_update_recommendation_scores(db):
1263 update_recommendation_scores(empty_pb2.Empty())
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)
1274 with session_scope() as session:
1275 session.add(UserBadge(user_id=user5.id, badge_id="board_member"))
1277 update_badges(empty_pb2.Empty())
1278 process_jobs()
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()
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 ]
1294 assert badge_tuples == expected # type: ignore[comparison-overlap]
1296 print(push_collector.by_user)
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."
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."
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."
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."
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."
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."
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."
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()
1335 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1336 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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)
1347 with session_scope() as session:
1348 # delete send_email BackgroundJob created by CreateHostRequest
1349 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1351 # Now user2 (host) blocks user1 (surfer)
1352 make_user_block(user2, user1)
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()
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"
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))
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 )
1384 with session_scope() as session:
1385 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1387 # Now user1 (surfer) blocks user2 (host)
1388 make_user_block(user1, user2)
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()
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"
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")
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 )
1426 # Approve the host request so it's visible for notifications
1427 moderator.approve_host_request(hr)
1429 # Verify that without blocking, a reminder would be sent
1430 send_host_request_reminders(empty_pb2.Empty())
1432 while process_job():
1433 pass
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"
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))
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
1448 # Now have the host block the surfer
1449 make_user_block(user2, user1)
1451 send_host_request_reminders(empty_pb2.Empty())
1453 while process_job(): 1453 ↛ 1454line 1453 didn't jump to line 1454 because the condition on line 1453 was never true
1454 pass
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"
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()
1469 make_friends(user1, user2)
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
1477 # Approve the group chat so it's visible for notifications
1478 moderator.approve_group_chat(group_chat_id)
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"))
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))
1488 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1489 send_message_notifications(empty_pb2.Empty())
1490 process_jobs()
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"
1498 # Clean up
1499 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
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
1505 u2 = session.execute(select(User).where(User.id == user2.id)).scalar_one()
1506 u2.last_notified_message_id = 0
1508 # Now have user2 block user1
1509 make_user_block(user2, user1)
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))
1516 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1517 send_message_notifications(empty_pb2.Empty())
1518 process_jobs()
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"
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)
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 )
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 )
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"))
1561 # user6: has old past_volunteer badge that should be removed
1562 session.add(UserBadge(user_id=user6.id, badge_id="past_volunteer"))
1564 update_badges(empty_pb2.Empty())
1565 process_jobs()
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
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
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
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
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."
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."
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."
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."
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)
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 )
1623 update_badges(empty_pb2.Empty())
1624 process_jobs()
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
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."
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)
1640 update_badges(empty_pb2.Empty())
1641 process_jobs()
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
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."
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."
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
1664 class FirstResult:
1665 def scalars(self):
1666 return self
1668 def unique(self):
1669 return [DummyUser()]
1671 class SecondResult:
1672 def all(self):
1673 return []
1675 class DummySession:
1676 def __init__(self):
1677 self.calls = 0
1679 def execute(self, *a, **k):
1680 self.calls += 1
1681 return FirstResult() if self.calls == 1 else SecondResult()
1683 def commit(self):
1684 pass
1686 def flush(self):
1687 pass
1689 def fake_session_scope():
1690 class Ctx:
1691 def __enter__(self):
1692 return DummySession()
1694 def __exit__(self, exc_type, exc, tb):
1695 pass
1697 return Ctx()
1699 monkeypatch.setattr(handlers, "session_scope", fake_session_scope)
1701 handlers.send_message_notifications(Empty())