Coverage for app / backend / src / tests / test_bg_jobs.py: 99%
687 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
1from datetime import date, datetime, timedelta
2from typing import Any
3from unittest.mock import call, patch
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 import queue_email
18from couchers.email.dev import print_dev_email
19from couchers.jobs import handlers
20from couchers.jobs.definitions import Job
21from couchers.jobs.enqueue import queue_job
22from couchers.jobs.handlers import (
23 add_users_to_email_list,
24 enforce_community_membership,
25 purge_account_deletion_tokens,
26 purge_login_tokens,
27 purge_password_reset_tokens,
28 send_host_request_reminders,
29 send_message_notifications,
30 send_onboarding_emails,
31 send_reference_reminders,
32 send_request_notifications,
33 update_badges,
34 update_recommendation_scores,
35)
36from couchers.jobs.worker import _run_job_and_schedule, process_job, run_scheduler, service_jobs
37from couchers.materialized_views import refresh_materialized_views
38from couchers.metrics import create_prometheus_server
39from couchers.models import (
40 AccountDeletionToken,
41 BackgroundJob,
42 BackgroundJobState,
43 Email,
44 HostRequest,
45 HostRequestStatus,
46 LoginToken,
47 Message,
48 MessageType,
49 PasswordResetToken,
50 UserBadge,
51 UserBlock,
52 Volunteer,
53)
54from couchers.proto import conversations_pb2, requests_pb2
55from couchers.utils import now, today
56from tests.fixtures.db import generate_user, make_friends, make_user_block, make_volunteer
57from tests.fixtures.misc import PushCollector, process_jobs
58from tests.fixtures.sessions import conversations_session, requests_session
59from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date
60from tests.test_requests import valid_request_text
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 # first test that sending host request creates email
660 with requests_session(token1) as requests:
661 host_request_id = requests.CreateHostRequest(
662 requests_pb2.CreateHostRequestReq(
663 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
664 )
665 ).host_request_id
666 moderator.approve_host_request(host_request_id)
668 with session_scope() as session:
669 # delete send_email BackgroundJob created by CreateHostRequest
670 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
672 # check send_request_notifications successfully creates background job
673 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
674 send_request_notifications(empty_pb2.Empty())
675 process_jobs()
676 assert (
677 session.execute(
678 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
679 ).scalar_one()
680 == 1
681 )
683 # delete all BackgroundJobs
684 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
686 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
687 send_request_notifications(empty_pb2.Empty())
688 process_jobs()
689 # should find no messages since host has already been notified
690 assert (
691 session.execute(
692 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
693 ).scalar_one()
694 == 0
695 )
697 # then test that responding to host request creates email
698 with requests_session(token2) as requests:
699 requests.RespondHostRequest(
700 requests_pb2.RespondHostRequestReq(
701 host_request_id=host_request_id,
702 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
703 text="Test request",
704 )
705 )
707 with session_scope() as session:
708 # delete send_email BackgroundJob created by RespondHostRequest
709 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
711 # check send_request_notifications successfully creates background job
712 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
713 send_request_notifications(empty_pb2.Empty())
714 process_jobs()
715 assert (
716 session.execute(
717 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
718 ).scalar_one()
719 == 1
720 )
722 # delete all BackgroundJobs
723 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
725 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
726 send_request_notifications(empty_pb2.Empty())
727 process_jobs()
728 # should find no messages since guest has already been notified
729 assert (
730 session.execute(
731 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
732 ).scalar_one()
733 == 0
734 )
737def test_send_message_notifications_seen(db, moderator):
738 user1, token1 = generate_user()
739 user2, token2 = generate_user()
741 make_friends(user1, user2)
743 send_message_notifications(empty_pb2.Empty())
745 # should find no jobs, since there's no messages
746 with session_scope() as session:
747 assert (
748 session.execute(
749 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
750 ).scalar_one()
751 == 0
752 )
754 with conversations_session(token1) as c:
755 group_chat_id = c.CreateGroupChat(
756 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
757 ).group_chat_id
758 moderator.approve_group_chat(group_chat_id)
759 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
760 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
761 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3"))
762 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4"))
764 # user 2 now marks those messages as seen
765 with conversations_session(token2) as c:
766 m_id = c.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id)).latest_message.message_id
767 c.MarkLastSeenGroupChat(
768 conversations_pb2.MarkLastSeenGroupChatReq(group_chat_id=group_chat_id, last_seen_message_id=m_id)
769 )
771 send_message_notifications(empty_pb2.Empty())
773 # no emails sent out
774 with session_scope() as session:
775 assert (
776 session.execute(
777 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
778 ).scalar_one()
779 == 0
780 )
782 def now_30_min_in_future():
783 return now() + timedelta(minutes=30)
785 # still shouldn't generate emails as user2 has seen all messages
786 with patch("couchers.jobs.handlers.now", now_30_min_in_future):
787 send_message_notifications(empty_pb2.Empty())
789 with session_scope() as session:
790 assert (
791 session.execute(
792 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
793 ).scalar_one()
794 == 0
795 )
798def test_send_onboarding_emails(db):
799 # needs to get first onboarding email
800 user1, token1 = generate_user(onboarding_emails_sent=0, last_onboarding_email_sent=None, complete_profile=False)
802 send_onboarding_emails(empty_pb2.Empty())
803 process_jobs()
805 with session_scope() as session:
806 assert (
807 session.execute(
808 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
809 ).scalar_one()
810 == 1
811 )
813 # needs to get second onboarding email, but not yet
814 user2, token2 = generate_user(
815 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=6), complete_profile=False
816 )
818 send_onboarding_emails(empty_pb2.Empty())
819 process_jobs()
821 with session_scope() as session:
822 assert (
823 session.execute(
824 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
825 ).scalar_one()
826 == 1
827 )
829 # needs to get second onboarding email
830 user3, token3 = generate_user(
831 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=8), complete_profile=False
832 )
834 send_onboarding_emails(empty_pb2.Empty())
835 process_jobs()
837 with session_scope() as session:
838 assert (
839 session.execute(
840 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
841 ).scalar_one()
842 == 2
843 )
846def test_send_reference_reminders(db):
847 # need to test:
848 # case 1: bidirectional (no emails)
849 # case 2: host left ref (surfer needs an email)
850 # case 3: surfer left ref (host needs an email)
851 # case 4: neither left ref (host & surfer need an email)
852 # case 5: neither left ref, but host blocked surfer, so neither should get an email
853 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
855 send_reference_reminders(empty_pb2.Empty())
857 # case 1: bidirectional (no emails)
858 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
859 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
861 # case 2: host left ref (surfer needs an email)
862 # host
863 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
864 # surfer
865 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
867 # case 3: surfer left ref (host needs an email)
868 # host
869 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
870 # surfer
871 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
873 # case 4: neither left ref (host & surfer need an email)
874 # surfer
875 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
876 # host
877 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
879 # case 5: neither left ref, but host blocked surfer, so neither should get an email
880 # surfer
881 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
882 # host
883 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
885 make_user_block(user9, user10)
887 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
888 # host
889 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
890 # surfer
891 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
893 with session_scope() as session:
894 # note that create_host_reference creates a host request whose age is one day older than the timedelta here
896 # case 1: bidirectional (no emails)
897 ref1, hr1 = create_host_reference(session, user2.id, user1.id, timedelta(days=7), surfing=True)
898 create_host_reference(session, user1.id, user2.id, timedelta(days=7), host_request_id=hr1)
900 # case 2: host left ref (surfer needs an email)
901 ref2, hr2 = create_host_reference(session, user3.id, user4.id, timedelta(days=11), surfing=False)
903 # case 3: surfer left ref (host needs an email)
904 ref3, hr3 = create_host_reference(session, user6.id, user5.id, timedelta(days=9), surfing=True)
906 # case 4: neither left ref (host & surfer need an email)
907 hr4 = create_host_request(session, user7.id, user8.id, timedelta(days=4))
909 # case 5: neither left ref, but host blocked surfer, so neither should get an email
910 hr5 = create_host_request(session, user9.id, user10.id, timedelta(days=7))
912 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
913 hr6 = create_host_request(session, user12.id, user11.id, timedelta(days=6), surfer_reason_didnt_meetup="")
915 expected_emails = [
916 (
917 "user11@couchers.org.invalid",
918 "[TEST] You have 14 days to write a reference for User 12!",
919 ("from when you hosted them", "/leave-reference/hosted/"),
920 ),
921 (
922 "user4@couchers.org.invalid",
923 "[TEST] You have 3 days to write a reference for User 3!",
924 ("from when you surfed with them", "/leave-reference/surfed/"),
925 ),
926 (
927 "user5@couchers.org.invalid",
928 "[TEST] You have 7 days to write a reference for User 6!",
929 ("from when you hosted them", "/leave-reference/hosted/"),
930 ),
931 (
932 "user7@couchers.org.invalid",
933 "[TEST] You have 14 days to write a reference for User 8!",
934 ("from when you surfed with them", "/leave-reference/surfed/"),
935 ),
936 (
937 "user8@couchers.org.invalid",
938 "[TEST] You have 14 days to write a reference for User 7!",
939 ("from when you hosted them", "/leave-reference/hosted/"),
940 ),
941 ]
943 send_reference_reminders(empty_pb2.Empty())
945 while process_job():
946 pass
948 with session_scope() as session:
949 emails = [
950 (email.recipient, email.subject, email.plain, email.html)
951 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
952 ]
954 actual_addresses_and_subjects = [email[:2] for email in emails]
955 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
957 print(actual_addresses_and_subjects)
958 print(expected_addresses_and_subjects)
960 assert actual_addresses_and_subjects == expected_addresses_and_subjects
962 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
963 for find in search_strings:
964 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
965 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
968def test_send_host_request_reminders(db, moderator):
969 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
970 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
971 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
972 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
973 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
974 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
975 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
976 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
977 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
978 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
979 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
980 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
981 user13, token13 = generate_user(email="user13@couchers.org.invalid", name="User 13")
982 user14, token14 = generate_user(email="user14@couchers.org.invalid", name="User 14")
984 with session_scope() as session:
985 # case 1: pending, future, interval elapsed => notify
986 hr1 = create_host_request_by_date(
987 session=session,
988 surfer_user_id=user1.id,
989 host_user_id=user2.id,
990 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
991 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
992 status=HostRequestStatus.pending,
993 host_sent_request_reminders=0,
994 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
995 )
997 # case 2: max reminders reached => do not notify
998 hr2 = create_host_request_by_date(
999 session=session,
1000 surfer_user_id=user3.id,
1001 host_user_id=user4.id,
1002 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1003 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1004 status=HostRequestStatus.pending,
1005 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS,
1006 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1007 )
1009 # case 3: interval not yet elapsed => do not notify
1010 hr3 = create_host_request_by_date(
1011 session=session,
1012 surfer_user_id=user5.id,
1013 host_user_id=user6.id,
1014 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1015 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1016 status=HostRequestStatus.pending,
1017 host_sent_request_reminders=0,
1018 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL + timedelta(hours=1),
1019 )
1021 # case 4: start date is today => do not notify
1022 hr4 = create_host_request_by_date(
1023 session=session,
1024 surfer_user_id=user7.id,
1025 host_user_id=user8.id,
1026 from_date=today(),
1027 to_date=today() + timedelta(days=2),
1028 status=HostRequestStatus.pending,
1029 host_sent_request_reminders=0,
1030 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1031 )
1033 # case 5: from_date in the past => do not notify
1034 hr5 = create_host_request_by_date(
1035 session=session,
1036 surfer_user_id=user9.id,
1037 host_user_id=user10.id,
1038 from_date=today() - timedelta(days=1),
1039 to_date=today() + timedelta(days=1),
1040 status=HostRequestStatus.pending,
1041 host_sent_request_reminders=0,
1042 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1043 )
1045 # case 6: non-pending status => do not notify
1046 hr6 = create_host_request_by_date(
1047 session=session,
1048 surfer_user_id=user11.id,
1049 host_user_id=user12.id,
1050 from_date=today() + timedelta(days=3),
1051 to_date=today() + timedelta(days=4),
1052 status=HostRequestStatus.accepted,
1053 host_sent_request_reminders=0,
1054 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1055 )
1057 # case 7: host already sent a message => do not notify
1058 hr7 = create_host_request_by_date(
1059 session=session,
1060 surfer_user_id=user13.id,
1061 host_user_id=user14.id,
1062 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1063 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1064 status=HostRequestStatus.pending,
1065 host_sent_request_reminders=0,
1066 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1067 )
1069 msg = Message(
1070 conversation_id=hr7,
1071 author_id=user14.id,
1072 text="Looking forward to hosting you!",
1073 message_type=MessageType.text,
1074 )
1075 msg.time = now()
1076 session.add(msg)
1078 # Approve host requests so they're visible for notifications
1079 moderator.approve_host_request(hr1)
1080 moderator.approve_host_request(hr2)
1081 moderator.approve_host_request(hr3)
1082 moderator.approve_host_request(hr4)
1083 moderator.approve_host_request(hr5)
1084 moderator.approve_host_request(hr6)
1085 moderator.approve_host_request(hr7)
1087 send_host_request_reminders(empty_pb2.Empty())
1089 while process_job():
1090 pass
1092 with session_scope() as session:
1093 emails = [
1094 (email.recipient, email.subject, email.plain, email.html)
1095 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
1096 ]
1098 expected_emails = [
1099 (
1100 "user2@couchers.org.invalid",
1101 "[TEST] You have a pending host request from User 1!",
1102 ("Please respond to the request!", "User 1"),
1103 )
1104 ]
1106 actual_addresses_and_subjects = [email[:2] for email in emails]
1107 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1109 print(actual_addresses_and_subjects)
1110 print(expected_addresses_and_subjects)
1112 assert actual_addresses_and_subjects == expected_addresses_and_subjects
1114 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
1115 for find in search_strings:
1116 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
1117 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
1120def test_add_users_to_email_list(db):
1121 new_config = config.copy()
1122 new_config["LISTMONK_ENABLED"] = True
1123 new_config["LISTMONK_BASE_URL"] = "https://example.com"
1124 new_config["LISTMONK_API_USERNAME"] = "test_user"
1125 new_config["LISTMONK_API_KEY"] = "dummy_api_key"
1126 new_config["LISTMONK_LIST_ID"] = 6
1128 with patch("couchers.jobs.handlers.config", new_config):
1129 with patch("couchers.jobs.handlers.requests.post") as mock:
1130 add_users_to_email_list(empty_pb2.Empty())
1131 mock.assert_not_called()
1133 generate_user(in_sync_with_newsletter=False, email="testing1@couchers.invalid", name="Tester1", id=15)
1134 generate_user(in_sync_with_newsletter=True, email="testing2@couchers.invalid", name="Tester2")
1135 generate_user(in_sync_with_newsletter=False, email="testing3@couchers.invalid", name="Tester3 von test", id=17)
1136 generate_user(
1137 in_sync_with_newsletter=False, email="testing4@couchers.invalid", name="Tester4", opt_out_of_newsletter=True
1138 )
1140 with patch("couchers.jobs.handlers.requests.post") as mock:
1141 ret = mock.return_value
1142 ret.status_code = 200
1143 add_users_to_email_list(empty_pb2.Empty())
1144 mock.assert_has_calls(
1145 [
1146 call(
1147 "https://example.com/api/subscribers",
1148 auth=("test_user", "dummy_api_key"),
1149 json={
1150 "email": "testing1@couchers.invalid",
1151 "name": "Tester1",
1152 "lists": [6],
1153 "preconfirm_subscriptions": True,
1154 "attribs": {"couchers_user_id": 15},
1155 "status": "enabled",
1156 },
1157 timeout=10,
1158 ),
1159 call(
1160 "https://example.com/api/subscribers",
1161 auth=("test_user", "dummy_api_key"),
1162 json={
1163 "email": "testing3@couchers.invalid",
1164 "name": "Tester3 von test",
1165 "lists": [6],
1166 "preconfirm_subscriptions": True,
1167 "attribs": {"couchers_user_id": 17},
1168 "status": "enabled",
1169 },
1170 timeout=10,
1171 ),
1172 ],
1173 any_order=True,
1174 )
1176 with patch("couchers.jobs.handlers.requests.post") as mock:
1177 add_users_to_email_list(empty_pb2.Empty())
1178 mock.assert_not_called()
1181def test_update_recommendation_scores(db):
1182 update_recommendation_scores(empty_pb2.Empty())
1185def test_update_badges(db, push_collector: PushCollector):
1186 user1, _ = generate_user(last_donated=None)
1187 user2, _ = generate_user(last_donated=None)
1188 user3, _ = generate_user(last_donated=None)
1189 user4, _ = generate_user(phone="+15555555555", phone_verification_verified=func.now(), last_donated=None)
1190 user5, _ = generate_user(phone="+15555555556", phone_verification_verified=func.now(), last_donated=None)
1191 user6, _ = generate_user(last_donated=None)
1193 with session_scope() as session:
1194 session.add(UserBadge(user_id=user5.id, badge_id="board_member"))
1196 update_badges(empty_pb2.Empty())
1197 process_jobs()
1199 with session_scope() as session:
1200 badge_tuples = session.execute(
1201 select(UserBadge.user_id, UserBadge.badge_id).order_by(UserBadge.user_id.asc(), UserBadge.id.asc())
1202 ).all()
1204 expected = [
1205 (user1.id, "founder"),
1206 (user1.id, "board_member"),
1207 (user2.id, "founder"),
1208 (user2.id, "board_member"),
1209 (user4.id, "phone_verified"),
1210 (user5.id, "phone_verified"),
1211 ]
1213 assert badge_tuples == expected # type: ignore[comparison-overlap]
1215 print(push_collector.by_user)
1217 push = push_collector.pop_for_user(user1.id, last=False)
1218 assert push.content.title == "New profile badge: Founder"
1219 assert push.content.body == "The Founder badge was added to your profile."
1221 push = push_collector.pop_for_user(user1.id, last=True)
1222 assert push.content.title == "New profile badge: Board Member"
1223 assert push.content.body == "The Board Member badge was added to your profile."
1225 push = push_collector.pop_for_user(user2.id, last=False)
1226 assert push.content.title == "New profile badge: Founder"
1227 assert push.content.body == "The Founder badge was added to your profile."
1229 push = push_collector.pop_for_user(user2.id, last=True)
1230 assert push.content.title == "New profile badge: Board Member"
1231 assert push.content.body == "The Board Member badge was added to your profile."
1233 push = push_collector.pop_for_user(user4.id, last=True)
1234 assert push.content.title == "New profile badge: Verified Phone"
1235 assert push.content.body == "The Verified Phone badge was added to your profile."
1237 push = push_collector.pop_for_user(user5.id, last=False)
1238 assert push.content.title == "Profile badge removed"
1239 assert push.content.body == "The Board Member badge was removed from your profile."
1241 push = push_collector.pop_for_user(user5.id, last=True)
1242 assert push.content.title == "New profile badge: Verified Phone"
1243 assert push.content.body == "The Verified Phone badge was added to your profile."
1246def test_send_request_notifications_blocked_users_no_notification(db, moderator):
1247 """
1248 Regression test: send_request_notifications should not send notifications
1249 when the host and surfer are not visible to each other (e.g., one blocked the other).
1250 """
1251 user1, token1 = generate_user()
1252 user2, token2 = generate_user()
1254 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1255 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1257 # Create a host request
1258 with requests_session(token1) as requests:
1259 host_request_id = requests.CreateHostRequest(
1260 requests_pb2.CreateHostRequestReq(
1261 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
1262 )
1263 ).host_request_id
1264 moderator.approve_host_request(host_request_id)
1266 with session_scope() as session:
1267 # delete send_email BackgroundJob created by CreateHostRequest
1268 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1270 # Now user2 (host) blocks user1 (surfer)
1271 make_user_block(user2, user1)
1273 with session_scope() as session:
1274 # check send_request_notifications does NOT create background job because users are blocked
1275 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1276 send_request_notifications(empty_pb2.Empty())
1277 process_jobs()
1279 # Should be 0 emails because the host blocked the surfer
1280 assert (
1281 session.execute(
1282 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1283 ).scalar_one()
1284 == 0
1285 ), "No notification email should be sent when host has blocked surfer"
1287 # Also test the reverse direction: surfer sends message to host, host should not get notification
1288 # First unblock
1289 with session_scope() as session:
1290 session.execute(delete(UserBlock).execution_options(synchronize_session=False))
1291 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1293 # Host responds
1294 with requests_session(token2) as requests:
1295 requests.RespondHostRequest(
1296 requests_pb2.RespondHostRequestReq(
1297 host_request_id=host_request_id,
1298 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1299 text="Accepting your request",
1300 )
1301 )
1303 with session_scope() as session:
1304 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1306 # Now user1 (surfer) blocks user2 (host)
1307 make_user_block(user1, user2)
1309 with session_scope() as session:
1310 # check send_request_notifications does NOT create background job
1311 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1312 send_request_notifications(empty_pb2.Empty())
1313 process_jobs()
1315 # Should be 0 emails because the surfer blocked the host
1316 assert (
1317 session.execute(
1318 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1319 ).scalar_one()
1320 == 0
1321 ), "No notification email should be sent when surfer has blocked host"
1324def test_send_host_request_reminders_blocked_users_no_notification(db, moderator):
1325 """
1326 send_host_request_reminders should not send notifications when the host and surfer are not visible to each other
1327 (e.g., one blocked the other).
1328 """
1329 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1330 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1332 with session_scope() as session:
1333 # Create a pending host request where the host has not replied
1334 hr = create_host_request_by_date(
1335 session=session,
1336 surfer_user_id=user1.id,
1337 host_user_id=user2.id,
1338 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1339 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1340 status=HostRequestStatus.pending,
1341 host_sent_request_reminders=0,
1342 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1343 )
1345 # Approve the host request so it's visible for notifications
1346 moderator.approve_host_request(hr)
1348 # Verify that without blocking, a reminder would be sent
1349 send_host_request_reminders(empty_pb2.Empty())
1351 while process_job():
1352 pass
1354 with session_scope() as session:
1355 emails = session.execute(select(Email)).scalars().all()
1356 assert len(emails) == 1, "Expected 1 reminder email before blocking"
1358 # Clean up emails and background jobs
1359 session.execute(delete(Email).execution_options(synchronize_session=False))
1360 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1362 # Reset the reminder counter so we can test again
1363 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr)).scalar_one()
1364 host_request.host_sent_request_reminders = 0
1365 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL
1367 # Now have the host block the surfer
1368 make_user_block(user2, user1)
1370 send_host_request_reminders(empty_pb2.Empty())
1372 while process_job(): 1372 ↛ 1373line 1372 didn't jump to line 1373 because the condition on line 1372 was never true
1373 pass
1375 with session_scope() as session:
1376 emails = session.execute(select(Email)).scalars().all()
1377 assert len(emails) == 0, "No reminder email should be sent when host has blocked surfer"
1380def test_send_message_notifications_blocked_users_no_notification(db, moderator):
1381 """
1382 Regression test: send_message_notifications should not send notifications
1383 for messages from users who are blocked by the recipient.
1384 """
1385 user1, token1 = generate_user()
1386 user2, token2 = generate_user()
1388 make_friends(user1, user2)
1390 # Create a group chat and send messages
1391 with conversations_session(token1) as c:
1392 group_chat_id = c.CreateGroupChat(
1393 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
1394 ).group_chat_id
1396 # Approve the group chat so it's visible for notifications
1397 moderator.approve_group_chat(group_chat_id)
1399 with conversations_session(token1) as c:
1400 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
1401 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
1403 # Verify that without blocking, a notification would be sent
1404 with session_scope() as session:
1405 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1407 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1408 send_message_notifications(empty_pb2.Empty())
1409 process_jobs()
1411 with session_scope() as session:
1412 email_job_count = session.execute(
1413 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1414 ).scalar_one()
1415 assert email_job_count == 1, "Expected 1 notification email before blocking"
1417 # Clean up
1418 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1420 # Reset the notification state so user2 will receive notifications for old messages again
1421 with session_scope() as session:
1422 from couchers.models import User
1424 u2 = session.execute(select(User).where(User.id == user2.id)).scalar_one()
1425 u2.last_notified_message_id = 0
1427 # Now have user2 block user1
1428 make_user_block(user2, user1)
1430 # The existing messages from user1 should now NOT trigger notifications
1431 # since user2 has blocked user1
1432 with session_scope() as session:
1433 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1435 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1436 send_message_notifications(empty_pb2.Empty())
1437 process_jobs()
1439 with session_scope() as session:
1440 email_job_count = session.execute(
1441 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1442 ).scalar_one()
1443 assert email_job_count == 0, "No notification email should be sent when recipient has blocked sender"
1446def test_update_badges_volunteers(db, push_collector: PushCollector):
1447 """Test that volunteer and past_volunteer badges are automatically granted based on Volunteer model."""
1448 # Create 6 users - users 1 and 2 get founder/board_member badges from static_badges
1449 user1, _ = generate_user(last_donated=None)
1450 user2, _ = generate_user(last_donated=None)
1451 user3, _ = generate_user(last_donated=None)
1452 user4, _ = generate_user(last_donated=None)
1453 user5, _ = generate_user(last_donated=None)
1454 user6, _ = generate_user(last_donated=None)
1456 with session_scope() as session:
1457 # user3: active volunteer (stopped_volunteering is null)
1458 session.add(
1459 make_volunteer(
1460 user_id=user3.id,
1461 role="Developer",
1462 started_volunteering=date(2020, 1, 1),
1463 stopped_volunteering=None,
1464 )
1465 )
1467 # user4: past volunteer (stopped_volunteering is set)
1468 session.add(
1469 make_volunteer(
1470 user_id=user4.id,
1471 role="Designer",
1472 started_volunteering=date(2020, 1, 1),
1473 stopped_volunteering=date(2023, 6, 1),
1474 )
1475 )
1477 # user5: has old volunteer badge that should be removed (not a volunteer anymore)
1478 session.add(UserBadge(user_id=user5.id, badge_id="volunteer"))
1480 # user6: has old past_volunteer badge that should be removed
1481 session.add(UserBadge(user_id=user6.id, badge_id="past_volunteer"))
1483 update_badges(empty_pb2.Empty())
1484 process_jobs()
1486 with session_scope() as session:
1487 # Check user3 has volunteer badge
1488 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1489 assert "volunteer" in user3_badges
1490 assert "past_volunteer" not in user3_badges
1492 # Check user4 has past_volunteer badge
1493 user4_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user4.id)).scalars().all()
1494 assert "past_volunteer" in user4_badges
1495 assert "volunteer" not in user4_badges
1497 # Check user5 lost the volunteer badge (not in Volunteer table)
1498 user5_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user5.id)).scalars().all()
1499 assert "volunteer" not in user5_badges
1501 # Check user6 lost the past_volunteer badge (not in Volunteer table)
1502 user6_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user6.id)).scalars().all()
1503 assert "past_volunteer" not in user6_badges
1505 # Check notifications for volunteer badge users
1506 push = push_collector.pop_for_user(user3.id, last=True)
1507 assert push.content.title == "New profile badge: Active Volunteer"
1508 assert push.content.body == "The Active Volunteer badge was added to your profile."
1510 push = push_collector.pop_for_user(user4.id, last=True)
1511 assert push.content.title == "New profile badge: Past Volunteer"
1512 assert push.content.body == "The Past Volunteer badge was added to your profile."
1514 push = push_collector.pop_for_user(user5.id, last=True)
1515 assert push.content.title == "Profile badge removed"
1516 assert push.content.body == "The Active Volunteer badge was removed from your profile."
1518 push = push_collector.pop_for_user(user6.id, last=True)
1519 assert push.content.title == "Profile badge removed"
1520 assert push.content.body == "The Past Volunteer badge was removed from your profile."
1523def test_update_badges_volunteer_status_change(db, push_collector: PushCollector):
1524 """Test that badge is updated when volunteer status changes from active to past."""
1525 # Create users - users 1 and 2 get founder/board_member badges from static_badges
1526 user1, _ = generate_user(last_donated=None)
1527 user2, _ = generate_user(last_donated=None)
1528 user3, _ = generate_user(last_donated=None)
1530 with session_scope() as session:
1531 # user3: start as active volunteer
1532 session.add(
1533 make_volunteer(
1534 user_id=user3.id,
1535 role="Developer",
1536 started_volunteering=date(2020, 1, 1),
1537 stopped_volunteering=None,
1538 show_on_team_page=True,
1539 )
1540 )
1542 update_badges(empty_pb2.Empty())
1543 process_jobs()
1545 with session_scope() as session:
1546 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1547 assert "volunteer" in user3_badges
1548 assert "past_volunteer" not in user3_badges
1550 push = push_collector.pop_for_user(user3.id, last=True)
1551 assert push.content.title == "New profile badge: Active Volunteer"
1552 assert push.content.body == "The Active Volunteer badge was added to your profile."
1554 # Now change the volunteer to past volunteer
1555 with session_scope() as session:
1556 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == user3.id)).scalar_one()
1557 volunteer.stopped_volunteering = date(2023, 12, 1)
1559 update_badges(empty_pb2.Empty())
1560 process_jobs()
1562 with session_scope() as session:
1563 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1564 assert "volunteer" not in user3_badges
1565 assert "past_volunteer" in user3_badges
1567 # Check both badges were updated
1568 push = push_collector.pop_for_user(user3.id, last=False)
1569 assert push.content.title == "Profile badge removed"
1570 assert push.content.body == "The Active Volunteer badge was removed from your profile."
1572 push = push_collector.pop_for_user(user3.id, last=True)
1573 assert push.content.title == "New profile badge: Past Volunteer"
1574 assert push.content.body == "The Past Volunteer badge was added to your profile."
1577def test_send_message_notifications_empty_unseen_simple(monkeypatch):
1578 class DummyUser:
1579 id = 1
1580 is_visible = True
1581 last_notified_message_id = 0
1583 class FirstResult:
1584 def scalars(self):
1585 return self
1587 def unique(self):
1588 return [DummyUser()]
1590 class SecondResult:
1591 def all(self):
1592 return []
1594 class DummySession:
1595 def __init__(self):
1596 self.calls = 0
1598 def execute(self, *a, **k):
1599 self.calls += 1
1600 return FirstResult() if self.calls == 1 else SecondResult()
1602 def commit(self):
1603 pass
1605 def flush(self):
1606 pass
1608 def fake_session_scope():
1609 class Ctx:
1610 def __enter__(self):
1611 return DummySession()
1613 def __exit__(self, exc_type, exc, tb):
1614 pass
1616 return Ctx()
1618 monkeypatch.setattr(handlers, "session_scope", fake_session_scope)
1620 handlers.send_message_notifications(Empty())