Coverage for app / backend / src / tests / test_bg_jobs.py: 99%
717 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
1from datetime import date, datetime, timedelta
2from typing import Any
3from unittest.mock import call, patch
5import pytest
6import requests
7from google.protobuf import empty_pb2
8from google.protobuf.empty_pb2 import Empty
9from sqlalchemy import select
10from sqlalchemy.sql import delete, func
12import couchers.jobs.worker
13from couchers.config import config
14from couchers.constants import HOST_REQUEST_MAX_REMINDERS, HOST_REQUEST_REMINDER_INTERVAL
15from couchers.crypto import urlsafe_secure_token
16from couchers.db import session_scope
17from couchers.email.dev import print_dev_email
18from couchers.email.queuing import queue_email
19from couchers.jobs import handlers
20from couchers.jobs.definitions import Job
21from couchers.jobs.enqueue import queue_job
22from couchers.jobs.handlers import (
23 add_users_to_email_list,
24 enforce_community_membership,
25 purge_account_deletion_tokens,
26 purge_login_tokens,
27 purge_password_reset_tokens,
28 send_host_request_reminders,
29 send_message_notifications,
30 send_onboarding_emails,
31 send_reference_reminders,
32 send_request_notifications,
33 update_badges,
34 update_recommendation_scores,
35)
36from couchers.jobs.worker import _run_job_and_schedule, process_job, run_scheduler, service_jobs
37from couchers.materialized_views import refresh_materialized_views
38from couchers.metrics import create_prometheus_server
39from couchers.models import (
40 AccountDeletionToken,
41 BackgroundJob,
42 BackgroundJobState,
43 Email,
44 HostRequest,
45 HostRequestStatus,
46 LoginToken,
47 Message,
48 MessageType,
49 PasswordResetToken,
50 User,
51 UserBadge,
52 UserBlock,
53 Volunteer,
54)
55from couchers.proto import conversations_pb2, requests_pb2
56from couchers.utils import now, today
57from tests.fixtures.db import generate_user, make_friends, make_user_block, make_volunteer
58from tests.fixtures.misc import PushCollector, process_jobs
59from tests.fixtures.sessions import conversations_session, requests_session
60from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date
61from tests.test_requests import valid_request_text
64def now_5_min_in_future() -> datetime:
65 return now() + timedelta(minutes=5)
68@pytest.fixture(autouse=True)
69def _(testconfig):
70 pass
73def _check_job_counter(job, status, attempt, exception):
74 metrics_string = requests.get("http://localhost:8000").text
75 string_to_check = f'attempt="{attempt}",exception="{exception}",job="{job}",status="{status}"'
76 assert string_to_check in metrics_string
79def test_email_job(db):
80 with session_scope() as session:
81 queue_email(session, "sender_name", "sender_email", "recipient", "subject", "plain", "html")
83 def mock_print_dev_email(
84 sender_name, sender_email, recipient, subject, plain, html, list_unsubscribe_header, source_data
85 ):
86 assert sender_name == "sender_name"
87 assert sender_email == "sender_email"
88 assert recipient == "recipient"
89 assert subject == "subject"
90 assert plain == "plain"
91 assert html == "html"
92 return print_dev_email(
93 sender_name, sender_email, recipient, subject, plain, html, list_unsubscribe_header, source_data
94 )
96 with patch("couchers.jobs.handlers.print_dev_email", mock_print_dev_email):
97 process_job()
99 with session_scope() as session:
100 assert (
101 session.execute(
102 select(func.count())
103 .select_from(BackgroundJob)
104 .where(BackgroundJob.state == BackgroundJobState.completed)
105 ).scalar_one()
106 == 1
107 )
108 assert (
109 session.execute(
110 select(func.count())
111 .select_from(BackgroundJob)
112 .where(BackgroundJob.state != BackgroundJobState.completed)
113 ).scalar_one()
114 == 0
115 )
118def test_purge_login_tokens(db):
119 user, api_token = generate_user()
121 with session_scope() as session:
122 login_token = LoginToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now())
123 session.add(login_token)
124 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 1
126 queue_job(session, job=purge_login_tokens, payload=empty_pb2.Empty())
127 process_job()
129 with session_scope() as session:
130 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 0
132 with session_scope() as session:
133 assert (
134 session.execute(
135 select(func.count())
136 .select_from(BackgroundJob)
137 .where(BackgroundJob.state == BackgroundJobState.completed)
138 ).scalar_one()
139 == 1
140 )
141 assert (
142 session.execute(
143 select(func.count())
144 .select_from(BackgroundJob)
145 .where(BackgroundJob.state != BackgroundJobState.completed)
146 ).scalar_one()
147 == 0
148 )
151def test_purge_password_reset_tokens(db):
152 user, api_token = generate_user()
154 with session_scope() as session:
155 password_reset_token = PasswordResetToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now())
156 session.add(password_reset_token)
157 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 1
159 queue_job(session, job=purge_password_reset_tokens, payload=empty_pb2.Empty())
160 process_job()
162 with session_scope() as session:
163 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 0
165 with session_scope() as session:
166 assert (
167 session.execute(
168 select(func.count())
169 .select_from(BackgroundJob)
170 .where(BackgroundJob.state == BackgroundJobState.completed)
171 ).scalar_one()
172 == 1
173 )
174 assert (
175 session.execute(
176 select(func.count())
177 .select_from(BackgroundJob)
178 .where(BackgroundJob.state != BackgroundJobState.completed)
179 ).scalar_one()
180 == 0
181 )
184def test_purge_account_deletion_tokens(db):
185 user, api_token = generate_user()
186 user2, api_token2 = generate_user()
187 user3, api_token3 = generate_user()
189 with session_scope() as session:
190 """
191 3 cases:
192 1) Token is valid
193 2) Token expired but account retrievable
194 3) Account is irretrievable (and expired)
195 """
196 account_deletion_tokens = [
197 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() - timedelta(hours=2)),
198 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user2.id, expiry=now()),
199 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user3.id, expiry=now() + timedelta(hours=5)),
200 ]
201 for token in account_deletion_tokens:
202 session.add(token)
203 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 3
205 queue_job(session, job=purge_account_deletion_tokens, payload=empty_pb2.Empty())
206 process_job()
208 with session_scope() as session:
209 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 1
211 with session_scope() as session:
212 assert (
213 session.execute(
214 select(func.count())
215 .select_from(BackgroundJob)
216 .where(BackgroundJob.state == BackgroundJobState.completed)
217 ).scalar_one()
218 == 1
219 )
220 assert (
221 session.execute(
222 select(func.count())
223 .select_from(BackgroundJob)
224 .where(BackgroundJob.state != BackgroundJobState.completed)
225 ).scalar_one()
226 == 0
227 )
230def test_enforce_community_memberships(db):
231 with session_scope() as session:
232 queue_job(session, job=enforce_community_membership, payload=empty_pb2.Empty())
233 process_job()
235 with session_scope() as session:
236 assert (
237 session.execute(
238 select(func.count())
239 .select_from(BackgroundJob)
240 .where(BackgroundJob.state == BackgroundJobState.completed)
241 ).scalar_one()
242 == 1
243 )
244 assert (
245 session.execute(
246 select(func.count())
247 .select_from(BackgroundJob)
248 .where(BackgroundJob.state != BackgroundJobState.completed)
249 ).scalar_one()
250 == 0
251 )
254def test_refresh_materialized_views(db):
255 with session_scope() as session:
256 queue_job(session, job=refresh_materialized_views, payload=empty_pb2.Empty())
258 process_job()
260 with session_scope() as session:
261 assert (
262 session.execute(
263 select(func.count())
264 .select_from(BackgroundJob)
265 .where(BackgroundJob.state == BackgroundJobState.completed)
266 ).scalar_one()
267 == 1
268 )
269 assert (
270 session.execute(
271 select(func.count())
272 .select_from(BackgroundJob)
273 .where(BackgroundJob.state != BackgroundJobState.completed)
274 ).scalar_one()
275 == 0
276 )
279def test_service_jobs(db):
280 with session_scope() as session:
281 queue_email(session, "sender_name", "sender_email", "recipient", "subject", "plain", "html")
283 # we create this HitSleep exception here, and mock out the normal sleep(1) in the infinite loop to instead raise
284 # this. that allows us to conveniently get out of the infinite loop and know we had no more jobs left
285 class HitSleep(Exception):
286 pass
288 # the mock `sleep` function that instead raises the aforementioned exception
289 def raising_sleep(seconds):
290 raise HitSleep()
292 with pytest.raises(HitSleep):
293 with patch("couchers.jobs.worker.sleep", raising_sleep):
294 service_jobs()
296 with session_scope() as session:
297 assert (
298 session.execute(
299 select(func.count())
300 .select_from(BackgroundJob)
301 .where(BackgroundJob.state == BackgroundJobState.completed)
302 ).scalar_one()
303 == 1
304 )
305 assert (
306 session.execute(
307 select(func.count())
308 .select_from(BackgroundJob)
309 .where(BackgroundJob.state != BackgroundJobState.completed)
310 ).scalar_one()
311 == 0
312 )
315def test_scheduler(db, monkeypatch):
316 def purge_login_tokens(payload: empty_pb2.Empty):
317 return
319 def send_message_notifications(payload: empty_pb2.Empty):
320 return
322 MOCK_JOBS = {
323 "purge_login_tokens": Job(purge_login_tokens, timedelta(seconds=7)),
324 "send_message_notifications": Job(send_message_notifications, timedelta(seconds=11)),
325 }
327 current_time = 0
328 end_time = 70
330 class EndOfTime(Exception):
331 pass
333 def mock_monotonic():
334 return current_time
336 def mock_sleep(seconds):
337 nonlocal current_time
338 current_time += seconds
339 if current_time > end_time:
340 raise EndOfTime()
342 realized_schedule = []
344 def mock_run_job_and_schedule(sched, job: Job[Any], frequency: timedelta) -> None:
345 realized_schedule.append((current_time, job.name))
346 _run_job_and_schedule(sched, job, frequency)
348 monkeypatch.setattr(couchers.jobs.worker, "_run_job_and_schedule", mock_run_job_and_schedule)
349 monkeypatch.setattr(couchers.jobs.worker, "JOBS", MOCK_JOBS)
350 monkeypatch.setattr(couchers.jobs.worker, "monotonic", mock_monotonic)
351 monkeypatch.setattr(couchers.jobs.worker, "sleep", mock_sleep)
353 with pytest.raises(EndOfTime):
354 run_scheduler()
356 # Convert to job indices for comparison (to maintain test compatibility)
357 job_order = ["purge_login_tokens", "send_message_notifications"]
358 realized_schedule_indices = [(time, job_order.index(job_name)) for time, job_name in realized_schedule]
360 assert realized_schedule_indices == [
361 (0.0, 0),
362 (0.0, 1),
363 (7.0, 0),
364 (11.0, 1),
365 (14.0, 0),
366 (21.0, 0),
367 (22.0, 1),
368 (28.0, 0),
369 (33.0, 1),
370 (35.0, 0),
371 (42.0, 0),
372 (44.0, 1),
373 (49.0, 0),
374 (55.0, 1),
375 (56.0, 0),
376 (63.0, 0),
377 (66.0, 1),
378 (70.0, 0),
379 ]
381 with session_scope() as session:
382 assert (
383 session.execute(
384 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.pending)
385 ).scalar_one()
386 == 18
387 )
388 assert (
389 session.execute(
390 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.pending)
391 ).scalar_one()
392 == 0
393 )
396def test_job_retry(db):
397 called_count = 0
399 def mock_job(payload: empty_pb2.Empty) -> None:
400 nonlocal called_count
401 called_count += 1
402 raise Exception()
404 with session_scope() as session:
405 queue_job(session, job=mock_job, payload=empty_pb2.Empty())
407 MOCK_JOBS: dict[str, Job[Any]] = {
408 "mock_job": Job(mock_job),
409 }
410 create_prometheus_server(port=8000)
412 # if IN_TEST is true, then the bg worker will raise on exceptions
413 new_config = config.copy()
414 new_config["IN_TEST"] = False
416 with patch("couchers.jobs.worker.config", new_config), patch("couchers.jobs.worker.JOBS", MOCK_JOBS):
417 process_job()
418 with session_scope() as session:
419 assert (
420 session.execute(
421 select(func.count())
422 .select_from(BackgroundJob)
423 .where(BackgroundJob.state == BackgroundJobState.error)
424 ).scalar_one()
425 == 1
426 )
427 assert (
428 session.execute(
429 select(func.count())
430 .select_from(BackgroundJob)
431 .where(BackgroundJob.state != BackgroundJobState.error)
432 ).scalar_one()
433 == 0
434 )
436 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
437 process_job()
438 with session_scope() as session:
439 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
440 process_job()
441 with session_scope() as session:
442 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
443 process_job()
444 with session_scope() as session:
445 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
446 process_job()
448 with session_scope() as session:
449 assert (
450 session.execute(
451 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.failed)
452 ).scalar_one()
453 == 1
454 )
455 assert (
456 session.execute(
457 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.failed)
458 ).scalar_one()
459 == 0
460 )
462 _check_job_counter("mock_job", "error", "4", "Exception")
463 _check_job_counter("mock_job", "failed", "5", "Exception")
466def test_no_jobs_no_problem(db):
467 with session_scope() as session:
468 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
470 assert not process_job()
472 with session_scope() as session:
473 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
476def test_send_message_notifications_basic(db, moderator):
477 user1, token1 = generate_user()
478 user2, token2 = generate_user()
479 user3, token3 = generate_user()
481 make_friends(user1, user2)
482 make_friends(user1, user3)
483 make_friends(user2, user3)
485 send_message_notifications(empty_pb2.Empty())
486 process_jobs()
488 # should find no jobs, since there's no messages
489 with session_scope() as session:
490 assert (
491 session.execute(
492 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
493 ).scalar_one()
494 == 0
495 )
497 with conversations_session(token1) as c:
498 group_chat_id1 = c.CreateGroupChat(
499 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id])
500 ).group_chat_id
501 moderator.approve_group_chat(group_chat_id1)
503 with conversations_session(token1) as c:
504 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 1"))
505 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 2"))
506 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 3"))
507 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 4"))
509 with conversations_session(token3) as c:
510 group_chat_id2 = c.CreateGroupChat(
511 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
512 ).group_chat_id
513 moderator.approve_group_chat(group_chat_id2)
515 with conversations_session(token3) as c:
516 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 5"))
517 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 6"))
519 send_message_notifications(empty_pb2.Empty())
520 process_jobs()
522 # no emails sent out
523 with session_scope() as session:
524 assert (
525 session.execute(
526 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
527 ).scalar_one()
528 == 0
529 )
531 # this should generate emails for both user2 and user3
532 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
533 send_message_notifications(empty_pb2.Empty())
534 process_jobs()
536 with session_scope() as session:
537 assert (
538 session.execute(
539 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
540 ).scalar_one()
541 == 2
542 )
543 # delete them all
544 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
546 # shouldn't generate any more emails
547 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
548 send_message_notifications(empty_pb2.Empty())
549 process_jobs()
551 with session_scope() as session:
552 assert (
553 session.execute(
554 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
555 ).scalar_one()
556 == 0
557 )
560def test_send_message_notifications_muted(db, moderator):
561 user1, token1 = generate_user()
562 user2, token2 = generate_user()
563 user3, token3 = generate_user()
565 make_friends(user1, user2)
566 make_friends(user1, user3)
567 make_friends(user2, user3)
569 send_message_notifications(empty_pb2.Empty())
570 process_jobs()
572 # should find no jobs, since there's no messages
573 with session_scope() as session:
574 assert (
575 session.execute(
576 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
577 ).scalar_one()
578 == 0
579 )
581 with conversations_session(token1) as c:
582 group_chat_id = c.CreateGroupChat(
583 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id])
584 ).group_chat_id
585 moderator.approve_group_chat(group_chat_id)
587 with conversations_session(token3) as c:
588 # mute it for user 3
589 c.MuteGroupChat(conversations_pb2.MuteGroupChatReq(group_chat_id=group_chat_id, forever=True))
591 with conversations_session(token1) as c:
592 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
593 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
594 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3"))
595 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4"))
597 with conversations_session(token3) as c:
598 group_chat_id = c.CreateGroupChat(
599 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
600 ).group_chat_id
601 moderator.approve_group_chat(group_chat_id)
602 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 5"))
603 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 6"))
605 send_message_notifications(empty_pb2.Empty())
606 process_jobs()
608 # no emails sent out
609 with session_scope() as session:
610 assert (
611 session.execute(
612 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
613 ).scalar_one()
614 == 0
615 )
617 # this should generate emails for both user2 and NOT user3
618 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
619 send_message_notifications(empty_pb2.Empty())
620 process_jobs()
622 with session_scope() as session:
623 assert (
624 session.execute(
625 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
626 ).scalar_one()
627 == 1
628 )
629 # delete them all
630 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
632 # shouldn't generate any more emails
633 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
634 send_message_notifications(empty_pb2.Empty())
635 process_jobs()
637 with session_scope() as session:
638 assert (
639 session.execute(
640 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
641 ).scalar_one()
642 == 0
643 )
646def test_send_request_notifications_host_request(db, moderator):
647 user1, token1 = generate_user()
648 user2, token2 = generate_user()
650 today_plus_2 = (today() + timedelta(days=2)).isoformat()
651 today_plus_3 = (today() + timedelta(days=3)).isoformat()
653 send_request_notifications(empty_pb2.Empty())
654 process_jobs()
656 # should find no jobs, since there's no messages
657 with session_scope() as session:
658 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
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 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
671 # the only unseen message is the creation message, which the host was already
672 # notified about via host_request__create — no missed_messages email
673 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
674 send_request_notifications(empty_pb2.Empty())
675 process_jobs()
676 assert (
677 session.execute(
678 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
679 ).scalar_one()
680 == 0
681 )
683 # test that responding to host request creates email
684 with requests_session(token2) as requests:
685 requests.RespondHostRequest(
686 requests_pb2.RespondHostRequestReq(
687 host_request_id=host_request_id,
688 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
689 text="Test request",
690 )
691 )
693 with session_scope() as session:
694 # delete send_email BackgroundJob created by RespondHostRequest
695 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
697 # check send_request_notifications successfully creates background job
698 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
699 send_request_notifications(empty_pb2.Empty())
700 process_jobs()
701 assert (
702 session.execute(
703 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
704 ).scalar_one()
705 == 1
706 )
708 # delete all BackgroundJobs
709 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
711 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
712 send_request_notifications(empty_pb2.Empty())
713 process_jobs()
714 # should find no messages since guest has already been notified
715 assert (
716 session.execute(
717 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
718 ).scalar_one()
719 == 0
720 )
723def test_send_request_notifications_host_request_with_followup(db, moderator):
724 """
725 When the surfer sends a follow-up message after creating the host request,
726 the host should get a missed_messages notification (even though the initial
727 creation message alone would be skipped).
728 """
729 user1, token1 = generate_user()
730 user2, token2 = generate_user()
732 today_plus_2 = (today() + timedelta(days=2)).isoformat()
733 today_plus_3 = (today() + timedelta(days=3)).isoformat()
735 with requests_session(token1) as requests:
736 host_request_id = requests.CreateHostRequest(
737 requests_pb2.CreateHostRequestReq(
738 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
739 )
740 ).host_request_id
741 moderator.approve_host_request(host_request_id)
743 # surfer sends a follow-up message
744 with requests_session(token1) as requests:
745 requests.SendHostRequestMessage(
746 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_id, text="Following up on my request!")
747 )
749 with session_scope() as session:
750 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
752 # now there are two unseen text messages for the host, so missed_messages should fire
753 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
754 send_request_notifications(empty_pb2.Empty())
755 process_jobs()
756 assert (
757 session.execute(
758 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
759 ).scalar_one()
760 == 1
761 )
764def test_send_request_notifications_two_requests_one_with_followup(db, moderator):
765 """
766 A host (user2) receives two requests: first from user1 (with a follow-up message),
767 then from user3 (creation only). Because request B is created after request A's
768 follow-up, it has a higher message ID. If the background job processes B first and
769 advances last_notified_request_message_id past A's messages, one might expect A's
770 notification to be lost — but it isn't, because the query results are already
771 materialized before the loop begins.
772 """
773 user1, token1 = generate_user()
774 user2, token2 = generate_user()
775 user3, token3 = generate_user()
777 today_plus_2 = (today() + timedelta(days=2)).isoformat()
778 today_plus_3 = (today() + timedelta(days=3)).isoformat()
780 # request A: user1 -> user2, with a follow-up
781 with requests_session(token1) as requests:
782 host_request_a = requests.CreateHostRequest(
783 requests_pb2.CreateHostRequestReq(
784 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
785 )
786 ).host_request_id
787 moderator.approve_host_request(host_request_a)
789 with requests_session(token1) as requests:
790 requests.SendHostRequestMessage(
791 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_a, text="Sorry, meant Tuesday night!")
792 )
794 # request B: user3 -> user2, creation only (higher message IDs than A's follow-up)
795 with requests_session(token3) as requests:
796 host_request_b = requests.CreateHostRequest(
797 requests_pb2.CreateHostRequestReq(
798 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
799 )
800 ).host_request_id
801 moderator.approve_host_request(host_request_b)
803 with session_scope() as session:
804 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
806 # should get exactly 1 missed_messages email: for request A (has follow-up),
807 # not request B (creation only, skipped)
808 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
809 send_request_notifications(empty_pb2.Empty())
810 process_jobs()
811 assert (
812 session.execute(
813 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
814 ).scalar_one()
815 == 1
816 )
819def test_send_message_notifications_seen(db, moderator):
820 user1, token1 = generate_user()
821 user2, token2 = generate_user()
823 make_friends(user1, user2)
825 send_message_notifications(empty_pb2.Empty())
827 # should find no jobs, since there's no messages
828 with session_scope() as session:
829 assert (
830 session.execute(
831 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
832 ).scalar_one()
833 == 0
834 )
836 with conversations_session(token1) as c:
837 group_chat_id = c.CreateGroupChat(
838 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
839 ).group_chat_id
840 moderator.approve_group_chat(group_chat_id)
841 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
842 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
843 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3"))
844 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4"))
846 # user 2 now marks those messages as seen
847 with conversations_session(token2) as c:
848 m_id = c.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id)).latest_message.message_id
849 c.MarkLastSeenGroupChat(
850 conversations_pb2.MarkLastSeenGroupChatReq(group_chat_id=group_chat_id, last_seen_message_id=m_id)
851 )
853 send_message_notifications(empty_pb2.Empty())
855 # no emails sent out
856 with session_scope() as session:
857 assert (
858 session.execute(
859 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
860 ).scalar_one()
861 == 0
862 )
864 def now_30_min_in_future():
865 return now() + timedelta(minutes=30)
867 # still shouldn't generate emails as user2 has seen all messages
868 with patch("couchers.jobs.handlers.now", now_30_min_in_future):
869 send_message_notifications(empty_pb2.Empty())
871 with session_scope() as session:
872 assert (
873 session.execute(
874 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
875 ).scalar_one()
876 == 0
877 )
880def test_send_onboarding_emails(db):
881 # needs to get first onboarding email
882 user1, token1 = generate_user(onboarding_emails_sent=0, last_onboarding_email_sent=None, complete_profile=False)
884 send_onboarding_emails(empty_pb2.Empty())
885 process_jobs()
887 with session_scope() as session:
888 assert (
889 session.execute(
890 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
891 ).scalar_one()
892 == 1
893 )
895 # needs to get second onboarding email, but not yet
896 user2, token2 = generate_user(
897 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=6), complete_profile=False
898 )
900 send_onboarding_emails(empty_pb2.Empty())
901 process_jobs()
903 with session_scope() as session:
904 assert (
905 session.execute(
906 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
907 ).scalar_one()
908 == 1
909 )
911 # needs to get second onboarding email
912 user3, token3 = generate_user(
913 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=8), complete_profile=False
914 )
916 send_onboarding_emails(empty_pb2.Empty())
917 process_jobs()
919 with session_scope() as session:
920 assert (
921 session.execute(
922 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
923 ).scalar_one()
924 == 2
925 )
928def test_send_reference_reminders(db):
929 # need to test:
930 # case 1: bidirectional (no emails)
931 # case 2: host left ref (surfer needs an email)
932 # case 3: surfer left ref (host needs an email)
933 # case 4: neither left ref (host & surfer need an email)
934 # case 5: neither left ref, but host blocked surfer, so neither should get an email
935 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
937 send_reference_reminders(empty_pb2.Empty())
939 # case 1: bidirectional (no emails)
940 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
941 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
943 # case 2: host left ref (surfer needs an email)
944 # host
945 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
946 # surfer
947 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
949 # case 3: surfer left ref (host needs an email)
950 # host
951 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
952 # surfer
953 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
955 # case 4: neither left ref (host & surfer need an email)
956 # surfer
957 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
958 # host
959 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
961 # case 5: neither left ref, but host blocked surfer, so neither should get an email
962 # surfer
963 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
964 # host
965 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
967 make_user_block(user9, user10)
969 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
970 # host
971 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
972 # surfer
973 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
975 with session_scope() as session:
976 # note that create_host_reference creates a host request whose age is one day older than the timedelta here
978 # case 1: bidirectional (no emails)
979 ref1, hr1 = create_host_reference(session, user2.id, user1.id, timedelta(days=7), surfing=True)
980 create_host_reference(session, user1.id, user2.id, timedelta(days=7), host_request_id=hr1)
982 # case 2: host left ref (surfer needs an email)
983 ref2, hr2 = create_host_reference(session, user3.id, user4.id, timedelta(days=11), surfing=False)
985 # case 3: surfer left ref (host needs an email)
986 ref3, hr3 = create_host_reference(session, user6.id, user5.id, timedelta(days=9), surfing=True)
988 # case 4: neither left ref (host & surfer need an email)
989 hr4 = create_host_request(session, user7.id, user8.id, timedelta(days=4))
991 # case 5: neither left ref, but host blocked surfer, so neither should get an email
992 hr5 = create_host_request(session, user9.id, user10.id, timedelta(days=7))
994 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
995 hr6 = create_host_request(session, user12.id, user11.id, timedelta(days=6), surfer_reason_didnt_meetup="")
997 expected_emails = [
998 (
999 "user11@couchers.org.invalid",
1000 "[TEST] You have 14 days to write a reference for User 12!",
1001 ("from when you hosted them", "/leave-reference/hosted/"),
1002 ),
1003 (
1004 "user4@couchers.org.invalid",
1005 "[TEST] You have 3 days to write a reference for User 3!",
1006 ("from when you surfed with them", "/leave-reference/surfed/"),
1007 ),
1008 (
1009 "user5@couchers.org.invalid",
1010 "[TEST] You have 7 days to write a reference for User 6!",
1011 ("from when you hosted them", "/leave-reference/hosted/"),
1012 ),
1013 (
1014 "user7@couchers.org.invalid",
1015 "[TEST] You have 14 days to write a reference for User 8!",
1016 ("from when you surfed with them", "/leave-reference/surfed/"),
1017 ),
1018 (
1019 "user8@couchers.org.invalid",
1020 "[TEST] You have 14 days to write a reference for User 7!",
1021 ("from when you hosted them", "/leave-reference/hosted/"),
1022 ),
1023 ]
1025 send_reference_reminders(empty_pb2.Empty())
1027 while process_job():
1028 pass
1030 with session_scope() as session:
1031 emails = [
1032 (email.recipient, email.subject, email.plain, email.html)
1033 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
1034 ]
1036 actual_addresses_and_subjects = [email[:2] for email in emails]
1037 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1039 print(actual_addresses_and_subjects)
1040 print(expected_addresses_and_subjects)
1042 assert actual_addresses_and_subjects == expected_addresses_and_subjects
1044 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
1045 for find in search_strings:
1046 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
1047 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
1050def test_send_host_request_reminders(db, moderator):
1051 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1052 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1053 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
1054 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
1055 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
1056 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
1057 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
1058 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
1059 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
1060 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
1061 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
1062 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
1063 user13, token13 = generate_user(email="user13@couchers.org.invalid", name="User 13")
1064 user14, token14 = generate_user(email="user14@couchers.org.invalid", name="User 14")
1066 with session_scope() as session:
1067 # case 1: pending, future, interval elapsed => notify
1068 hr1 = create_host_request_by_date(
1069 session=session,
1070 surfer_user_id=user1.id,
1071 host_user_id=user2.id,
1072 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1073 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1074 status=HostRequestStatus.pending,
1075 host_sent_request_reminders=0,
1076 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1077 )
1079 # case 2: max reminders reached => do not notify
1080 hr2 = create_host_request_by_date(
1081 session=session,
1082 surfer_user_id=user3.id,
1083 host_user_id=user4.id,
1084 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1085 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1086 status=HostRequestStatus.pending,
1087 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS,
1088 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1089 )
1091 # case 3: interval not yet elapsed => do not notify
1092 hr3 = create_host_request_by_date(
1093 session=session,
1094 surfer_user_id=user5.id,
1095 host_user_id=user6.id,
1096 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1097 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1098 status=HostRequestStatus.pending,
1099 host_sent_request_reminders=0,
1100 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL + timedelta(hours=1),
1101 )
1103 # case 4: start date is today => do not notify
1104 hr4 = create_host_request_by_date(
1105 session=session,
1106 surfer_user_id=user7.id,
1107 host_user_id=user8.id,
1108 from_date=today(),
1109 to_date=today() + timedelta(days=2),
1110 status=HostRequestStatus.pending,
1111 host_sent_request_reminders=0,
1112 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1113 )
1115 # case 5: from_date in the past => do not notify
1116 hr5 = create_host_request_by_date(
1117 session=session,
1118 surfer_user_id=user9.id,
1119 host_user_id=user10.id,
1120 from_date=today() - timedelta(days=1),
1121 to_date=today() + timedelta(days=1),
1122 status=HostRequestStatus.pending,
1123 host_sent_request_reminders=0,
1124 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1125 )
1127 # case 6: non-pending status => do not notify
1128 hr6 = create_host_request_by_date(
1129 session=session,
1130 surfer_user_id=user11.id,
1131 host_user_id=user12.id,
1132 from_date=today() + timedelta(days=3),
1133 to_date=today() + timedelta(days=4),
1134 status=HostRequestStatus.accepted,
1135 host_sent_request_reminders=0,
1136 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1137 )
1139 # case 7: host already sent a message => do not notify
1140 hr7 = create_host_request_by_date(
1141 session=session,
1142 surfer_user_id=user13.id,
1143 host_user_id=user14.id,
1144 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1145 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1146 status=HostRequestStatus.pending,
1147 host_sent_request_reminders=0,
1148 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1149 )
1151 msg = Message(
1152 conversation_id=hr7,
1153 author_id=user14.id,
1154 text="Looking forward to hosting you!",
1155 message_type=MessageType.text,
1156 )
1157 msg.time = now()
1158 session.add(msg)
1160 # Approve host requests so they're visible for notifications
1161 moderator.approve_host_request(hr1)
1162 moderator.approve_host_request(hr2)
1163 moderator.approve_host_request(hr3)
1164 moderator.approve_host_request(hr4)
1165 moderator.approve_host_request(hr5)
1166 moderator.approve_host_request(hr6)
1167 moderator.approve_host_request(hr7)
1169 send_host_request_reminders(empty_pb2.Empty())
1171 while process_job():
1172 pass
1174 with session_scope() as session:
1175 emails = [
1176 (email.recipient, email.subject, email.plain, email.html)
1177 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
1178 ]
1180 expected_emails = [
1181 (
1182 "user2@couchers.org.invalid",
1183 "[TEST] You have a pending host request from User 1!",
1184 ("Please respond to the request!", "User 1"),
1185 )
1186 ]
1188 actual_addresses_and_subjects = [email[:2] for email in emails]
1189 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1191 print(actual_addresses_and_subjects)
1192 print(expected_addresses_and_subjects)
1194 assert actual_addresses_and_subjects == expected_addresses_and_subjects
1196 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
1197 for find in search_strings:
1198 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
1199 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
1202def test_add_users_to_email_list(db):
1203 new_config = config.copy()
1204 new_config["LISTMONK_ENABLED"] = True
1205 new_config["LISTMONK_BASE_URL"] = "https://example.com"
1206 new_config["LISTMONK_API_USERNAME"] = "test_user"
1207 new_config["LISTMONK_API_KEY"] = "dummy_api_key"
1208 new_config["LISTMONK_LIST_ID"] = 6
1210 with patch("couchers.jobs.handlers.config", new_config):
1211 with patch("couchers.jobs.handlers.requests.post") as mock:
1212 add_users_to_email_list(empty_pb2.Empty())
1213 mock.assert_not_called()
1215 generate_user(in_sync_with_newsletter=False, email="testing1@couchers.invalid", name="Tester1", id=15)
1216 generate_user(in_sync_with_newsletter=True, email="testing2@couchers.invalid", name="Tester2")
1217 generate_user(in_sync_with_newsletter=False, email="testing3@couchers.invalid", name="Tester3 von test", id=17)
1218 generate_user(
1219 in_sync_with_newsletter=False, email="testing4@couchers.invalid", name="Tester4", opt_out_of_newsletter=True
1220 )
1222 with patch("couchers.jobs.handlers.requests.post") as mock:
1223 ret = mock.return_value
1224 ret.status_code = 200
1225 add_users_to_email_list(empty_pb2.Empty())
1226 mock.assert_has_calls(
1227 [
1228 call(
1229 "https://example.com/api/subscribers",
1230 auth=("test_user", "dummy_api_key"),
1231 json={
1232 "email": "testing1@couchers.invalid",
1233 "name": "Tester1",
1234 "lists": [6],
1235 "preconfirm_subscriptions": True,
1236 "attribs": {"couchers_user_id": 15},
1237 "status": "enabled",
1238 },
1239 timeout=10,
1240 ),
1241 call(
1242 "https://example.com/api/subscribers",
1243 auth=("test_user", "dummy_api_key"),
1244 json={
1245 "email": "testing3@couchers.invalid",
1246 "name": "Tester3 von test",
1247 "lists": [6],
1248 "preconfirm_subscriptions": True,
1249 "attribs": {"couchers_user_id": 17},
1250 "status": "enabled",
1251 },
1252 timeout=10,
1253 ),
1254 ],
1255 any_order=True,
1256 )
1258 with patch("couchers.jobs.handlers.requests.post") as mock:
1259 add_users_to_email_list(empty_pb2.Empty())
1260 mock.assert_not_called()
1263def test_update_recommendation_scores(db):
1264 update_recommendation_scores(empty_pb2.Empty())
1267def test_update_badges(db, push_collector: PushCollector):
1268 user1, _ = generate_user(last_donated=None)
1269 user2, _ = generate_user(last_donated=None)
1270 user3, _ = generate_user(last_donated=None)
1271 user4, _ = generate_user(phone="+15555555555", phone_verification_verified=func.now(), last_donated=None)
1272 user5, _ = generate_user(phone="+15555555556", phone_verification_verified=func.now(), last_donated=None)
1273 user6, _ = generate_user(last_donated=None)
1275 with session_scope() as session:
1276 session.add(UserBadge(user_id=user5.id, badge_id="board_member"))
1278 update_badges(empty_pb2.Empty())
1279 process_jobs()
1281 with session_scope() as session:
1282 badge_tuples = session.execute(
1283 select(UserBadge.user_id, UserBadge.badge_id).order_by(UserBadge.user_id.asc(), UserBadge.id.asc())
1284 ).all()
1286 expected = [
1287 (user1.id, "founder"),
1288 (user1.id, "board_member"),
1289 (user2.id, "founder"),
1290 (user2.id, "board_member"),
1291 (user4.id, "phone_verified"),
1292 (user5.id, "phone_verified"),
1293 ]
1295 assert badge_tuples == expected # type: ignore[comparison-overlap]
1297 print(push_collector.by_user)
1299 push = push_collector.pop_for_user(user1.id, last=False)
1300 assert push.content.title == "New profile badge: Founder"
1301 assert push.content.body == "The Founder badge was added to your profile."
1303 push = push_collector.pop_for_user(user1.id, last=True)
1304 assert push.content.title == "New profile badge: Board Member"
1305 assert push.content.body == "The Board Member badge was added to your profile."
1307 push = push_collector.pop_for_user(user2.id, last=False)
1308 assert push.content.title == "New profile badge: Founder"
1309 assert push.content.body == "The Founder badge was added to your profile."
1311 push = push_collector.pop_for_user(user2.id, last=True)
1312 assert push.content.title == "New profile badge: Board Member"
1313 assert push.content.body == "The Board Member badge was added to your profile."
1315 push = push_collector.pop_for_user(user4.id, last=True)
1316 assert push.content.title == "New profile badge: Verified Phone"
1317 assert push.content.body == "The Verified Phone badge was added to your profile."
1319 push = push_collector.pop_for_user(user5.id, last=False)
1320 assert push.content.title == "Profile badge removed"
1321 assert push.content.body == "The Board Member badge was removed from your profile."
1323 push = push_collector.pop_for_user(user5.id, last=True)
1324 assert push.content.title == "New profile badge: Verified Phone"
1325 assert push.content.body == "The Verified Phone badge was added to your profile."
1328def test_send_request_notifications_blocked_users_no_notification(db, moderator):
1329 """
1330 Regression test: send_request_notifications should not send notifications
1331 when the host and surfer are not visible to each other (e.g., one blocked the other).
1332 """
1333 user1, token1 = generate_user()
1334 user2, token2 = generate_user()
1336 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1337 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1339 # Create a host request
1340 with requests_session(token1) as requests:
1341 host_request_id = requests.CreateHostRequest(
1342 requests_pb2.CreateHostRequestReq(
1343 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
1344 )
1345 ).host_request_id
1346 moderator.approve_host_request(host_request_id)
1348 with session_scope() as session:
1349 # delete send_email BackgroundJob created by CreateHostRequest
1350 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1352 # Now user2 (host) blocks user1 (surfer)
1353 make_user_block(user2, user1)
1355 with session_scope() as session:
1356 # check send_request_notifications does NOT create background job because users are blocked
1357 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1358 send_request_notifications(empty_pb2.Empty())
1359 process_jobs()
1361 # Should be 0 emails because the host blocked the surfer
1362 assert (
1363 session.execute(
1364 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1365 ).scalar_one()
1366 == 0
1367 ), "No notification email should be sent when host has blocked surfer"
1369 # Also test the reverse direction: surfer sends message to host, host should not get notification
1370 # First unblock
1371 with session_scope() as session:
1372 session.execute(delete(UserBlock).execution_options(synchronize_session=False))
1373 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1375 # Host responds
1376 with requests_session(token2) as requests:
1377 requests.RespondHostRequest(
1378 requests_pb2.RespondHostRequestReq(
1379 host_request_id=host_request_id,
1380 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1381 text="Accepting your request",
1382 )
1383 )
1385 with session_scope() as session:
1386 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1388 # Now user1 (surfer) blocks user2 (host)
1389 make_user_block(user1, user2)
1391 with session_scope() as session:
1392 # check send_request_notifications does NOT create background job
1393 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1394 send_request_notifications(empty_pb2.Empty())
1395 process_jobs()
1397 # Should be 0 emails because the surfer blocked the host
1398 assert (
1399 session.execute(
1400 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1401 ).scalar_one()
1402 == 0
1403 ), "No notification email should be sent when surfer has blocked host"
1406def test_send_host_request_reminders_blocked_users_no_notification(db, moderator):
1407 """
1408 send_host_request_reminders should not send notifications when the host and surfer are not visible to each other
1409 (e.g., one blocked the other).
1410 """
1411 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1412 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1414 with session_scope() as session:
1415 # Create a pending host request where the host has not replied
1416 hr = create_host_request_by_date(
1417 session=session,
1418 surfer_user_id=user1.id,
1419 host_user_id=user2.id,
1420 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1421 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1422 status=HostRequestStatus.pending,
1423 host_sent_request_reminders=0,
1424 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1425 )
1427 # Approve the host request so it's visible for notifications
1428 moderator.approve_host_request(hr)
1430 # Verify that without blocking, a reminder would be sent
1431 send_host_request_reminders(empty_pb2.Empty())
1433 while process_job():
1434 pass
1436 with session_scope() as session:
1437 emails = session.execute(select(Email)).scalars().all()
1438 assert len(emails) == 1, "Expected 1 reminder email before blocking"
1440 # Clean up emails and background jobs
1441 session.execute(delete(Email).execution_options(synchronize_session=False))
1442 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1444 # Reset the reminder counter so we can test again
1445 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr)).scalar_one()
1446 host_request.recipient_sent_request_reminders = 0
1447 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL
1449 # Now have the host block the surfer
1450 make_user_block(user2, user1)
1452 send_host_request_reminders(empty_pb2.Empty())
1454 while process_job(): 1454 ↛ 1455line 1454 didn't jump to line 1455 because the condition on line 1454 was never true
1455 pass
1457 with session_scope() as session:
1458 emails = session.execute(select(Email)).scalars().all()
1459 assert len(emails) == 0, "No reminder email should be sent when host has blocked surfer"
1462def test_send_message_notifications_blocked_users_no_notification(db, moderator):
1463 """
1464 Regression test: send_message_notifications should not send notifications
1465 for messages from users who are blocked by the recipient.
1466 """
1467 user1, token1 = generate_user()
1468 user2, token2 = generate_user()
1470 make_friends(user1, user2)
1472 # Create a group chat and send messages
1473 with conversations_session(token1) as c:
1474 group_chat_id = c.CreateGroupChat(
1475 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
1476 ).group_chat_id
1478 # Approve the group chat so it's visible for notifications
1479 moderator.approve_group_chat(group_chat_id)
1481 with conversations_session(token1) as c:
1482 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
1483 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
1485 # Verify that without blocking, a notification would be sent
1486 with session_scope() as session:
1487 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1489 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1490 send_message_notifications(empty_pb2.Empty())
1491 process_jobs()
1493 with session_scope() as session:
1494 email_job_count = session.execute(
1495 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1496 ).scalar_one()
1497 assert email_job_count == 1, "Expected 1 notification email before blocking"
1499 # Clean up
1500 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1502 # Reset the notification state so user2 will receive notifications for old messages again
1503 with session_scope() as session:
1504 u2 = session.execute(select(User).where(User.id == user2.id)).scalar_one()
1505 u2.last_notified_message_id = 0
1507 # Now have user2 block user1
1508 make_user_block(user2, user1)
1510 # The existing messages from user1 should now NOT trigger notifications
1511 # since user2 has blocked user1
1512 with session_scope() as session:
1513 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1515 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
1516 send_message_notifications(empty_pb2.Empty())
1517 process_jobs()
1519 with session_scope() as session:
1520 email_job_count = session.execute(
1521 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1522 ).scalar_one()
1523 assert email_job_count == 0, "No notification email should be sent when recipient has blocked sender"
1526def test_update_badges_volunteers(db, push_collector: PushCollector):
1527 """Test that volunteer and past_volunteer badges are automatically granted based on Volunteer model."""
1528 # Create 6 users - users 1 and 2 get founder/board_member badges from static_badges
1529 user1, _ = generate_user(last_donated=None)
1530 user2, _ = generate_user(last_donated=None)
1531 user3, _ = generate_user(last_donated=None)
1532 user4, _ = generate_user(last_donated=None)
1533 user5, _ = generate_user(last_donated=None)
1534 user6, _ = generate_user(last_donated=None)
1536 with session_scope() as session:
1537 # user3: active volunteer (stopped_volunteering is null)
1538 session.add(
1539 make_volunteer(
1540 user_id=user3.id,
1541 role="Developer",
1542 started_volunteering=date(2020, 1, 1),
1543 stopped_volunteering=None,
1544 )
1545 )
1547 # user4: past volunteer (stopped_volunteering is set)
1548 session.add(
1549 make_volunteer(
1550 user_id=user4.id,
1551 role="Designer",
1552 started_volunteering=date(2020, 1, 1),
1553 stopped_volunteering=date(2023, 6, 1),
1554 )
1555 )
1557 # user5: has old volunteer badge that should be removed (not a volunteer anymore)
1558 session.add(UserBadge(user_id=user5.id, badge_id="volunteer"))
1560 # user6: has old past_volunteer badge that should be removed
1561 session.add(UserBadge(user_id=user6.id, badge_id="past_volunteer"))
1563 update_badges(empty_pb2.Empty())
1564 process_jobs()
1566 with session_scope() as session:
1567 # Check user3 has volunteer badge
1568 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1569 assert "volunteer" in user3_badges
1570 assert "past_volunteer" not in user3_badges
1572 # Check user4 has past_volunteer badge
1573 user4_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user4.id)).scalars().all()
1574 assert "past_volunteer" in user4_badges
1575 assert "volunteer" not in user4_badges
1577 # Check user5 lost the volunteer badge (not in Volunteer table)
1578 user5_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user5.id)).scalars().all()
1579 assert "volunteer" not in user5_badges
1581 # Check user6 lost the past_volunteer badge (not in Volunteer table)
1582 user6_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user6.id)).scalars().all()
1583 assert "past_volunteer" not in user6_badges
1585 # Check notifications for volunteer badge users
1586 push = push_collector.pop_for_user(user3.id, last=True)
1587 assert push.content.title == "New profile badge: Active Volunteer"
1588 assert push.content.body == "The Active Volunteer badge was added to your profile."
1590 push = push_collector.pop_for_user(user4.id, last=True)
1591 assert push.content.title == "New profile badge: Past Volunteer"
1592 assert push.content.body == "The Past Volunteer badge was added to your profile."
1594 push = push_collector.pop_for_user(user5.id, last=True)
1595 assert push.content.title == "Profile badge removed"
1596 assert push.content.body == "The Active Volunteer badge was removed from your profile."
1598 push = push_collector.pop_for_user(user6.id, last=True)
1599 assert push.content.title == "Profile badge removed"
1600 assert push.content.body == "The Past Volunteer badge was removed from your profile."
1603def test_update_badges_volunteer_status_change(db, push_collector: PushCollector):
1604 """Test that badge is updated when volunteer status changes from active to past."""
1605 # Create users - users 1 and 2 get founder/board_member badges from static_badges
1606 user1, _ = generate_user(last_donated=None)
1607 user2, _ = generate_user(last_donated=None)
1608 user3, _ = generate_user(last_donated=None)
1610 with session_scope() as session:
1611 # user3: start as active volunteer
1612 session.add(
1613 make_volunteer(
1614 user_id=user3.id,
1615 role="Developer",
1616 started_volunteering=date(2020, 1, 1),
1617 stopped_volunteering=None,
1618 show_on_team_page=True,
1619 )
1620 )
1622 update_badges(empty_pb2.Empty())
1623 process_jobs()
1625 with session_scope() as session:
1626 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1627 assert "volunteer" in user3_badges
1628 assert "past_volunteer" not in user3_badges
1630 push = push_collector.pop_for_user(user3.id, last=True)
1631 assert push.content.title == "New profile badge: Active Volunteer"
1632 assert push.content.body == "The Active Volunteer badge was added to your profile."
1634 # Now change the volunteer to past volunteer
1635 with session_scope() as session:
1636 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == user3.id)).scalar_one()
1637 volunteer.stopped_volunteering = date(2023, 12, 1)
1639 update_badges(empty_pb2.Empty())
1640 process_jobs()
1642 with session_scope() as session:
1643 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1644 assert "volunteer" not in user3_badges
1645 assert "past_volunteer" in user3_badges
1647 # Check both badges were updated
1648 push = push_collector.pop_for_user(user3.id, last=False)
1649 assert push.content.title == "Profile badge removed"
1650 assert push.content.body == "The Active Volunteer badge was removed from your profile."
1652 push = push_collector.pop_for_user(user3.id, last=True)
1653 assert push.content.title == "New profile badge: Past Volunteer"
1654 assert push.content.body == "The Past Volunteer badge was added to your profile."
1657def test_send_message_notifications_empty_unseen_simple(monkeypatch):
1658 class DummyUser:
1659 id = 1
1660 is_visible = True
1661 last_notified_message_id = 0
1663 class FirstResult:
1664 def scalars(self):
1665 return self
1667 def unique(self):
1668 return [DummyUser()]
1670 class SecondResult:
1671 def all(self):
1672 return []
1674 class DummySession:
1675 def __init__(self):
1676 self.calls = 0
1678 def execute(self, *a, **k):
1679 self.calls += 1
1680 return FirstResult() if self.calls == 1 else SecondResult()
1682 def commit(self):
1683 pass
1685 def flush(self):
1686 pass
1688 def fake_session_scope():
1689 class Ctx:
1690 def __enter__(self):
1691 return DummySession()
1693 def __exit__(self, exc_type, exc, tb):
1694 pass
1696 return Ctx()
1698 monkeypatch.setattr(handlers, "session_scope", fake_session_scope)
1700 handlers.send_message_notifications(Empty())