Coverage for app/backend/src/tests/test_bg_jobs.py: 99%
734 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1from datetime import date, datetime, timedelta
2from typing import Any
3from unittest.mock import call, patch
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 import experimentation
14from couchers.config import config
15from couchers.constants import HOST_REQUEST_MAX_REMINDERS, HOST_REQUEST_REMINDER_INTERVAL
16from couchers.crypto import urlsafe_secure_token
17from couchers.db import session_scope
18from couchers.email.dev import print_dev_email
19from couchers.email.queuing import queue_email
20from couchers.jobs import handlers
21from couchers.jobs.definitions import Job
22from couchers.jobs.enqueue import queue_job
23from couchers.jobs.handlers import (
24 add_users_to_email_list,
25 enforce_community_membership,
26 purge_account_deletion_tokens,
27 purge_login_tokens,
28 purge_password_reset_tokens,
29 send_host_request_reminders,
30 send_message_notifications,
31 send_onboarding_emails,
32 send_reference_reminders,
33 send_request_notifications,
34 update_badges,
35 update_recommendation_scores,
36)
37from couchers.jobs.worker import _run_job_and_schedule, process_job, run_scheduler, service_jobs
38from couchers.materialized_views import refresh_materialized_views
39from couchers.metrics import create_prometheus_server
40from couchers.models import (
41 AccountDeletionToken,
42 BackgroundJob,
43 BackgroundJobState,
44 Email,
45 HostRequest,
46 HostRequestStatus,
47 LoginToken,
48 Message,
49 MessageType,
50 PasswordResetToken,
51 User,
52 UserBadge,
53 UserBlock,
54 Volunteer,
55)
56from couchers.proto import conversations_pb2, requests_pb2
57from couchers.proto.internal import jobs_pb2
58from couchers.utils import now, today
59from tests.fixtures.db import generate_user, make_friends, make_user_block, make_volunteer
60from tests.fixtures.misc import PushCollector, process_jobs
61from tests.fixtures.sessions import conversations_session, requests_session
62from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date
63from tests.test_requests import valid_request_text
66def now_5_min_in_future() -> datetime:
67 return now() + timedelta(minutes=5)
70@pytest.fixture(autouse=True)
71def _(testconfig):
72 pass
75def _check_job_counter(job, status, attempt, exception):
76 metrics_string = requests.get("http://localhost:8000").text
77 string_to_check = f'attempt="{attempt}",exception="{exception}",job="{job}",status="{status}"'
78 assert string_to_check in metrics_string
81def test_email_job(db):
82 with session_scope() as session:
83 queue_email(
84 session,
85 jobs_pb2.SendEmailPayload(
86 sender_name="sender_name",
87 sender_email="sender_email",
88 recipient="recipient",
89 subject="subject",
90 plain="plain",
91 html="html",
92 ),
93 )
95 def mock_print_dev_email(payload):
96 assert payload.sender_name == "sender_name"
97 assert payload.sender_email == "sender_email"
98 assert payload.recipient == "recipient"
99 assert payload.subject == "subject"
100 assert payload.plain == "plain"
101 assert payload.html == "html"
102 return print_dev_email(payload)
104 with patch("couchers.jobs.handlers.print_dev_email", mock_print_dev_email):
105 process_job()
107 with session_scope() as session:
108 assert (
109 session.execute(
110 select(func.count())
111 .select_from(BackgroundJob)
112 .where(BackgroundJob.state == BackgroundJobState.completed)
113 ).scalar_one()
114 == 1
115 )
116 assert (
117 session.execute(
118 select(func.count())
119 .select_from(BackgroundJob)
120 .where(BackgroundJob.state != BackgroundJobState.completed)
121 ).scalar_one()
122 == 0
123 )
126def test_purge_login_tokens(db):
127 user, api_token = generate_user()
129 with session_scope() as session:
130 login_token = LoginToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now())
131 session.add(login_token)
132 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 1
134 queue_job(session, job=purge_login_tokens, payload=empty_pb2.Empty())
135 process_job()
137 with session_scope() as session:
138 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 0
140 with session_scope() as session:
141 assert (
142 session.execute(
143 select(func.count())
144 .select_from(BackgroundJob)
145 .where(BackgroundJob.state == BackgroundJobState.completed)
146 ).scalar_one()
147 == 1
148 )
149 assert (
150 session.execute(
151 select(func.count())
152 .select_from(BackgroundJob)
153 .where(BackgroundJob.state != BackgroundJobState.completed)
154 ).scalar_one()
155 == 0
156 )
159def test_purge_password_reset_tokens(db):
160 user, api_token = generate_user()
162 with session_scope() as session:
163 password_reset_token = PasswordResetToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now())
164 session.add(password_reset_token)
165 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 1
167 queue_job(session, job=purge_password_reset_tokens, payload=empty_pb2.Empty())
168 process_job()
170 with session_scope() as session:
171 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 0
173 with session_scope() as session:
174 assert (
175 session.execute(
176 select(func.count())
177 .select_from(BackgroundJob)
178 .where(BackgroundJob.state == BackgroundJobState.completed)
179 ).scalar_one()
180 == 1
181 )
182 assert (
183 session.execute(
184 select(func.count())
185 .select_from(BackgroundJob)
186 .where(BackgroundJob.state != BackgroundJobState.completed)
187 ).scalar_one()
188 == 0
189 )
192def test_purge_account_deletion_tokens(db):
193 user, api_token = generate_user()
194 user2, api_token2 = generate_user()
195 user3, api_token3 = generate_user()
197 with session_scope() as session:
198 """
199 3 cases:
200 1) Token is valid
201 2) Token expired but account retrievable
202 3) Account is irretrievable (and expired)
203 """
204 account_deletion_tokens = [
205 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() - timedelta(hours=2)),
206 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user2.id, expiry=now()),
207 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user3.id, expiry=now() + timedelta(hours=5)),
208 ]
209 for token in account_deletion_tokens:
210 session.add(token)
211 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 3
213 queue_job(session, job=purge_account_deletion_tokens, payload=empty_pb2.Empty())
214 process_job()
216 with session_scope() as session:
217 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 1
219 with session_scope() as session:
220 assert (
221 session.execute(
222 select(func.count())
223 .select_from(BackgroundJob)
224 .where(BackgroundJob.state == BackgroundJobState.completed)
225 ).scalar_one()
226 == 1
227 )
228 assert (
229 session.execute(
230 select(func.count())
231 .select_from(BackgroundJob)
232 .where(BackgroundJob.state != BackgroundJobState.completed)
233 ).scalar_one()
234 == 0
235 )
238def test_enforce_community_memberships(db):
239 with session_scope() as session:
240 queue_job(session, job=enforce_community_membership, payload=empty_pb2.Empty())
241 process_job()
243 with session_scope() as session:
244 assert (
245 session.execute(
246 select(func.count())
247 .select_from(BackgroundJob)
248 .where(BackgroundJob.state == BackgroundJobState.completed)
249 ).scalar_one()
250 == 1
251 )
252 assert (
253 session.execute(
254 select(func.count())
255 .select_from(BackgroundJob)
256 .where(BackgroundJob.state != BackgroundJobState.completed)
257 ).scalar_one()
258 == 0
259 )
262def test_refresh_materialized_views(db):
263 with session_scope() as session:
264 queue_job(session, job=refresh_materialized_views, payload=empty_pb2.Empty())
266 process_job()
268 with session_scope() as session:
269 assert (
270 session.execute(
271 select(func.count())
272 .select_from(BackgroundJob)
273 .where(BackgroundJob.state == BackgroundJobState.completed)
274 ).scalar_one()
275 == 1
276 )
277 assert (
278 session.execute(
279 select(func.count())
280 .select_from(BackgroundJob)
281 .where(BackgroundJob.state != BackgroundJobState.completed)
282 ).scalar_one()
283 == 0
284 )
287def test_service_jobs(db):
288 with session_scope() as session:
289 queue_email(
290 session,
291 jobs_pb2.SendEmailPayload(
292 sender_name="sender_name",
293 sender_email="sender_email",
294 recipient="recipient",
295 subject="subject",
296 plain="plain",
297 html="html",
298 ),
299 )
301 # we create this HitSleep exception here, and mock out the normal sleep(1) in the infinite loop to instead raise
302 # this. that allows us to conveniently get out of the infinite loop and know we had no more jobs left
303 class HitSleep(Exception):
304 pass
306 # the mock `sleep` function that instead raises the aforementioned exception
307 def raising_sleep(seconds):
308 raise HitSleep()
310 with pytest.raises(HitSleep):
311 with patch("couchers.jobs.worker.sleep", raising_sleep):
312 service_jobs()
314 with session_scope() as session:
315 assert (
316 session.execute(
317 select(func.count())
318 .select_from(BackgroundJob)
319 .where(BackgroundJob.state == BackgroundJobState.completed)
320 ).scalar_one()
321 == 1
322 )
323 assert (
324 session.execute(
325 select(func.count())
326 .select_from(BackgroundJob)
327 .where(BackgroundJob.state != BackgroundJobState.completed)
328 ).scalar_one()
329 == 0
330 )
333def test_scheduler(db, monkeypatch):
334 def purge_login_tokens(payload: empty_pb2.Empty):
335 return
337 def send_message_notifications(payload: empty_pb2.Empty):
338 return
340 MOCK_JOBS = {
341 "purge_login_tokens": Job(purge_login_tokens, timedelta(seconds=7)),
342 "send_message_notifications": Job(send_message_notifications, timedelta(seconds=11)),
343 }
345 current_time = 0
346 end_time = 70
348 class EndOfTime(Exception):
349 pass
351 def mock_monotonic():
352 return current_time
354 def mock_sleep(seconds):
355 nonlocal current_time
356 current_time += seconds
357 if current_time > end_time:
358 raise EndOfTime()
360 realized_schedule = []
362 def mock_run_job_and_schedule(sched, job: Job[Any], frequency: timedelta) -> None:
363 realized_schedule.append((current_time, job.name))
364 _run_job_and_schedule(sched, job, frequency)
366 monkeypatch.setattr(couchers.jobs.worker, "_run_job_and_schedule", mock_run_job_and_schedule)
367 monkeypatch.setattr(couchers.jobs.worker, "JOBS", MOCK_JOBS)
368 monkeypatch.setattr(couchers.jobs.worker, "monotonic", mock_monotonic)
369 monkeypatch.setattr(couchers.jobs.worker, "sleep", mock_sleep)
371 with pytest.raises(EndOfTime):
372 run_scheduler()
374 # Convert to job indices for comparison (to maintain test compatibility)
375 job_order = ["purge_login_tokens", "send_message_notifications"]
376 realized_schedule_indices = [(time, job_order.index(job_name)) for time, job_name in realized_schedule]
378 assert realized_schedule_indices == [
379 (0.0, 0),
380 (0.0, 1),
381 (7.0, 0),
382 (11.0, 1),
383 (14.0, 0),
384 (21.0, 0),
385 (22.0, 1),
386 (28.0, 0),
387 (33.0, 1),
388 (35.0, 0),
389 (42.0, 0),
390 (44.0, 1),
391 (49.0, 0),
392 (55.0, 1),
393 (56.0, 0),
394 (63.0, 0),
395 (66.0, 1),
396 (70.0, 0),
397 ]
399 with session_scope() as session:
400 assert (
401 session.execute(
402 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.pending)
403 ).scalar_one()
404 == 18
405 )
406 assert (
407 session.execute(
408 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.pending)
409 ).scalar_one()
410 == 0
411 )
414def test_job_retry(db):
415 called_count = 0
417 def mock_job(payload: empty_pb2.Empty) -> None:
418 nonlocal called_count
419 called_count += 1
420 raise Exception()
422 with session_scope() as session:
423 queue_job(session, job=mock_job, payload=empty_pb2.Empty())
425 MOCK_JOBS: dict[str, Job[Any]] = {
426 "mock_job": Job(mock_job),
427 }
428 create_prometheus_server(port=8000)
430 # if IN_TEST is true, then the bg worker will raise on exceptions
431 new_config = config.copy()
432 new_config.IN_TEST = False
434 with patch("couchers.jobs.worker.config", new_config), patch("couchers.jobs.worker.JOBS", MOCK_JOBS):
435 process_job()
436 with session_scope() as session:
437 assert (
438 session.execute(
439 select(func.count())
440 .select_from(BackgroundJob)
441 .where(BackgroundJob.state == BackgroundJobState.error)
442 ).scalar_one()
443 == 1
444 )
445 assert (
446 session.execute(
447 select(func.count())
448 .select_from(BackgroundJob)
449 .where(BackgroundJob.state != BackgroundJobState.error)
450 ).scalar_one()
451 == 0
452 )
454 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
455 process_job()
456 with session_scope() as session:
457 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
458 process_job()
459 with session_scope() as session:
460 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
461 process_job()
462 with session_scope() as session:
463 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
464 process_job()
466 with session_scope() as session:
467 assert (
468 session.execute(
469 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.failed)
470 ).scalar_one()
471 == 1
472 )
473 assert (
474 session.execute(
475 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.failed)
476 ).scalar_one()
477 == 0
478 )
480 _check_job_counter("mock_job", "error", "4", "Exception")
481 _check_job_counter("mock_job", "failed", "5", "Exception")
484def test_no_jobs_no_problem(db):
485 with session_scope() as session:
486 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
488 assert not process_job()
490 with session_scope() as session:
491 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
494def test_send_message_notifications_basic(db, moderator):
495 user1, token1 = generate_user()
496 user2, token2 = generate_user()
497 user3, token3 = generate_user()
499 make_friends(user1, user2)
500 make_friends(user1, user3)
501 make_friends(user2, user3)
503 send_message_notifications(empty_pb2.Empty())
504 process_jobs()
506 # should find no jobs, since there's no messages
507 with session_scope() as session:
508 assert (
509 session.execute(
510 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
511 ).scalar_one()
512 == 0
513 )
515 with conversations_session(token1) as c:
516 group_chat_id1 = c.CreateGroupChat(
517 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id])
518 ).group_chat_id
519 moderator.approve_group_chat(group_chat_id1)
521 with conversations_session(token1) as c:
522 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 1"))
523 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 2"))
524 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 3"))
525 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 4"))
527 with conversations_session(token3) as c:
528 group_chat_id2 = c.CreateGroupChat(
529 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
530 ).group_chat_id
531 moderator.approve_group_chat(group_chat_id2)
533 with conversations_session(token3) as c:
534 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 5"))
535 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 6"))
537 send_message_notifications(empty_pb2.Empty())
538 process_jobs()
540 # no emails sent out
541 with session_scope() as session:
542 assert (
543 session.execute(
544 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
545 ).scalar_one()
546 == 0
547 )
549 # this should generate emails for both user2 and user3
550 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
551 send_message_notifications(empty_pb2.Empty())
552 process_jobs()
554 with session_scope() as session:
555 assert (
556 session.execute(
557 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
558 ).scalar_one()
559 == 2
560 )
561 # delete them all
562 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
564 # shouldn't generate any more emails
565 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
566 send_message_notifications(empty_pb2.Empty())
567 process_jobs()
569 with session_scope() as session:
570 assert (
571 session.execute(
572 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
573 ).scalar_one()
574 == 0
575 )
578def test_send_message_notifications_muted(db, moderator):
579 user1, token1 = generate_user()
580 user2, token2 = generate_user()
581 user3, token3 = generate_user()
583 make_friends(user1, user2)
584 make_friends(user1, user3)
585 make_friends(user2, user3)
587 send_message_notifications(empty_pb2.Empty())
588 process_jobs()
590 # should find no jobs, since there's no messages
591 with session_scope() as session:
592 assert (
593 session.execute(
594 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
595 ).scalar_one()
596 == 0
597 )
599 with conversations_session(token1) as c:
600 group_chat_id = c.CreateGroupChat(
601 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id])
602 ).group_chat_id
603 moderator.approve_group_chat(group_chat_id)
605 with conversations_session(token3) as c:
606 # mute it for user 3
607 c.MuteGroupChat(conversations_pb2.MuteGroupChatReq(group_chat_id=group_chat_id, forever=True))
609 with conversations_session(token1) as c:
610 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
611 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
612 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3"))
613 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4"))
615 with conversations_session(token3) as c:
616 group_chat_id = c.CreateGroupChat(
617 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
618 ).group_chat_id
619 moderator.approve_group_chat(group_chat_id)
620 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 5"))
621 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 6"))
623 send_message_notifications(empty_pb2.Empty())
624 process_jobs()
626 # no emails sent out
627 with session_scope() as session:
628 assert (
629 session.execute(
630 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
631 ).scalar_one()
632 == 0
633 )
635 # this should generate emails for both user2 and NOT user3
636 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
637 send_message_notifications(empty_pb2.Empty())
638 process_jobs()
640 with session_scope() as session:
641 assert (
642 session.execute(
643 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
644 ).scalar_one()
645 == 1
646 )
647 # delete them all
648 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
650 # shouldn't generate any more emails
651 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
652 send_message_notifications(empty_pb2.Empty())
653 process_jobs()
655 with session_scope() as session:
656 assert (
657 session.execute(
658 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
659 ).scalar_one()
660 == 0
661 )
664def test_send_request_notifications_host_request(db, moderator):
665 user1, token1 = generate_user()
666 user2, token2 = generate_user()
668 today_plus_2 = (today() + timedelta(days=2)).isoformat()
669 today_plus_3 = (today() + timedelta(days=3)).isoformat()
671 send_request_notifications(empty_pb2.Empty())
672 process_jobs()
674 # should find no jobs, since there's no messages
675 with session_scope() as session:
676 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
678 with requests_session(token1) as requests:
679 host_request_id = requests.CreateHostRequest(
680 requests_pb2.CreateHostRequestReq(
681 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
682 )
683 ).host_request_id
684 moderator.approve_host_request(host_request_id)
686 with session_scope() as session:
687 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
689 # the only unseen message is the creation message, which the host was already
690 # notified about via host_request__create — no missed_messages email
691 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
692 send_request_notifications(empty_pb2.Empty())
693 process_jobs()
694 assert (
695 session.execute(
696 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
697 ).scalar_one()
698 == 0
699 )
701 # test that responding to host request creates email
702 with requests_session(token2) as requests:
703 requests.RespondHostRequest(
704 requests_pb2.RespondHostRequestReq(
705 host_request_id=host_request_id,
706 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
707 text="Test request",
708 )
709 )
711 with session_scope() as session:
712 # delete send_email BackgroundJob created by RespondHostRequest
713 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
715 # check send_request_notifications successfully creates background job
716 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
717 send_request_notifications(empty_pb2.Empty())
718 process_jobs()
719 assert (
720 session.execute(
721 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
722 ).scalar_one()
723 == 1
724 )
726 # delete all BackgroundJobs
727 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
729 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
730 send_request_notifications(empty_pb2.Empty())
731 process_jobs()
732 # should find no messages since guest has already been notified
733 assert (
734 session.execute(
735 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
736 ).scalar_one()
737 == 0
738 )
741def test_send_request_notifications_host_request_with_followup(db, moderator):
742 """
743 When the surfer sends a follow-up message after creating the host request,
744 the host should get a missed_messages notification (even though the initial
745 creation message alone would be skipped).
746 """
747 user1, token1 = generate_user()
748 user2, token2 = generate_user()
750 today_plus_2 = (today() + timedelta(days=2)).isoformat()
751 today_plus_3 = (today() + timedelta(days=3)).isoformat()
753 with requests_session(token1) as requests:
754 host_request_id = requests.CreateHostRequest(
755 requests_pb2.CreateHostRequestReq(
756 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
757 )
758 ).host_request_id
759 moderator.approve_host_request(host_request_id)
761 # surfer sends a follow-up message
762 with requests_session(token1) as requests:
763 requests.SendHostRequestMessage(
764 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_id, text="Following up on my request!")
765 )
767 with session_scope() as session:
768 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
770 # now there are two unseen text messages for the host, so missed_messages should fire
771 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
772 send_request_notifications(empty_pb2.Empty())
773 process_jobs()
774 assert (
775 session.execute(
776 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
777 ).scalar_one()
778 == 1
779 )
782def test_send_request_notifications_two_requests_one_with_followup(db, moderator):
783 """
784 A host (user2) receives two requests: first from user1 (with a follow-up message),
785 then from user3 (creation only). Because request B is created after request A's
786 follow-up, it has a higher message ID. If the background job processes B first and
787 advances last_notified_request_message_id past A's messages, one might expect A's
788 notification to be lost — but it isn't, because the query results are already
789 materialized before the loop begins.
790 """
791 user1, token1 = generate_user()
792 user2, token2 = generate_user()
793 user3, token3 = generate_user()
795 today_plus_2 = (today() + timedelta(days=2)).isoformat()
796 today_plus_3 = (today() + timedelta(days=3)).isoformat()
798 # request A: user1 -> user2, with a follow-up
799 with requests_session(token1) as requests:
800 host_request_a = requests.CreateHostRequest(
801 requests_pb2.CreateHostRequestReq(
802 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
803 )
804 ).host_request_id
805 moderator.approve_host_request(host_request_a)
807 with requests_session(token1) as requests:
808 requests.SendHostRequestMessage(
809 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_a, text="Sorry, meant Tuesday night!")
810 )
812 # request B: user3 -> user2, creation only (higher message IDs than A's follow-up)
813 with requests_session(token3) as requests:
814 host_request_b = requests.CreateHostRequest(
815 requests_pb2.CreateHostRequestReq(
816 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
817 )
818 ).host_request_id
819 moderator.approve_host_request(host_request_b)
821 with session_scope() as session:
822 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
824 # should get exactly 1 missed_messages email: for request A (has follow-up),
825 # not request B (creation only, skipped)
826 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
827 send_request_notifications(empty_pb2.Empty())
828 process_jobs()
829 assert (
830 session.execute(
831 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
832 ).scalar_one()
833 == 1
834 )
837def test_send_message_notifications_seen(db, moderator):
838 user1, token1 = generate_user()
839 user2, token2 = generate_user()
841 make_friends(user1, user2)
843 send_message_notifications(empty_pb2.Empty())
845 # should find no jobs, since there's no messages
846 with session_scope() as session:
847 assert (
848 session.execute(
849 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
850 ).scalar_one()
851 == 0
852 )
854 with conversations_session(token1) as c:
855 group_chat_id = c.CreateGroupChat(
856 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
857 ).group_chat_id
858 moderator.approve_group_chat(group_chat_id)
859 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
860 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
861 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3"))
862 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4"))
864 # user 2 now marks those messages as seen
865 with conversations_session(token2) as c:
866 m_id = c.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id)).latest_message.message_id
867 c.MarkLastSeenGroupChat(
868 conversations_pb2.MarkLastSeenGroupChatReq(group_chat_id=group_chat_id, last_seen_message_id=m_id)
869 )
871 send_message_notifications(empty_pb2.Empty())
873 # no emails sent out
874 with session_scope() as session:
875 assert (
876 session.execute(
877 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
878 ).scalar_one()
879 == 0
880 )
882 def now_30_min_in_future():
883 return now() + timedelta(minutes=30)
885 # still shouldn't generate emails as user2 has seen all messages
886 with patch("couchers.jobs.handlers.now", now_30_min_in_future):
887 send_message_notifications(empty_pb2.Empty())
889 with session_scope() as session:
890 assert (
891 session.execute(
892 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
893 ).scalar_one()
894 == 0
895 )
898def test_send_onboarding_emails(db):
899 # needs to get first onboarding email
900 user1, token1 = generate_user(onboarding_emails_sent=0, last_onboarding_email_sent=None, complete_profile=False)
902 send_onboarding_emails(empty_pb2.Empty())
903 process_jobs()
905 with session_scope() as session:
906 assert (
907 session.execute(
908 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
909 ).scalar_one()
910 == 1
911 )
913 # needs to get second onboarding email, but not yet
914 user2, token2 = generate_user(
915 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=6), complete_profile=False
916 )
918 send_onboarding_emails(empty_pb2.Empty())
919 process_jobs()
921 with session_scope() as session:
922 assert (
923 session.execute(
924 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
925 ).scalar_one()
926 == 1
927 )
929 # needs to get second onboarding email
930 user3, token3 = generate_user(
931 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=8), complete_profile=False
932 )
934 send_onboarding_emails(empty_pb2.Empty())
935 process_jobs()
937 with session_scope() as session:
938 assert (
939 session.execute(
940 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
941 ).scalar_one()
942 == 2
943 )
946def test_send_reference_reminders(db):
947 # need to test:
948 # case 1: bidirectional (no emails)
949 # case 2: host left ref (surfer needs an email)
950 # case 3: surfer left ref (host needs an email)
951 # case 4: neither left ref (host & surfer need an email)
952 # case 5: neither left ref, but host blocked surfer, so neither should get an email
953 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
955 send_reference_reminders(empty_pb2.Empty())
957 # case 1: bidirectional (no emails)
958 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
959 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
961 # case 2: host left ref (surfer needs an email)
962 # host
963 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
964 # surfer
965 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
967 # case 3: surfer left ref (host needs an email)
968 # host
969 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
970 # surfer
971 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
973 # case 4: neither left ref (host & surfer need an email)
974 # surfer
975 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
976 # host
977 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
979 # case 5: neither left ref, but host blocked surfer, so neither should get an email
980 # surfer
981 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
982 # host
983 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
985 make_user_block(user9, user10)
987 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
988 # host
989 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
990 # surfer
991 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
993 with session_scope() as session:
994 # note that create_host_reference creates a host request whose age is one day older than the timedelta here
996 # case 1: bidirectional (no emails)
997 ref1, hr1 = create_host_reference(session, user2.id, user1.id, timedelta(days=7), surfing=True)
998 create_host_reference(session, user1.id, user2.id, timedelta(days=7), host_request_id=hr1)
1000 # case 2: host left ref (surfer needs an email)
1001 ref2, hr2 = create_host_reference(session, user3.id, user4.id, timedelta(days=11), surfing=False)
1003 # case 3: surfer left ref (host needs an email)
1004 ref3, hr3 = create_host_reference(session, user6.id, user5.id, timedelta(days=9), surfing=True)
1006 # case 4: neither left ref (host & surfer need an email)
1007 hr4 = create_host_request(session, user7.id, user8.id, timedelta(days=4))
1009 # case 5: neither left ref, but host blocked surfer, so neither should get an email
1010 hr5 = create_host_request(session, user9.id, user10.id, timedelta(days=7))
1012 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
1013 hr6 = create_host_request(session, user12.id, user11.id, timedelta(days=6), surfer_reason_didnt_meetup="")
1015 expected_emails = [
1016 (
1017 "user11@couchers.org.invalid",
1018 "[TEST] You have 14 days to write a reference for User 12!",
1019 ("from when you hosted them", "/leave-reference/hosted/"),
1020 ),
1021 (
1022 "user4@couchers.org.invalid",
1023 "[TEST] You have 3 days to write a reference for User 3!",
1024 ("from when you surfed with them", "/leave-reference/surfed/"),
1025 ),
1026 (
1027 "user5@couchers.org.invalid",
1028 "[TEST] You have 7 days to write a reference for User 6!",
1029 ("from when you hosted them", "/leave-reference/hosted/"),
1030 ),
1031 (
1032 "user7@couchers.org.invalid",
1033 "[TEST] You have 14 days to write a reference for User 8!",
1034 ("from when you surfed with them", "/leave-reference/surfed/"),
1035 ),
1036 (
1037 "user8@couchers.org.invalid",
1038 "[TEST] You have 14 days to write a reference for User 7!",
1039 ("from when you hosted them", "/leave-reference/hosted/"),
1040 ),
1041 ]
1043 send_reference_reminders(empty_pb2.Empty())
1045 while process_job():
1046 pass
1048 with session_scope() as session:
1049 emails = [
1050 (email.recipient, email.subject, email.plain, email.html)
1051 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
1052 ]
1054 actual_addresses_and_subjects = [email[:2] for email in emails]
1055 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1057 print(actual_addresses_and_subjects)
1058 print(expected_addresses_and_subjects)
1060 assert actual_addresses_and_subjects == expected_addresses_and_subjects
1062 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
1063 for find in search_strings:
1064 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
1065 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
1068def test_send_host_request_reminders(db, moderator):
1069 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1070 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1071 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
1072 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
1073 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
1074 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
1075 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
1076 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
1077 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
1078 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
1079 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
1080 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
1081 user13, token13 = generate_user(email="user13@couchers.org.invalid", name="User 13")
1082 user14, token14 = generate_user(email="user14@couchers.org.invalid", name="User 14")
1084 with session_scope() as session:
1085 # case 1: pending, future, interval elapsed => notify
1086 hr1 = create_host_request_by_date(
1087 session=session,
1088 surfer_user_id=user1.id,
1089 host_user_id=user2.id,
1090 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1091 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1092 status=HostRequestStatus.pending,
1093 host_sent_request_reminders=0,
1094 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1095 )
1097 # case 2: max reminders reached => do not notify
1098 hr2 = create_host_request_by_date(
1099 session=session,
1100 surfer_user_id=user3.id,
1101 host_user_id=user4.id,
1102 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1103 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1104 status=HostRequestStatus.pending,
1105 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS,
1106 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1107 )
1109 # case 3: interval not yet elapsed => do not notify
1110 hr3 = create_host_request_by_date(
1111 session=session,
1112 surfer_user_id=user5.id,
1113 host_user_id=user6.id,
1114 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1115 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1116 status=HostRequestStatus.pending,
1117 host_sent_request_reminders=0,
1118 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL + timedelta(hours=1),
1119 )
1121 # case 4: start date is today => do not notify
1122 hr4 = create_host_request_by_date(
1123 session=session,
1124 surfer_user_id=user7.id,
1125 host_user_id=user8.id,
1126 from_date=today(),
1127 to_date=today() + timedelta(days=2),
1128 status=HostRequestStatus.pending,
1129 host_sent_request_reminders=0,
1130 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1131 )
1133 # case 5: from_date in the past => do not notify
1134 hr5 = create_host_request_by_date(
1135 session=session,
1136 surfer_user_id=user9.id,
1137 host_user_id=user10.id,
1138 from_date=today() - timedelta(days=1),
1139 to_date=today() + timedelta(days=1),
1140 status=HostRequestStatus.pending,
1141 host_sent_request_reminders=0,
1142 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1143 )
1145 # case 6: non-pending status => do not notify
1146 hr6 = create_host_request_by_date(
1147 session=session,
1148 surfer_user_id=user11.id,
1149 host_user_id=user12.id,
1150 from_date=today() + timedelta(days=3),
1151 to_date=today() + timedelta(days=4),
1152 status=HostRequestStatus.accepted,
1153 host_sent_request_reminders=0,
1154 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1155 )
1157 # case 7: host already sent a message => do not notify
1158 hr7 = create_host_request_by_date(
1159 session=session,
1160 surfer_user_id=user13.id,
1161 host_user_id=user14.id,
1162 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1163 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1164 status=HostRequestStatus.pending,
1165 host_sent_request_reminders=0,
1166 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1167 )
1169 msg = Message(
1170 conversation_id=hr7,
1171 author_id=user14.id,
1172 text="Looking forward to hosting you!",
1173 message_type=MessageType.text,
1174 )
1175 msg.time = now()
1176 session.add(msg)
1178 # Approve host requests so they're visible for notifications
1179 moderator.approve_host_request(hr1)
1180 moderator.approve_host_request(hr2)
1181 moderator.approve_host_request(hr3)
1182 moderator.approve_host_request(hr4)
1183 moderator.approve_host_request(hr5)
1184 moderator.approve_host_request(hr6)
1185 moderator.approve_host_request(hr7)
1187 send_host_request_reminders(empty_pb2.Empty())
1189 while process_job():
1190 pass
1192 with session_scope() as session:
1193 emails = [
1194 (email.recipient, email.subject, email.plain, email.html)
1195 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
1196 ]
1198 expected_emails = [
1199 (
1200 "user2@couchers.org.invalid",
1201 "[TEST] You have a pending host request from User 1!",
1202 ("Please respond to the request!", "User 1"),
1203 )
1204 ]
1206 actual_addresses_and_subjects = [email[:2] for email in emails]
1207 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1209 print(actual_addresses_and_subjects)
1210 print(expected_addresses_and_subjects)
1212 assert actual_addresses_and_subjects == expected_addresses_and_subjects
1214 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
1215 for find in search_strings:
1216 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
1217 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
1220def test_add_users_to_email_list(db, feature_flags):
1221 feature_flags.set("listmonk_enabled", True)
1222 new_config = config.copy()
1223 new_config.LISTMONK_BASE_URL = "https://example.com"
1224 new_config.LISTMONK_API_USERNAME = "test_user"
1225 new_config.LISTMONK_API_KEY = "dummy_api_key"
1226 new_config.LISTMONK_LIST_ID = 6
1228 with patch("couchers.jobs.handlers.config", new_config):
1229 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls:
1230 mock_session_cls.return_value.post.return_value.status_code = 200
1231 add_users_to_email_list(empty_pb2.Empty())
1232 mock_session_cls.return_value.post.assert_not_called()
1234 generate_user(in_sync_with_newsletter=False, email="testing1@couchers.invalid", name="Tester1", id=15)
1235 generate_user(in_sync_with_newsletter=True, email="testing2@couchers.invalid", name="Tester2")
1236 generate_user(in_sync_with_newsletter=False, email="testing3@couchers.invalid", name="Tester3 von test", id=17)
1237 generate_user(
1238 in_sync_with_newsletter=False, email="testing4@couchers.invalid", name="Tester4", opt_out_of_newsletter=True
1239 )
1241 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls:
1242 mock_sess = mock_session_cls.return_value
1243 mock_sess.post.return_value.status_code = 200
1244 add_users_to_email_list(empty_pb2.Empty())
1245 mock_sess.post.assert_has_calls(
1246 [
1247 call(
1248 "https://example.com/api/subscribers",
1249 json={
1250 "email": "testing1@couchers.invalid",
1251 "name": "Tester1",
1252 "lists": [6],
1253 "preconfirm_subscriptions": True,
1254 "attribs": {"couchers_user_id": 15},
1255 "status": "enabled",
1256 },
1257 timeout=10,
1258 ),
1259 call(
1260 "https://example.com/api/subscribers",
1261 json={
1262 "email": "testing3@couchers.invalid",
1263 "name": "Tester3 von test",
1264 "lists": [6],
1265 "preconfirm_subscriptions": True,
1266 "attribs": {"couchers_user_id": 17},
1267 "status": "enabled",
1268 },
1269 timeout=10,
1270 ),
1271 ],
1272 any_order=True,
1273 )
1275 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls:
1276 mock_session_cls.return_value.post.return_value.status_code = 200
1277 add_users_to_email_list(empty_pb2.Empty())
1278 mock_session_cls.return_value.post.assert_not_called()
1281def test_update_recommendation_scores(db):
1282 update_recommendation_scores(empty_pb2.Empty())
1285def test_update_badges(db, push_collector: PushCollector):
1286 user1, _ = generate_user(last_donated=None)
1287 user2, _ = generate_user(last_donated=None)
1288 user3, _ = generate_user(last_donated=None)
1289 user4, _ = generate_user(phone="+15555555555", phone_verification_verified=func.now(), last_donated=None)
1290 user5, _ = generate_user(phone="+15555555556", phone_verification_verified=func.now(), last_donated=None)
1291 user6, _ = generate_user(last_donated=None)
1293 with session_scope() as session:
1294 session.add(UserBadge(user_id=user5.id, badge_id="board_member"))
1296 update_badges(empty_pb2.Empty())
1297 process_jobs()
1299 with session_scope() as session:
1300 badge_tuples = session.execute(
1301 select(UserBadge.user_id, UserBadge.badge_id).order_by(UserBadge.user_id.asc(), UserBadge.id.asc())
1302 ).all()
1304 expected = [
1305 (user1.id, "founder"),
1306 (user1.id, "board_member"),
1307 (user2.id, "founder"),
1308 (user2.id, "board_member"),
1309 (user4.id, "phone_verified"),
1310 (user5.id, "phone_verified"),
1311 ]
1313 assert badge_tuples == expected # type: ignore[comparison-overlap]
1315 print(push_collector.by_user)
1317 push = push_collector.pop_for_user(user1.id, last=False)
1318 assert push.content.title == "New profile badge: Founder"
1319 assert push.content.body == "The Founder badge was added to your profile."
1321 push = push_collector.pop_for_user(user1.id, last=True)
1322 assert push.content.title == "New profile badge: Board Member"
1323 assert push.content.body == "The Board Member badge was added to your profile."
1325 push = push_collector.pop_for_user(user2.id, last=False)
1326 assert push.content.title == "New profile badge: Founder"
1327 assert push.content.body == "The Founder badge was added to your profile."
1329 push = push_collector.pop_for_user(user2.id, last=True)
1330 assert push.content.title == "New profile badge: Board Member"
1331 assert push.content.body == "The Board Member badge was added to your profile."
1333 push = push_collector.pop_for_user(user4.id, last=True)
1334 assert push.content.title == "New profile badge: Verified Phone"
1335 assert push.content.body == "The Verified Phone badge was added to your profile."
1337 push = push_collector.pop_for_user(user5.id, last=False)
1338 assert push.content.title == "Profile badge removed"
1339 assert push.content.body == "The Board Member badge was removed from your profile."
1341 push = push_collector.pop_for_user(user5.id, last=True)
1342 assert push.content.title == "New profile badge: Verified Phone"
1343 assert push.content.body == "The Verified Phone badge was added to your profile."
1346def test_update_badges_awards_moderator_to_superuser(db):
1347 """The show_moderator_badge flag defaults on, so superusers are awarded the moderator badge."""
1348 superuser, _ = generate_user(is_superuser=True, last_donated=None)
1350 update_badges(empty_pb2.Empty())
1352 with session_scope() as session:
1353 assert (
1354 session.execute(
1355 select(func.count())
1356 .select_from(UserBadge)
1357 .where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1358 ).scalar()
1359 == 1
1360 )
1363def test_update_badges_skips_moderator_when_flag_off(db, monkeypatch):
1364 """With show_moderator_badge forced off, superusers are not awarded the moderator badge."""
1365 # force show_moderator_badge off for everyone (force rule with no coverage applies globally)
1366 monkeypatch.setattr(experimentation, "_initialized", True)
1367 monkeypatch.setattr(
1368 experimentation,
1369 "_state",
1370 {"features": {"show_moderator_badge": {"defaultValue": True, "rules": [{"force": False}]}}, "savedGroups": {}},
1371 )
1372 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "")
1374 superuser, _ = generate_user(is_superuser=True, last_donated=None)
1376 update_badges(empty_pb2.Empty())
1378 with session_scope() as session:
1379 assert (
1380 session.execute(
1381 select(func.count())
1382 .select_from(UserBadge)
1383 .where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1384 ).scalar()
1385 == 0
1386 )
1389def test_send_request_notifications_blocked_users_no_notification(db, moderator):
1390 """
1391 Regression test: send_request_notifications should not send notifications
1392 when the host and surfer are not visible to each other (e.g., one blocked the other).
1393 """
1394 user1, token1 = generate_user()
1395 user2, token2 = generate_user()
1397 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1398 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1400 # Create a host request
1401 with requests_session(token1) as requests:
1402 host_request_id = requests.CreateHostRequest(
1403 requests_pb2.CreateHostRequestReq(
1404 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
1405 )
1406 ).host_request_id
1407 moderator.approve_host_request(host_request_id)
1409 with session_scope() as session:
1410 # delete send_email BackgroundJob created by CreateHostRequest
1411 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1413 # Now user2 (host) blocks user1 (surfer)
1414 make_user_block(user2, user1)
1416 with session_scope() as session:
1417 # check send_request_notifications does NOT create background job because users are blocked
1418 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1419 send_request_notifications(empty_pb2.Empty())
1420 process_jobs()
1422 # Should be 0 emails because the host blocked the surfer
1423 assert (
1424 session.execute(
1425 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1426 ).scalar_one()
1427 == 0
1428 ), "No notification email should be sent when host has blocked surfer"
1430 # Also test the reverse direction: surfer sends message to host, host should not get notification
1431 # First unblock
1432 with session_scope() as session:
1433 session.execute(delete(UserBlock).execution_options(synchronize_session=False))
1434 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1436 # Host responds
1437 with requests_session(token2) as requests:
1438 requests.RespondHostRequest(
1439 requests_pb2.RespondHostRequestReq(
1440 host_request_id=host_request_id,
1441 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1442 text="Accepting your request",
1443 )
1444 )
1446 with session_scope() as session:
1447 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1449 # Now user1 (surfer) blocks user2 (host)
1450 make_user_block(user1, user2)
1452 with session_scope() as session:
1453 # check send_request_notifications does NOT create background job
1454 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1455 send_request_notifications(empty_pb2.Empty())
1456 process_jobs()
1458 # Should be 0 emails because the surfer blocked the host
1459 assert (
1460 session.execute(
1461 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1462 ).scalar_one()
1463 == 0
1464 ), "No notification email should be sent when surfer has blocked host"
1467def test_send_host_request_reminders_blocked_users_no_notification(db, moderator):
1468 """
1469 send_host_request_reminders should not send notifications when the host and surfer are not visible to each other
1470 (e.g., one blocked the other).
1471 """
1472 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1473 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1475 with session_scope() as session:
1476 # Create a pending host request where the host has not replied
1477 hr = create_host_request_by_date(
1478 session=session,
1479 surfer_user_id=user1.id,
1480 host_user_id=user2.id,
1481 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1482 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1483 status=HostRequestStatus.pending,
1484 host_sent_request_reminders=0,
1485 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1486 )
1488 # Approve the host request so it's visible for notifications
1489 moderator.approve_host_request(hr)
1491 # Verify that without blocking, a reminder would be sent
1492 send_host_request_reminders(empty_pb2.Empty())
1494 while process_job():
1495 pass
1497 with session_scope() as session:
1498 emails = session.execute(select(Email)).scalars().all()
1499 assert len(emails) == 1, "Expected 1 reminder email before blocking"
1501 # Clean up emails and background jobs
1502 session.execute(delete(Email).execution_options(synchronize_session=False))
1503 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1505 # Reset the reminder counter so we can test again
1506 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr)).scalar_one()
1507 host_request.recipient_sent_request_reminders = 0
1508 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL
1510 # Now have the host block the surfer
1511 make_user_block(user2, user1)
1513 send_host_request_reminders(empty_pb2.Empty())
1515 while process_job(): 1515 ↛ 1516line 1515 didn't jump to line 1516 because the condition on line 1515 was never true
1516 pass
1518 with session_scope() as session:
1519 emails = session.execute(select(Email)).scalars().all()
1520 assert len(emails) == 0, "No reminder email should be sent when host has blocked surfer"
1523def test_send_message_notifications_blocked_users_no_notification(db, moderator):
1524 """
1525 Regression test: send_message_notifications should not send notifications
1526 for messages from users who are blocked by the recipient.
1527 """
1528 user1, token1 = generate_user()
1529 user2, token2 = generate_user()
1531 make_friends(user1, user2)
1533 # Create a group chat and send messages
1534 with conversations_session(token1) as c:
1535 group_chat_id = c.CreateGroupChat(
1536 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
1537 ).group_chat_id
1539 # Approve the group chat so it's visible for notifications
1540 moderator.approve_group_chat(group_chat_id)
1542 with conversations_session(token1) as c:
1543 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
1544 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
1546 # Verify that without blocking, a notification would be sent
1547 with session_scope() as session:
1548 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1550 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1551 send_message_notifications(empty_pb2.Empty())
1552 process_jobs()
1554 with session_scope() as session:
1555 email_job_count = session.execute(
1556 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1557 ).scalar_one()
1558 assert email_job_count == 1, "Expected 1 notification email before blocking"
1560 # Clean up
1561 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1563 # Reset the notification state so user2 will receive notifications for old messages again
1564 with session_scope() as session:
1565 u2 = session.execute(select(User).where(User.id == user2.id)).scalar_one()
1566 u2.last_notified_message_id = 0
1568 # Now have user2 block user1
1569 make_user_block(user2, user1)
1571 # The existing messages from user1 should now NOT trigger notifications
1572 # since user2 has blocked user1
1573 with session_scope() as session:
1574 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1576 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1577 send_message_notifications(empty_pb2.Empty())
1578 process_jobs()
1580 with session_scope() as session:
1581 email_job_count = session.execute(
1582 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1583 ).scalar_one()
1584 assert email_job_count == 0, "No notification email should be sent when recipient has blocked sender"
1587def test_update_badges_volunteers(db, push_collector: PushCollector):
1588 """Test that volunteer and past_volunteer badges are automatically granted based on Volunteer model."""
1589 # Create 6 users - users 1 and 2 get founder/board_member badges from static_badges
1590 user1, _ = generate_user(last_donated=None)
1591 user2, _ = generate_user(last_donated=None)
1592 user3, _ = generate_user(last_donated=None)
1593 user4, _ = generate_user(last_donated=None)
1594 user5, _ = generate_user(last_donated=None)
1595 user6, _ = generate_user(last_donated=None)
1597 with session_scope() as session:
1598 # user3: active volunteer (stopped_volunteering is null)
1599 session.add(
1600 make_volunteer(
1601 user_id=user3.id,
1602 role="Developer",
1603 started_volunteering=date(2020, 1, 1),
1604 stopped_volunteering=None,
1605 )
1606 )
1608 # user4: past volunteer (stopped_volunteering is set)
1609 session.add(
1610 make_volunteer(
1611 user_id=user4.id,
1612 role="Designer",
1613 started_volunteering=date(2020, 1, 1),
1614 stopped_volunteering=date(2023, 6, 1),
1615 )
1616 )
1618 # user5: has old volunteer badge that should be removed (not a volunteer anymore)
1619 session.add(UserBadge(user_id=user5.id, badge_id="volunteer"))
1621 # user6: has old past_volunteer badge that should be removed
1622 session.add(UserBadge(user_id=user6.id, badge_id="past_volunteer"))
1624 update_badges(empty_pb2.Empty())
1625 process_jobs()
1627 with session_scope() as session:
1628 # Check user3 has volunteer badge
1629 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1630 assert "volunteer" in user3_badges
1631 assert "past_volunteer" not in user3_badges
1633 # Check user4 has past_volunteer badge
1634 user4_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user4.id)).scalars().all()
1635 assert "past_volunteer" in user4_badges
1636 assert "volunteer" not in user4_badges
1638 # Check user5 lost the volunteer badge (not in Volunteer table)
1639 user5_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user5.id)).scalars().all()
1640 assert "volunteer" not in user5_badges
1642 # Check user6 lost the past_volunteer badge (not in Volunteer table)
1643 user6_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user6.id)).scalars().all()
1644 assert "past_volunteer" not in user6_badges
1646 # Check notifications for volunteer badge users
1647 push = push_collector.pop_for_user(user3.id, last=True)
1648 assert push.content.title == "New profile badge: Active Volunteer"
1649 assert push.content.body == "The Active Volunteer badge was added to your profile."
1651 push = push_collector.pop_for_user(user4.id, last=True)
1652 assert push.content.title == "New profile badge: Past Volunteer"
1653 assert push.content.body == "The Past Volunteer badge was added to your profile."
1655 push = push_collector.pop_for_user(user5.id, last=True)
1656 assert push.content.title == "Profile badge removed"
1657 assert push.content.body == "The Active Volunteer badge was removed from your profile."
1659 push = push_collector.pop_for_user(user6.id, last=True)
1660 assert push.content.title == "Profile badge removed"
1661 assert push.content.body == "The Past Volunteer badge was removed from your profile."
1664def test_update_badges_volunteer_status_change(db, push_collector: PushCollector):
1665 """Test that badge is updated when volunteer status changes from active to past."""
1666 # Create users - users 1 and 2 get founder/board_member badges from static_badges
1667 user1, _ = generate_user(last_donated=None)
1668 user2, _ = generate_user(last_donated=None)
1669 user3, _ = generate_user(last_donated=None)
1671 with session_scope() as session:
1672 # user3: start as active volunteer
1673 session.add(
1674 make_volunteer(
1675 user_id=user3.id,
1676 role="Developer",
1677 started_volunteering=date(2020, 1, 1),
1678 stopped_volunteering=None,
1679 show_on_team_page=True,
1680 )
1681 )
1683 update_badges(empty_pb2.Empty())
1684 process_jobs()
1686 with session_scope() as session:
1687 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1688 assert "volunteer" in user3_badges
1689 assert "past_volunteer" not in user3_badges
1691 push = push_collector.pop_for_user(user3.id, last=True)
1692 assert push.content.title == "New profile badge: Active Volunteer"
1693 assert push.content.body == "The Active Volunteer badge was added to your profile."
1695 # Now change the volunteer to past volunteer
1696 with session_scope() as session:
1697 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == user3.id)).scalar_one()
1698 volunteer.stopped_volunteering = date(2023, 12, 1)
1700 update_badges(empty_pb2.Empty())
1701 process_jobs()
1703 with session_scope() as session:
1704 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1705 assert "volunteer" not in user3_badges
1706 assert "past_volunteer" in user3_badges
1708 # Check both badges were updated
1709 push = push_collector.pop_for_user(user3.id, last=False)
1710 assert push.content.title == "Profile badge removed"
1711 assert push.content.body == "The Active Volunteer badge was removed from your profile."
1713 push = push_collector.pop_for_user(user3.id, last=True)
1714 assert push.content.title == "New profile badge: Past Volunteer"
1715 assert push.content.body == "The Past Volunteer badge was added to your profile."
1718def test_send_message_notifications_empty_unseen_simple(monkeypatch):
1719 class DummyUser:
1720 id = 1
1721 is_visible = True
1722 last_notified_message_id = 0
1724 class FirstResult:
1725 def scalars(self):
1726 return self
1728 def unique(self):
1729 return [DummyUser()]
1731 class SecondResult:
1732 def all(self):
1733 return []
1735 class DummySession:
1736 def __init__(self):
1737 self.calls = 0
1739 def execute(self, *a, **k):
1740 self.calls += 1
1741 return FirstResult() if self.calls == 1 else SecondResult()
1743 def commit(self):
1744 pass
1746 def flush(self):
1747 pass
1749 def fake_session_scope():
1750 class Ctx:
1751 def __enter__(self):
1752 return DummySession()
1754 def __exit__(self, exc_type, exc, tb):
1755 pass
1757 return Ctx()
1759 monkeypatch.setattr(handlers, "session_scope", fake_session_scope)
1761 handlers.send_message_notifications(Empty())