Coverage for app/backend/src/tests/test_bg_jobs.py: 99%
894 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1from datetime import date, 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 prometheus_client import REGISTRY
10from sqlalchemy import select, text
11from sqlalchemy.sql import delete, func
13import couchers.jobs.worker
14from couchers import experimentation
15from couchers.config import config
16from couchers.constants import (
17 HOST_REQUEST_MAX_REMINDERS,
18 HOST_REQUEST_REMINDER_INTERVAL,
19 MISSED_MESSAGES_DELAY,
20 MISSED_MESSAGES_DELAY_WITH_PUSH,
21)
22from couchers.crypto import urlsafe_secure_token
23from couchers.db import session_scope
24from couchers.email.dev import print_dev_email
25from couchers.email.queuing import queue_email
26from couchers.jobs import handlers
27from couchers.jobs.definitions import Job
28from couchers.jobs.enqueue import queue_job
29from couchers.jobs.handlers import (
30 add_users_to_email_list,
31 enforce_community_membership,
32 purge_account_deletion_tokens,
33 purge_login_tokens,
34 purge_password_reset_tokens,
35 send_host_request_reminders,
36 send_message_notifications,
37 send_onboarding_emails,
38 send_reference_reminders,
39 send_request_notifications,
40 update_badges,
41 update_recommendation_scores,
42)
43from couchers.jobs.worker import _run_job_and_schedule, process_job, run_scheduler, service_jobs
44from couchers.materialized_views import refresh_materialized_views
45from couchers.metrics import create_prometheus_server
46from couchers.models import (
47 AccountDeletionToken,
48 BackgroundJob,
49 BackgroundJobState,
50 DeviceType,
51 Email,
52 HostingStatus,
53 HostRequest,
54 HostRequestStatus,
55 LoginToken,
56 Message,
57 MessageType,
58 PasswordResetToken,
59 PostalVerificationAttempt,
60 PostalVerificationStatus,
61 PushNotificationPlatform,
62 PushNotificationSubscription,
63 User,
64 UserBadge,
65 UserBlock,
66 Volunteer,
67)
68from couchers.proto import conversations_pb2, messages_pb2, requests_pb2
69from couchers.proto.internal import jobs_pb2
70from couchers.utils import now, today
71from tests.fixtures.db import generate_user, make_friends, make_user_block, make_volunteer
72from tests.fixtures.misc import PushCollector, process_jobs
73from tests.fixtures.sessions import conversations_session, requests_session
74from tests.fixtures.timewarp import Timewarp
75from tests.test_references import create_host_reference, create_host_request, create_host_request_by_date
76from tests.test_requests import valid_request_text
79def _add_mobile_push_subscription(user_id: int, *, disabled: bool = False) -> None:
80 with session_scope() as session:
81 sub = PushNotificationSubscription(
82 user_id=user_id,
83 platform=PushNotificationPlatform.expo,
84 token=f"ExponentPushToken[{user_id}]",
85 device_name="Test phone",
86 device_type=DeviceType.ios,
87 )
88 session.add(sub)
89 if disabled:
90 session.flush()
91 sub.disabled_at = now()
94def _count_queued_emails() -> int:
95 with session_scope() as session:
96 return session.execute(
97 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
98 ).scalar_one()
101def _check_job_counter(port, job, status, attempt, exception):
102 metrics_string = requests.get(f"http://localhost:{port}").text
103 string_to_check = f'attempt="{attempt}",exception="{exception}",job="{job}",status="{status}"'
104 assert string_to_check in metrics_string
107def _queued_seconds_sum():
108 total = 0.0
109 for metric in REGISTRY.collect():
110 if metric.name == "couchers_background_jobs_queued_seconds":
111 total += sum(sample.value for sample in metric.samples if sample.name.endswith("_sum"))
112 return total
115def test_email_job(db):
116 with session_scope() as session:
117 queue_email(
118 session,
119 jobs_pb2.SendEmailPayload(
120 sender_name="sender_name",
121 sender_email="sender_email",
122 recipient="recipient",
123 subject="subject",
124 plain="plain",
125 html="html",
126 ),
127 )
129 def mock_print_dev_email(payload):
130 assert payload.sender_name == "sender_name"
131 assert payload.sender_email == "sender_email"
132 assert payload.recipient == "recipient"
133 assert payload.subject == "subject"
134 assert payload.plain == "plain"
135 assert payload.html == "html"
136 return print_dev_email(payload)
138 with patch("couchers.jobs.handlers.print_dev_email", mock_print_dev_email):
139 process_job()
141 with session_scope() as session:
142 assert (
143 session.execute(
144 select(func.count())
145 .select_from(BackgroundJob)
146 .where(BackgroundJob.state == BackgroundJobState.completed)
147 ).scalar_one()
148 == 1
149 )
150 assert (
151 session.execute(
152 select(func.count())
153 .select_from(BackgroundJob)
154 .where(BackgroundJob.state != BackgroundJobState.completed)
155 ).scalar_one()
156 == 0
157 )
160def test_purge_login_tokens(db):
161 user, api_token = generate_user()
163 with session_scope() as session:
164 login_token = LoginToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now())
165 session.add(login_token)
166 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 1
168 queue_job(session, job=purge_login_tokens, payload=empty_pb2.Empty())
169 process_job()
171 with session_scope() as session:
172 assert session.execute(select(func.count()).select_from(LoginToken)).scalar_one() == 0
174 with session_scope() as session:
175 assert (
176 session.execute(
177 select(func.count())
178 .select_from(BackgroundJob)
179 .where(BackgroundJob.state == BackgroundJobState.completed)
180 ).scalar_one()
181 == 1
182 )
183 assert (
184 session.execute(
185 select(func.count())
186 .select_from(BackgroundJob)
187 .where(BackgroundJob.state != BackgroundJobState.completed)
188 ).scalar_one()
189 == 0
190 )
193def test_purge_password_reset_tokens(db):
194 user, api_token = generate_user()
196 with session_scope() as session:
197 password_reset_token = PasswordResetToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now())
198 session.add(password_reset_token)
199 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 1
201 queue_job(session, job=purge_password_reset_tokens, payload=empty_pb2.Empty())
202 process_job()
204 with session_scope() as session:
205 assert session.execute(select(func.count()).select_from(PasswordResetToken)).scalar_one() == 0
207 with session_scope() as session:
208 assert (
209 session.execute(
210 select(func.count())
211 .select_from(BackgroundJob)
212 .where(BackgroundJob.state == BackgroundJobState.completed)
213 ).scalar_one()
214 == 1
215 )
216 assert (
217 session.execute(
218 select(func.count())
219 .select_from(BackgroundJob)
220 .where(BackgroundJob.state != BackgroundJobState.completed)
221 ).scalar_one()
222 == 0
223 )
226def test_purge_account_deletion_tokens(db):
227 user, api_token = generate_user()
228 user2, api_token2 = generate_user()
229 user3, api_token3 = generate_user()
231 with session_scope() as session:
232 """
233 3 cases:
234 1) Token is valid
235 2) Token expired but account retrievable
236 3) Account is irretrievable (and expired)
237 """
238 account_deletion_tokens = [
239 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() - timedelta(hours=2)),
240 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user2.id, expiry=now()),
241 AccountDeletionToken(token=urlsafe_secure_token(), user_id=user3.id, expiry=now() + timedelta(hours=5)),
242 ]
243 for token in account_deletion_tokens:
244 session.add(token)
245 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 3
247 queue_job(session, job=purge_account_deletion_tokens, payload=empty_pb2.Empty())
248 process_job()
250 with session_scope() as session:
251 assert session.execute(select(func.count()).select_from(AccountDeletionToken)).scalar_one() == 1
253 with session_scope() as session:
254 assert (
255 session.execute(
256 select(func.count())
257 .select_from(BackgroundJob)
258 .where(BackgroundJob.state == BackgroundJobState.completed)
259 ).scalar_one()
260 == 1
261 )
262 assert (
263 session.execute(
264 select(func.count())
265 .select_from(BackgroundJob)
266 .where(BackgroundJob.state != BackgroundJobState.completed)
267 ).scalar_one()
268 == 0
269 )
272def test_enforce_community_memberships(db):
273 with session_scope() as session:
274 queue_job(session, job=enforce_community_membership, payload=empty_pb2.Empty())
275 process_job()
277 with session_scope() as session:
278 assert (
279 session.execute(
280 select(func.count())
281 .select_from(BackgroundJob)
282 .where(BackgroundJob.state == BackgroundJobState.completed)
283 ).scalar_one()
284 == 1
285 )
286 assert (
287 session.execute(
288 select(func.count())
289 .select_from(BackgroundJob)
290 .where(BackgroundJob.state != BackgroundJobState.completed)
291 ).scalar_one()
292 == 0
293 )
296def test_refresh_materialized_views(db):
297 with session_scope() as session:
298 queue_job(session, job=refresh_materialized_views, payload=empty_pb2.Empty())
300 process_job()
302 with session_scope() as session:
303 assert (
304 session.execute(
305 select(func.count())
306 .select_from(BackgroundJob)
307 .where(BackgroundJob.state == BackgroundJobState.completed)
308 ).scalar_one()
309 == 1
310 )
311 assert (
312 session.execute(
313 select(func.count())
314 .select_from(BackgroundJob)
315 .where(BackgroundJob.state != BackgroundJobState.completed)
316 ).scalar_one()
317 == 0
318 )
321def test_service_jobs(db):
322 with session_scope() as session:
323 queue_email(
324 session,
325 jobs_pb2.SendEmailPayload(
326 sender_name="sender_name",
327 sender_email="sender_email",
328 recipient="recipient",
329 subject="subject",
330 plain="plain",
331 html="html",
332 ),
333 )
335 # we create this HitSleep exception here, and mock out the normal sleep(1) in the infinite loop to instead raise
336 # this. that allows us to conveniently get out of the infinite loop and know we had no more jobs left
337 class HitSleep(Exception):
338 pass
340 # the mock `sleep` function that instead raises the aforementioned exception
341 def raising_sleep(seconds):
342 raise HitSleep()
344 with pytest.raises(HitSleep):
345 with patch("couchers.jobs.worker.sleep", raising_sleep):
346 service_jobs()
348 with session_scope() as session:
349 assert (
350 session.execute(
351 select(func.count())
352 .select_from(BackgroundJob)
353 .where(BackgroundJob.state == BackgroundJobState.completed)
354 ).scalar_one()
355 == 1
356 )
357 assert (
358 session.execute(
359 select(func.count())
360 .select_from(BackgroundJob)
361 .where(BackgroundJob.state != BackgroundJobState.completed)
362 ).scalar_one()
363 == 0
364 )
367def test_scheduler(db, monkeypatch):
368 def purge_login_tokens(payload: empty_pb2.Empty):
369 return
371 def send_message_notifications(payload: empty_pb2.Empty):
372 return
374 MOCK_JOBS = {
375 "purge_login_tokens": Job(purge_login_tokens, timedelta(seconds=7)),
376 "send_message_notifications": Job(send_message_notifications, timedelta(seconds=11)),
377 }
379 current_time = 0
380 end_time = 70
382 class EndOfTime(Exception):
383 pass
385 def mock_monotonic():
386 return current_time
388 def mock_sleep(seconds):
389 nonlocal current_time
390 current_time += seconds
391 if current_time > end_time:
392 raise EndOfTime()
394 realized_schedule = []
396 def mock_run_job_and_schedule(sched, job: Job[Any], frequency: timedelta) -> None:
397 realized_schedule.append((current_time, job.name))
398 _run_job_and_schedule(sched, job, frequency)
400 monkeypatch.setattr(couchers.jobs.worker, "_run_job_and_schedule", mock_run_job_and_schedule)
401 monkeypatch.setattr(couchers.jobs.worker, "JOBS", MOCK_JOBS)
402 monkeypatch.setattr(couchers.jobs.worker, "monotonic", mock_monotonic)
403 monkeypatch.setattr(couchers.jobs.worker, "sleep", mock_sleep)
405 with pytest.raises(EndOfTime):
406 run_scheduler()
408 # Convert to job indices for comparison (to maintain test compatibility)
409 job_order = ["purge_login_tokens", "send_message_notifications"]
410 realized_schedule_indices = [(time, job_order.index(job_name)) for time, job_name in realized_schedule]
412 assert realized_schedule_indices == [
413 (0.0, 0),
414 (0.0, 1),
415 (7.0, 0),
416 (11.0, 1),
417 (14.0, 0),
418 (21.0, 0),
419 (22.0, 1),
420 (28.0, 0),
421 (33.0, 1),
422 (35.0, 0),
423 (42.0, 0),
424 (44.0, 1),
425 (49.0, 0),
426 (55.0, 1),
427 (56.0, 0),
428 (63.0, 0),
429 (66.0, 1),
430 (70.0, 0),
431 ]
433 with session_scope() as session:
434 assert (
435 session.execute(
436 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.pending)
437 ).scalar_one()
438 == 18
439 )
440 assert (
441 session.execute(
442 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.pending)
443 ).scalar_one()
444 == 0
445 )
448def test_job_retry(db):
449 called_count = 0
451 def mock_job(payload: empty_pb2.Empty) -> None:
452 nonlocal called_count
453 called_count += 1
454 raise Exception()
456 with session_scope() as session:
457 queue_job(session, job=mock_job, payload=empty_pb2.Empty())
459 MOCK_JOBS: dict[str, Job[Any]] = {
460 "mock_job": Job(mock_job),
461 }
462 # port 0 so parallel test runs don't fight over the port
463 metrics_server = create_prometheus_server(port=0)
465 # if IN_TEST is true, then the bg worker will raise on exceptions
466 config.IN_TEST = False
468 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS):
469 process_job()
470 with session_scope() as session:
471 assert (
472 session.execute(
473 select(func.count())
474 .select_from(BackgroundJob)
475 .where(BackgroundJob.state == BackgroundJobState.error)
476 ).scalar_one()
477 == 1
478 )
479 assert (
480 session.execute(
481 select(func.count())
482 .select_from(BackgroundJob)
483 .where(BackgroundJob.state != BackgroundJobState.error)
484 ).scalar_one()
485 == 0
486 )
488 job = session.execute(select(BackgroundJob)).scalar_one()
489 assert job.next_attempt_after > now() + timedelta(seconds=25)
491 job.next_attempt_after = func.now()
492 process_job()
493 with session_scope() as session:
494 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
495 process_job()
496 with session_scope() as session:
497 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
498 process_job()
499 with session_scope() as session:
500 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = func.now()
501 process_job()
503 with session_scope() as session:
504 assert (
505 session.execute(
506 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.failed)
507 ).scalar_one()
508 == 1
509 )
510 assert (
511 session.execute(
512 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.state != BackgroundJobState.failed)
513 ).scalar_one()
514 == 0
515 )
517 _check_job_counter(metrics_server.server_port, "mock_job", "error", "4", "Exception")
518 _check_job_counter(metrics_server.server_port, "mock_job", "failed", "5", "Exception")
519 metrics_server.shutdown()
520 metrics_server.server_close()
523def test_job_retry_backs_off_from_now_not_from_a_stale_next_attempt_after(db):
524 def mock_job(payload: empty_pb2.Empty) -> None:
525 raise Exception()
527 MOCK_JOBS: dict[str, Job[Any]] = {"mock_job": Job(mock_job)}
529 with session_scope() as session:
530 queue_job(session, job=mock_job, payload=empty_pb2.Empty())
531 session.flush()
532 session.execute(select(BackgroundJob)).scalar_one().next_attempt_after = now() - timedelta(hours=1)
534 config.IN_TEST = False
536 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS):
537 assert process_job()
539 with session_scope() as session:
540 job = session.execute(select(BackgroundJob)).scalar_one()
541 assert job.state == BackgroundJobState.error
542 assert job.try_count == 1
543 assert now() + timedelta(seconds=25) < job.next_attempt_after < now() + timedelta(seconds=35)
545 assert not process_job()
548def test_job_queued_latency_excludes_retry_backoff(db):
549 def mock_job(payload: empty_pb2.Empty) -> None:
550 pass
552 MOCK_JOBS: dict[str, Job[Any]] = {"mock_job": Job(mock_job)}
554 with session_scope() as session:
555 queue_job(session, job=mock_job, payload=empty_pb2.Empty())
556 session.flush()
557 job = session.execute(select(BackgroundJob)).scalar_one()
558 job.queued = now() - timedelta(hours=1)
559 job.next_attempt_after = now()
561 before = _queued_seconds_sum()
563 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS):
564 assert process_job()
566 assert _queued_seconds_sum() - before < 60
569def test_job_dequeue_steps_over_other_workers_jobs(db):
570 handled = []
572 def mock_job(payload: jobs_pb2.SendEmailPayload) -> None:
573 handled.append(payload.subject)
575 MOCK_JOBS: dict[str, Job[Any]] = {"mock_job": Job(mock_job)}
577 with session_scope() as session:
578 for subject in ["first", "second", "third"]:
579 queue_job(session, job=mock_job, payload=jobs_pb2.SendEmailPayload(subject=subject))
580 session.flush()
581 # queued in one transaction, so they'd otherwise all share a next_attempt_after and tie in the ordering
582 for i, job in enumerate(session.execute(select(BackgroundJob).order_by(BackgroundJob.id)).scalars()):
583 job.next_attempt_after = now() - timedelta(seconds=3 - i)
585 # another worker already finished "first" and committed
586 with session_scope() as session:
587 finished = (
588 session.execute(select(BackgroundJob).order_by(BackgroundJob.next_attempt_after).limit(1)).scalars().one()
589 )
590 finished.state = BackgroundJobState.completed
592 with session_scope() as holder:
593 # another worker is holding "second", mid-flight
594 held = (
595 holder.execute(
596 select(BackgroundJob)
597 .where(BackgroundJob.ready_for_retry)
598 .order_by(BackgroundJob.next_attempt_after)
599 .limit(1)
600 .with_for_update(skip_locked=True)
601 )
602 .scalars()
603 .one()
604 )
605 assert held.payload == jobs_pb2.SendEmailPayload(subject="second").SerializeToString()
607 # the dequeue must run at READ COMMITTED: under a stricter isolation level it can't follow the update chain of
608 # the row the other worker just completed, and aborts the whole transaction rather than stepping over it
609 assert holder.execute(text("show transaction_isolation")).scalar_one() == "read committed"
611 with patch("couchers.jobs.worker.JOBS", MOCK_JOBS):
612 assert process_job()
614 assert handled == ["third"]
617def test_no_jobs_no_problem(db):
618 with session_scope() as session:
619 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
621 assert not process_job()
623 with session_scope() as session:
624 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
627def test_send_message_notifications_basic(db, moderator, timewarp: Timewarp):
628 user1, token1 = generate_user()
629 user2, token2 = generate_user()
630 user3, token3 = generate_user()
632 make_friends(user1, user2)
633 make_friends(user1, user3)
634 make_friends(user2, user3)
636 send_message_notifications(empty_pb2.Empty())
637 process_jobs()
639 # should find no jobs, since there's no messages
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 == 0
646 )
648 with conversations_session(token1) as c:
649 group_chat_id1 = c.CreateGroupChat(
650 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id])
651 ).group_chat_id
652 moderator.approve_group_chat(group_chat_id1)
654 with conversations_session(token1) as c:
655 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 1"))
656 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 2"))
657 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 3"))
658 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id1, text="Test message 4"))
660 with conversations_session(token3) as c:
661 group_chat_id2 = c.CreateGroupChat(
662 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
663 ).group_chat_id
664 moderator.approve_group_chat(group_chat_id2)
666 with conversations_session(token3) as c:
667 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 5"))
668 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id2, text="Test message 6"))
670 send_message_notifications(empty_pb2.Empty())
671 process_jobs()
673 # no emails sent out
674 with session_scope() as session:
675 assert (
676 session.execute(
677 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
678 ).scalar_one()
679 == 0
680 )
682 timewarp.advance(MISSED_MESSAGES_DELAY)
684 # this should generate emails for both user2 and user3
685 send_message_notifications(empty_pb2.Empty())
686 process_jobs()
688 with session_scope() as session:
689 assert (
690 session.execute(
691 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
692 ).scalar_one()
693 == 2
694 )
695 # delete them all
696 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
698 # shouldn't generate any more emails
699 send_message_notifications(empty_pb2.Empty())
700 process_jobs()
702 with session_scope() as session:
703 assert (
704 session.execute(
705 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
706 ).scalar_one()
707 == 0
708 )
711def test_send_message_notifications_ignores_abandoned_subscription(db, moderator, timewarp: Timewarp):
712 """
713 Rejoining a chat leaves the earlier subscription behind with its own last-seen state, and the job
714 used to notify off that stale one even once the user had caught up on their newest subscription.
715 """
716 user1, token1 = generate_user()
717 user2, token2 = generate_user()
718 user3, token3 = generate_user()
720 make_friends(user1, user2)
721 make_friends(user1, user3)
722 make_friends(user2, user3)
724 with conversations_session(token1) as c:
725 group_chat_id = c.CreateGroupChat(
726 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id])
727 ).group_chat_id
728 moderator.approve_group_chat(group_chat_id)
730 with conversations_session(token1) as c:
731 # unread and un-notified for user2 when they get removed below
732 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
733 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
734 c.RemoveGroupChatUser(conversations_pb2.RemoveGroupChatUserReq(group_chat_id=group_chat_id, user_id=user2.id))
735 c.InviteToGroupChat(conversations_pb2.InviteToGroupChatReq(group_chat_id=group_chat_id, user_id=user2.id))
737 for token in [token2, token3]:
738 with conversations_session(token) as c:
739 latest_message_id = c.GetGroupChat(
740 conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id)
741 ).latest_message.message_id
742 c.MarkLastSeenGroupChat(
743 conversations_pb2.MarkLastSeenGroupChatReq(
744 group_chat_id=group_chat_id, last_seen_message_id=latest_message_id
745 )
746 )
748 timewarp.advance(MISSED_MESSAGES_DELAY)
750 send_message_notifications(empty_pb2.Empty())
751 process_jobs()
753 with session_scope() as session:
754 assert (
755 session.execute(
756 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
757 ).scalar_one()
758 == 0
759 )
762def test_send_message_notifications_muted(db, moderator, timewarp: Timewarp):
763 user1, token1 = generate_user()
764 user2, token2 = generate_user()
765 user3, token3 = generate_user()
767 make_friends(user1, user2)
768 make_friends(user1, user3)
769 make_friends(user2, user3)
771 send_message_notifications(empty_pb2.Empty())
772 process_jobs()
774 # should find no jobs, since there's no messages
775 with session_scope() as session:
776 assert (
777 session.execute(
778 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
779 ).scalar_one()
780 == 0
781 )
783 with conversations_session(token1) as c:
784 group_chat_id = c.CreateGroupChat(
785 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id, user3.id])
786 ).group_chat_id
787 moderator.approve_group_chat(group_chat_id)
789 with conversations_session(token3) as c:
790 # mute it for user 3
791 c.MuteGroupChat(conversations_pb2.MuteGroupChatReq(group_chat_id=group_chat_id, forever=True))
793 with conversations_session(token1) as c:
794 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
795 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
796 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3"))
797 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4"))
799 with conversations_session(token3) as c:
800 group_chat_id = c.CreateGroupChat(
801 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
802 ).group_chat_id
803 moderator.approve_group_chat(group_chat_id)
804 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 5"))
805 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 6"))
807 send_message_notifications(empty_pb2.Empty())
808 process_jobs()
810 # no emails sent out
811 with session_scope() as session:
812 assert (
813 session.execute(
814 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
815 ).scalar_one()
816 == 0
817 )
819 timewarp.advance(MISSED_MESSAGES_DELAY)
821 # this should generate emails for both user2 and NOT user3
822 send_message_notifications(empty_pb2.Empty())
823 process_jobs()
825 with session_scope() as session:
826 assert (
827 session.execute(
828 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
829 ).scalar_one()
830 == 1
831 )
832 # delete them all
833 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
835 # shouldn't generate any more emails
836 send_message_notifications(empty_pb2.Empty())
837 process_jobs()
839 with session_scope() as session:
840 assert (
841 session.execute(
842 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
843 ).scalar_one()
844 == 0
845 )
848def _send_one_chat_message(token: str, recipient_id: int, moderator) -> None:
849 with conversations_session(token) as c:
850 group_chat_id = c.CreateGroupChat(
851 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[recipient_id])
852 ).group_chat_id
853 moderator.approve_group_chat(group_chat_id)
855 with conversations_session(token) as c:
856 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
858 with session_scope() as session:
859 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
862def test_send_message_notifications_delayed_for_push_capable_users(
863 db, moderator, push_collector: PushCollector, timewarp: Timewarp
864):
865 user1, token1 = generate_user()
866 user2, token2 = generate_user()
867 make_friends(user1, user2)
868 _add_mobile_push_subscription(user2.id)
870 _send_one_chat_message(token1, user2.id, moderator)
872 # user2 already got a push about this, so the usual 5 minute delay doesn't apply to them
873 timewarp.advance(MISSED_MESSAGES_DELAY)
874 send_message_notifications(empty_pb2.Empty())
875 process_jobs()
876 assert _count_queued_emails() == 0
878 timewarp.advance(MISSED_MESSAGES_DELAY_WITH_PUSH)
879 send_message_notifications(empty_pb2.Empty())
880 process_jobs()
881 assert _count_queued_emails() == 1
884def test_send_message_notifications_not_delayed_when_push_subscription_disabled(
885 db, moderator, push_collector: PushCollector, timewarp: Timewarp
886):
887 user1, token1 = generate_user()
888 user2, token2 = generate_user()
889 make_friends(user1, user2)
890 # e.g. the device unregistered, so we can't reach user2 by push and the email is all they'll get
891 _add_mobile_push_subscription(user2.id, disabled=True)
893 _send_one_chat_message(token1, user2.id, moderator)
895 timewarp.advance(MISSED_MESSAGES_DELAY)
896 send_message_notifications(empty_pb2.Empty())
897 process_jobs()
898 assert _count_queued_emails() == 1
901def test_send_request_notifications_host_request(db, moderator, timewarp: Timewarp):
902 user1, token1 = generate_user()
903 user2, token2 = generate_user()
905 today_plus_2 = (today() + timedelta(days=2)).isoformat()
906 today_plus_3 = (today() + timedelta(days=3)).isoformat()
908 send_request_notifications(empty_pb2.Empty())
909 process_jobs()
911 # should find no jobs, since there's no messages
912 with session_scope() as session:
913 assert session.execute(select(func.count()).select_from(BackgroundJob)).scalar_one() == 0
915 with requests_session(token1) as requests:
916 host_request_id = requests.CreateHostRequest(
917 requests_pb2.CreateHostRequestReq(
918 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
919 )
920 ).host_request_id
921 moderator.approve_host_request(host_request_id)
923 with session_scope() as session:
924 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
926 timewarp.advance(MISSED_MESSAGES_DELAY)
928 # the only unseen message is the creation message, which the host was already
929 # notified about via host_request__create — no missed_messages email
930 send_request_notifications(empty_pb2.Empty())
931 process_jobs()
932 assert _count_queued_emails() == 0
934 # test that responding to host request creates email
935 with requests_session(token2) as requests:
936 requests.RespondHostRequest(
937 requests_pb2.RespondHostRequestReq(
938 host_request_id=host_request_id,
939 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
940 text="Test request",
941 )
942 )
944 with session_scope() as session:
945 # delete send_email BackgroundJob created by RespondHostRequest
946 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
948 timewarp.advance(MISSED_MESSAGES_DELAY)
950 # check send_request_notifications successfully creates background job
951 send_request_notifications(empty_pb2.Empty())
952 process_jobs()
953 assert _count_queued_emails() == 1
955 with session_scope() as session:
956 # delete all BackgroundJobs
957 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
959 send_request_notifications(empty_pb2.Empty())
960 process_jobs()
961 # should find no messages since guest has already been notified
962 assert _count_queued_emails() == 0
965def test_send_request_notifications_host_request_with_followup(db, moderator, timewarp: Timewarp):
966 """
967 When the surfer sends a follow-up message after creating the host request,
968 the host should get a missed_messages notification (even though the initial
969 creation message alone would be skipped).
970 """
971 user1, token1 = generate_user()
972 user2, token2 = generate_user()
974 today_plus_2 = (today() + timedelta(days=2)).isoformat()
975 today_plus_3 = (today() + timedelta(days=3)).isoformat()
977 with requests_session(token1) as requests:
978 host_request_id = requests.CreateHostRequest(
979 requests_pb2.CreateHostRequestReq(
980 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
981 )
982 ).host_request_id
983 moderator.approve_host_request(host_request_id)
985 # surfer sends a follow-up message
986 with requests_session(token1) as requests:
987 requests.SendHostRequestMessage(
988 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_id, text="Following up on my request!")
989 )
991 with session_scope() as session:
992 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
994 timewarp.advance(MISSED_MESSAGES_DELAY)
996 # now there are two unseen text messages for the host, so missed_messages should fire
997 send_request_notifications(empty_pb2.Empty())
998 process_jobs()
999 assert _count_queued_emails() == 1
1002def test_send_request_notifications_two_requests_one_with_followup(db, moderator, timewarp: Timewarp):
1003 """
1004 A host (user2) receives two requests: first from user1 (with a follow-up message),
1005 then from user3 (creation only). Because request B is created after request A's
1006 follow-up, it has a higher message ID. If the background job processes B first and
1007 advances last_notified_request_message_id past A's messages, one might expect A's
1008 notification to be lost — but it isn't, because the query results are already
1009 materialized before the loop begins.
1010 """
1011 user1, token1 = generate_user()
1012 user2, token2 = generate_user()
1013 user3, token3 = generate_user()
1015 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1016 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1018 # request A: user1 -> user2, with a follow-up
1019 with requests_session(token1) as requests:
1020 host_request_a = requests.CreateHostRequest(
1021 requests_pb2.CreateHostRequestReq(
1022 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
1023 )
1024 ).host_request_id
1025 moderator.approve_host_request(host_request_a)
1027 with requests_session(token1) as requests:
1028 requests.SendHostRequestMessage(
1029 requests_pb2.SendHostRequestMessageReq(host_request_id=host_request_a, text="Sorry, meant Tuesday night!")
1030 )
1032 # request B: user3 -> user2, creation only (higher message IDs than A's follow-up)
1033 with requests_session(token3) as requests:
1034 host_request_b = requests.CreateHostRequest(
1035 requests_pb2.CreateHostRequestReq(
1036 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
1037 )
1038 ).host_request_id
1039 moderator.approve_host_request(host_request_b)
1041 with session_scope() as session:
1042 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1044 timewarp.advance(MISSED_MESSAGES_DELAY)
1046 # should get exactly 1 missed_messages email: for request A (has follow-up),
1047 # not request B (creation only, skipped)
1048 send_request_notifications(empty_pb2.Empty())
1049 process_jobs()
1050 assert _count_queued_emails() == 1
1053def test_send_request_notifications_delayed_for_push_capable_users(
1054 db, moderator, push_collector: PushCollector, timewarp: Timewarp
1055):
1056 user1, token1 = generate_user()
1057 user2, token2 = generate_user()
1058 _add_mobile_push_subscription(user1.id)
1060 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1061 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1063 with requests_session(token1) as requests:
1064 host_request_id = requests.CreateHostRequest(
1065 requests_pb2.CreateHostRequestReq(
1066 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
1067 )
1068 ).host_request_id
1069 moderator.approve_host_request(host_request_id)
1071 with requests_session(token2) as requests:
1072 requests.RespondHostRequest(
1073 requests_pb2.RespondHostRequestReq(
1074 host_request_id=host_request_id,
1075 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1076 text="Test request",
1077 )
1078 )
1080 with session_scope() as session:
1081 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1083 timewarp.advance(MISSED_MESSAGES_DELAY)
1084 send_request_notifications(empty_pb2.Empty())
1085 process_jobs()
1086 assert _count_queued_emails() == 0
1088 timewarp.advance(MISSED_MESSAGES_DELAY_WITH_PUSH)
1089 send_request_notifications(empty_pb2.Empty())
1090 process_jobs()
1091 assert _count_queued_emails() == 1
1094def test_send_message_notifications_seen(db, moderator, timewarp: Timewarp):
1095 user1, token1 = generate_user()
1096 user2, token2 = generate_user()
1098 make_friends(user1, user2)
1100 send_message_notifications(empty_pb2.Empty())
1102 # should find no jobs, since there's no messages
1103 with session_scope() as session:
1104 assert (
1105 session.execute(
1106 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1107 ).scalar_one()
1108 == 0
1109 )
1111 with conversations_session(token1) as c:
1112 group_chat_id = c.CreateGroupChat(
1113 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
1114 ).group_chat_id
1115 moderator.approve_group_chat(group_chat_id)
1116 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
1117 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
1118 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 3"))
1119 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 4"))
1121 # user 2 now marks those messages as seen
1122 with conversations_session(token2) as c:
1123 m_id = c.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id)).latest_message.message_id
1124 c.MarkLastSeenGroupChat(
1125 conversations_pb2.MarkLastSeenGroupChatReq(group_chat_id=group_chat_id, last_seen_message_id=m_id)
1126 )
1128 send_message_notifications(empty_pb2.Empty())
1130 # no emails sent out
1131 with session_scope() as session:
1132 assert (
1133 session.execute(
1134 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1135 ).scalar_one()
1136 == 0
1137 )
1139 timewarp.advance(timedelta(minutes=30))
1141 # still shouldn't generate emails as user2 has seen all messages
1142 send_message_notifications(empty_pb2.Empty())
1144 with session_scope() as session:
1145 assert (
1146 session.execute(
1147 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1148 ).scalar_one()
1149 == 0
1150 )
1153def test_send_onboarding_emails(db):
1154 # needs to get first onboarding email
1155 user1, token1 = generate_user(onboarding_emails_sent=0, last_onboarding_email_sent=None, complete_profile=False)
1157 send_onboarding_emails(empty_pb2.Empty())
1158 process_jobs()
1160 with session_scope() as session:
1161 assert (
1162 session.execute(
1163 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1164 ).scalar_one()
1165 == 1
1166 )
1168 # needs to get second onboarding email, but not yet
1169 user2, token2 = generate_user(
1170 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=6), complete_profile=False
1171 )
1173 send_onboarding_emails(empty_pb2.Empty())
1174 process_jobs()
1176 with session_scope() as session:
1177 assert (
1178 session.execute(
1179 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1180 ).scalar_one()
1181 == 1
1182 )
1184 # needs to get second onboarding email
1185 user3, token3 = generate_user(
1186 onboarding_emails_sent=1, last_onboarding_email_sent=now() - timedelta(days=8), complete_profile=False
1187 )
1189 send_onboarding_emails(empty_pb2.Empty())
1190 process_jobs()
1192 with session_scope() as session:
1193 assert (
1194 session.execute(
1195 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.job_type == "send_email")
1196 ).scalar_one()
1197 == 2
1198 )
1201def test_send_reference_reminders(db):
1202 # need to test:
1203 # case 1: bidirectional (no emails)
1204 # case 2: host left ref (surfer needs an email)
1205 # case 3: surfer left ref (host needs an email)
1206 # case 4: neither left ref (host & surfer need an email)
1207 # case 5: neither left ref, but host blocked surfer, so neither should get an email
1208 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
1210 send_reference_reminders(empty_pb2.Empty())
1212 # case 1: bidirectional (no emails)
1213 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1214 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1216 # case 2: host left ref (surfer needs an email)
1217 # host
1218 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
1219 # surfer
1220 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
1222 # case 3: surfer left ref (host needs an email)
1223 # host
1224 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
1225 # surfer
1226 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
1228 # case 4: neither left ref (host & surfer need an email)
1229 # surfer
1230 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
1231 # host
1232 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
1234 # case 5: neither left ref, but host blocked surfer, so neither should get an email
1235 # surfer
1236 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
1237 # host
1238 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
1240 make_user_block(user9, user10)
1242 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
1243 # host
1244 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
1245 # surfer
1246 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
1248 with session_scope() as session:
1249 # note that create_host_reference creates a host request whose age is one day older than the timedelta here
1251 # case 1: bidirectional (no emails)
1252 ref1, hr1 = create_host_reference(session, user2.id, user1.id, timedelta(days=7), surfing=True)
1253 create_host_reference(session, user1.id, user2.id, timedelta(days=7), host_request_id=hr1)
1255 # case 2: host left ref (surfer needs an email)
1256 ref2, hr2 = create_host_reference(session, user3.id, user4.id, timedelta(days=11), surfing=False)
1258 # case 3: surfer left ref (host needs an email)
1259 ref3, hr3 = create_host_reference(session, user6.id, user5.id, timedelta(days=9), surfing=True)
1261 # case 4: neither left ref (host & surfer need an email)
1262 hr4 = create_host_request(session, user7.id, user8.id, timedelta(days=4))
1264 # case 5: neither left ref, but host blocked surfer, so neither should get an email
1265 hr5 = create_host_request(session, user9.id, user10.id, timedelta(days=7))
1267 # case 6: neither left ref, surfer indicated they didn't meet up, (host still needs an email)
1268 hr6 = create_host_request(session, user12.id, user11.id, timedelta(days=6), surfer_reason_didnt_meetup="")
1270 expected_emails = [
1271 (
1272 "user11@couchers.org.invalid",
1273 "[TEST] You have 14 days to write a reference for User 12",
1274 ("from when you hosted them", "/leave-reference/hosted/"),
1275 ),
1276 (
1277 "user4@couchers.org.invalid",
1278 "[TEST] You have 3 days to write a reference for User 3",
1279 ("from when you surfed with them", "/leave-reference/surfed/"),
1280 ),
1281 (
1282 "user5@couchers.org.invalid",
1283 "[TEST] You have 7 days to write a reference for User 6",
1284 ("from when you hosted them", "/leave-reference/hosted/"),
1285 ),
1286 (
1287 "user7@couchers.org.invalid",
1288 "[TEST] You have 14 days to write a reference for User 8",
1289 ("from when you surfed with them", "/leave-reference/surfed/"),
1290 ),
1291 (
1292 "user8@couchers.org.invalid",
1293 "[TEST] You have 14 days to write a reference for User 7",
1294 ("from when you hosted them", "/leave-reference/hosted/"),
1295 ),
1296 ]
1298 send_reference_reminders(empty_pb2.Empty())
1300 while process_job():
1301 pass
1303 with session_scope() as session:
1304 emails = [
1305 (email.recipient, email.subject, email.plain, email.html)
1306 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
1307 ]
1309 actual_addresses_and_subjects = [email[:2] for email in emails]
1310 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1312 print(actual_addresses_and_subjects)
1313 print(expected_addresses_and_subjects)
1315 assert actual_addresses_and_subjects == expected_addresses_and_subjects
1317 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
1318 for find in search_strings:
1319 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
1320 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
1323def test_send_reference_reminders_public_trip_offer(db):
1324 """An offer reverses initiator/recipient, so the reminders have to be picked by the stay role."""
1325 surfer, _ = generate_user(email="surfer@couchers.org.invalid", name="Surfer")
1326 host, _ = generate_user(email="host@couchers.org.invalid", name="Host")
1328 with session_scope() as session:
1329 create_host_request(session, surfer.id, host.id, timedelta(days=4), is_offer=True)
1331 send_reference_reminders(empty_pb2.Empty())
1333 while process_job():
1334 pass
1336 with session_scope() as session:
1337 emails = {
1338 email.recipient: (email.subject, email.plain) for email in session.execute(select(Email)).scalars().all()
1339 }
1341 assert set(emails) == {"surfer@couchers.org.invalid", "host@couchers.org.invalid"}
1342 surfer_subject, surfer_plain = emails["surfer@couchers.org.invalid"]
1343 assert surfer_subject == "[TEST] You have 14 days to write a reference for Host"
1344 assert "from when you surfed with them" in surfer_plain
1345 host_subject, host_plain = emails["host@couchers.org.invalid"]
1346 assert host_subject == "[TEST] You have 14 days to write a reference for Surfer"
1347 assert "from when you hosted them" in host_plain
1350def test_send_host_request_reminders(db, moderator):
1351 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1352 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1353 user3, token3 = generate_user(email="user3@couchers.org.invalid", name="User 3")
1354 user4, token4 = generate_user(email="user4@couchers.org.invalid", name="User 4")
1355 user5, token5 = generate_user(email="user5@couchers.org.invalid", name="User 5")
1356 user6, token6 = generate_user(email="user6@couchers.org.invalid", name="User 6")
1357 user7, token7 = generate_user(email="user7@couchers.org.invalid", name="User 7")
1358 user8, token8 = generate_user(email="user8@couchers.org.invalid", name="User 8")
1359 user9, token9 = generate_user(email="user9@couchers.org.invalid", name="User 9")
1360 user10, token10 = generate_user(email="user10@couchers.org.invalid", name="User 10")
1361 user11, token11 = generate_user(email="user11@couchers.org.invalid", name="User 11")
1362 user12, token12 = generate_user(email="user12@couchers.org.invalid", name="User 12")
1363 user13, token13 = generate_user(email="user13@couchers.org.invalid", name="User 13")
1364 user14, token14 = generate_user(email="user14@couchers.org.invalid", name="User 14")
1366 with session_scope() as session:
1367 # case 1: pending, future, interval elapsed => notify
1368 hr1 = create_host_request_by_date(
1369 session=session,
1370 surfer_user_id=user1.id,
1371 host_user_id=user2.id,
1372 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1373 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1374 status=HostRequestStatus.pending,
1375 host_sent_request_reminders=0,
1376 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1377 )
1379 # case 2: max reminders reached => do not notify
1380 hr2 = create_host_request_by_date(
1381 session=session,
1382 surfer_user_id=user3.id,
1383 host_user_id=user4.id,
1384 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1385 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1386 status=HostRequestStatus.pending,
1387 host_sent_request_reminders=HOST_REQUEST_MAX_REMINDERS,
1388 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1389 )
1391 # case 3: interval not yet elapsed => do not notify
1392 hr3 = create_host_request_by_date(
1393 session=session,
1394 surfer_user_id=user5.id,
1395 host_user_id=user6.id,
1396 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1397 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1398 status=HostRequestStatus.pending,
1399 host_sent_request_reminders=0,
1400 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL + timedelta(hours=1),
1401 )
1403 # case 4: start date is today => do not notify
1404 hr4 = create_host_request_by_date(
1405 session=session,
1406 surfer_user_id=user7.id,
1407 host_user_id=user8.id,
1408 from_date=today(),
1409 to_date=today() + timedelta(days=2),
1410 status=HostRequestStatus.pending,
1411 host_sent_request_reminders=0,
1412 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1413 )
1415 # case 5: from_date in the past => do not notify
1416 hr5 = create_host_request_by_date(
1417 session=session,
1418 surfer_user_id=user9.id,
1419 host_user_id=user10.id,
1420 from_date=today() - timedelta(days=1),
1421 to_date=today() + timedelta(days=1),
1422 status=HostRequestStatus.pending,
1423 host_sent_request_reminders=0,
1424 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1425 )
1427 # case 6: non-pending status => do not notify
1428 hr6 = create_host_request_by_date(
1429 session=session,
1430 surfer_user_id=user11.id,
1431 host_user_id=user12.id,
1432 from_date=today() + timedelta(days=3),
1433 to_date=today() + timedelta(days=4),
1434 status=HostRequestStatus.accepted,
1435 host_sent_request_reminders=0,
1436 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1437 )
1439 # case 7: host already sent a message => do not notify
1440 hr7 = create_host_request_by_date(
1441 session=session,
1442 surfer_user_id=user13.id,
1443 host_user_id=user14.id,
1444 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1445 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1446 status=HostRequestStatus.pending,
1447 host_sent_request_reminders=0,
1448 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1449 )
1451 msg = Message(
1452 conversation_id=hr7,
1453 author_id=user14.id,
1454 text="Looking forward to hosting you!",
1455 message_type=MessageType.text,
1456 )
1457 msg.time = now()
1458 session.add(msg)
1460 # Approve host requests so they're visible for notifications
1461 moderator.approve_host_request(hr1)
1462 moderator.approve_host_request(hr2)
1463 moderator.approve_host_request(hr3)
1464 moderator.approve_host_request(hr4)
1465 moderator.approve_host_request(hr5)
1466 moderator.approve_host_request(hr6)
1467 moderator.approve_host_request(hr7)
1469 send_host_request_reminders(empty_pb2.Empty())
1471 while process_job():
1472 pass
1474 with session_scope() as session:
1475 emails = [
1476 (email.recipient, email.subject, email.plain, email.html)
1477 for email in session.execute(select(Email).order_by(Email.recipient.asc())).scalars().all()
1478 ]
1480 expected_emails = [
1481 (
1482 "user2@couchers.org.invalid",
1483 "[TEST] You have a pending host request from User 1",
1484 ("User 1", "is waiting for your response"),
1485 )
1486 ]
1488 actual_addresses_and_subjects = [email[:2] for email in emails]
1489 expected_addresses_and_subjects = [email[:2] for email in expected_emails]
1491 print(actual_addresses_and_subjects)
1492 print(expected_addresses_and_subjects)
1494 assert actual_addresses_and_subjects == expected_addresses_and_subjects
1496 for (address, subject, plain, html), (_, _, search_strings) in zip(emails, expected_emails):
1497 for find in search_strings:
1498 assert find in plain, f"Expected to find string {find} in PLAIN email {subject} to {address}, didn't"
1499 assert find in html, f"Expected to find string {find} in HTML email {subject} to {address}, didn't"
1502def test_add_users_to_email_list(db, feature_flags):
1503 feature_flags.set("listmonk_enabled", True)
1504 config.LISTMONK_BASE_URL = "https://example.com"
1505 config.LISTMONK_API_USERNAME = "test_user"
1506 config.LISTMONK_API_KEY = "dummy_api_key"
1507 config.LISTMONK_LIST_ID = 6
1509 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls:
1510 mock_session_cls.return_value.post.return_value.status_code = 200
1511 add_users_to_email_list(empty_pb2.Empty())
1512 mock_session_cls.return_value.post.assert_not_called()
1514 generate_user(in_sync_with_newsletter=False, email="testing1@couchers.invalid", name="Tester1", id=15)
1515 generate_user(in_sync_with_newsletter=True, email="testing2@couchers.invalid", name="Tester2")
1516 generate_user(in_sync_with_newsletter=False, email="testing3@couchers.invalid", name="Tester3 von test", id=17)
1517 generate_user(
1518 in_sync_with_newsletter=False, email="testing4@couchers.invalid", name="Tester4", opt_out_of_newsletter=True
1519 )
1521 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls:
1522 mock_sess = mock_session_cls.return_value
1523 mock_sess.post.return_value.status_code = 200
1524 add_users_to_email_list(empty_pb2.Empty())
1525 mock_sess.post.assert_has_calls(
1526 [
1527 call(
1528 "https://example.com/api/subscribers",
1529 json={
1530 "email": "testing1@couchers.invalid",
1531 "name": "Tester1",
1532 "lists": [6],
1533 "preconfirm_subscriptions": True,
1534 "attribs": {"couchers_user_id": 15},
1535 "status": "enabled",
1536 },
1537 timeout=10,
1538 ),
1539 call(
1540 "https://example.com/api/subscribers",
1541 json={
1542 "email": "testing3@couchers.invalid",
1543 "name": "Tester3 von test",
1544 "lists": [6],
1545 "preconfirm_subscriptions": True,
1546 "attribs": {"couchers_user_id": 17},
1547 "status": "enabled",
1548 },
1549 timeout=10,
1550 ),
1551 ],
1552 any_order=True,
1553 )
1555 with patch("couchers.jobs.handlers.requests.Session") as mock_session_cls:
1556 mock_session_cls.return_value.post.return_value.status_code = 200
1557 add_users_to_email_list(empty_pb2.Empty())
1558 mock_session_cls.return_value.post.assert_not_called()
1561def test_update_recommendation_scores(db):
1562 generate_user(complete_profile=True)
1563 generate_user(hosting_status=HostingStatus.can_host)
1564 generate_user()
1566 def scores():
1567 with session_scope() as session:
1568 return session.execute(select(User.id, User.recommendation_score).order_by(User.id)).all()
1570 update_recommendation_scores(empty_pb2.Empty())
1571 first = scores()
1572 update_recommendation_scores(empty_pb2.Empty())
1573 # search applies noise per viewer, so the stored score itself should be deterministic
1574 assert scores() == first
1577def test_update_badges(db, push_collector: PushCollector):
1578 user1, _ = generate_user(last_donated=None)
1579 user2, _ = generate_user(last_donated=None)
1580 user3, _ = generate_user(last_donated=None)
1581 user4, _ = generate_user(phone="+15555555555", phone_verification_verified=func.now(), last_donated=None)
1582 user5, _ = generate_user(phone="+15555555556", phone_verification_verified=func.now(), last_donated=None)
1583 user6, _ = generate_user(last_donated=None)
1585 with session_scope() as session:
1586 session.add(UserBadge(user_id=user5.id, badge_id="board_member"))
1587 session.add(
1588 PostalVerificationAttempt(
1589 user_id=user6.id,
1590 address_line_1="123 Main St",
1591 city="Test City",
1592 country_code="US",
1593 status=PostalVerificationStatus.succeeded,
1594 verification_code="ABC123",
1595 postcard_sent_at=func.now(),
1596 verified_at=func.now(),
1597 )
1598 )
1600 update_badges(empty_pb2.Empty())
1601 process_jobs()
1603 with session_scope() as session:
1604 badge_tuples = session.execute(
1605 select(UserBadge.user_id, UserBadge.badge_id).order_by(UserBadge.user_id.asc(), UserBadge.id.asc())
1606 ).all()
1608 expected = [
1609 (user1.id, "founder"),
1610 (user1.id, "board_member"),
1611 (user2.id, "founder"),
1612 (user2.id, "board_member"),
1613 (user4.id, "phone_verified"),
1614 (user5.id, "phone_verified"),
1615 (user6.id, "postal_verified"),
1616 ]
1618 assert badge_tuples == expected # type: ignore[comparison-overlap]
1620 print(push_collector.by_user)
1622 push = push_collector.pop_for_user(user1.id, last=False)
1623 assert push.content.title == "New profile badge: Founder"
1624 assert push.content.body == "The Founder badge was added to your profile."
1626 push = push_collector.pop_for_user(user1.id, last=True)
1627 assert push.content.title == "New profile badge: Board Member"
1628 assert push.content.body == "The Board Member badge was added to your profile."
1630 push = push_collector.pop_for_user(user2.id, last=False)
1631 assert push.content.title == "New profile badge: Founder"
1632 assert push.content.body == "The Founder badge was added to your profile."
1634 push = push_collector.pop_for_user(user2.id, last=True)
1635 assert push.content.title == "New profile badge: Board Member"
1636 assert push.content.body == "The Board Member badge was added to your profile."
1638 push = push_collector.pop_for_user(user4.id, last=True)
1639 assert push.content.title == "New profile badge: Verified Phone"
1640 assert push.content.body == "The Verified Phone badge was added to your profile."
1642 push = push_collector.pop_for_user(user5.id, last=False)
1643 assert push.content.title == "Profile badge removed"
1644 assert push.content.body == "The Board Member badge was removed from your profile."
1646 push = push_collector.pop_for_user(user5.id, last=True)
1647 assert push.content.title == "New profile badge: Verified Phone"
1648 assert push.content.body == "The Verified Phone badge was added to your profile."
1651def test_update_badges_awards_moderator_to_superuser(db):
1652 """The show_moderator_badge flag defaults on, so superusers are awarded the moderator badge."""
1653 superuser, _ = generate_user(is_superuser=True, last_donated=None)
1655 update_badges(empty_pb2.Empty())
1657 with session_scope() as session:
1658 assert (
1659 session.execute(
1660 select(func.count())
1661 .select_from(UserBadge)
1662 .where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1663 ).scalar()
1664 == 1
1665 )
1668def test_update_badges_skips_moderator_when_flag_off(db, monkeypatch):
1669 """With show_moderator_badge forced off, superusers are not awarded the moderator badge."""
1670 # force show_moderator_badge off for everyone (force rule with no coverage applies globally)
1671 monkeypatch.setattr(experimentation, "_initialized", True)
1672 monkeypatch.setattr(
1673 experimentation,
1674 "_state",
1675 {"features": {"show_moderator_badge": {"defaultValue": True, "rules": [{"force": False}]}}, "savedGroups": {}},
1676 )
1677 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "")
1679 superuser, _ = generate_user(is_superuser=True, last_donated=None)
1681 update_badges(empty_pb2.Empty())
1683 with session_scope() as session:
1684 assert (
1685 session.execute(
1686 select(func.count())
1687 .select_from(UserBadge)
1688 .where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1689 ).scalar()
1690 == 0
1691 )
1694def test_send_request_notifications_blocked_users_no_notification(db, moderator, timewarp: Timewarp):
1695 """
1696 Regression test: send_request_notifications should not send notifications
1697 when the host and surfer are not visible to each other (e.g., one blocked the other).
1698 """
1699 user1, token1 = generate_user()
1700 user2, token2 = generate_user()
1702 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1703 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1705 # Create a host request
1706 with requests_session(token1) as requests:
1707 host_request_id = requests.CreateHostRequest(
1708 requests_pb2.CreateHostRequestReq(
1709 host_user_id=user2.id, from_date=today_plus_2, to_date=today_plus_3, text=valid_request_text()
1710 )
1711 ).host_request_id
1712 moderator.approve_host_request(host_request_id)
1714 with session_scope() as session:
1715 # delete send_email BackgroundJob created by CreateHostRequest
1716 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1718 # Now user2 (host) blocks user1 (surfer)
1719 make_user_block(user2, user1)
1721 timewarp.advance(MISSED_MESSAGES_DELAY)
1723 # check send_request_notifications does NOT create background job because users are blocked
1724 send_request_notifications(empty_pb2.Empty())
1725 process_jobs()
1727 # Should be 0 emails because the host blocked the surfer
1728 assert _count_queued_emails() == 0, "No notification email should be sent when host has blocked surfer"
1730 # Also test the reverse direction: surfer sends message to host, host should not get notification
1731 # First unblock
1732 with session_scope() as session:
1733 session.execute(delete(UserBlock).execution_options(synchronize_session=False))
1734 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1736 # Host responds
1737 with requests_session(token2) as requests:
1738 requests.RespondHostRequest(
1739 requests_pb2.RespondHostRequestReq(
1740 host_request_id=host_request_id,
1741 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1742 text="Accepting your request",
1743 )
1744 )
1746 with session_scope() as session:
1747 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1749 # Now user1 (surfer) blocks user2 (host)
1750 make_user_block(user1, user2)
1752 timewarp.advance(MISSED_MESSAGES_DELAY)
1754 # check send_request_notifications does NOT create background job
1755 send_request_notifications(empty_pb2.Empty())
1756 process_jobs()
1758 # Should be 0 emails because the surfer blocked the host
1759 assert _count_queued_emails() == 0, "No notification email should be sent when surfer has blocked host"
1762def test_send_host_request_reminders_blocked_users_no_notification(db, moderator):
1763 """
1764 send_host_request_reminders should not send notifications when the host and surfer are not visible to each other
1765 (e.g., one blocked the other).
1766 """
1767 user1, token1 = generate_user(email="user1@couchers.org.invalid", name="User 1")
1768 user2, token2 = generate_user(email="user2@couchers.org.invalid", name="User 2")
1770 with session_scope() as session:
1771 # Create a pending host request where the host has not replied
1772 hr = create_host_request_by_date(
1773 session=session,
1774 surfer_user_id=user1.id,
1775 host_user_id=user2.id,
1776 from_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=1),
1777 to_date=today() + HOST_REQUEST_REMINDER_INTERVAL + timedelta(days=2),
1778 status=HostRequestStatus.pending,
1779 host_sent_request_reminders=0,
1780 last_sent_request_reminder_time=now() - HOST_REQUEST_REMINDER_INTERVAL,
1781 )
1783 # Approve the host request so it's visible for notifications
1784 moderator.approve_host_request(hr)
1786 # Verify that without blocking, a reminder would be sent
1787 send_host_request_reminders(empty_pb2.Empty())
1789 while process_job():
1790 pass
1792 with session_scope() as session:
1793 emails = session.execute(select(Email)).scalars().all()
1794 assert len(emails) == 1, "Expected 1 reminder email before blocking"
1796 # Clean up emails and background jobs
1797 session.execute(delete(Email).execution_options(synchronize_session=False))
1798 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1800 # Reset the reminder counter so we can test again
1801 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr)).scalar_one()
1802 host_request.recipient_sent_request_reminders = 0
1803 host_request.last_sent_request_reminder_time = now() - HOST_REQUEST_REMINDER_INTERVAL
1805 # Now have the host block the surfer
1806 make_user_block(user2, user1)
1808 send_host_request_reminders(empty_pb2.Empty())
1810 while process_job(): 1810 ↛ 1811line 1810 didn't jump to line 1811 because the condition on line 1810 was never true
1811 pass
1813 with session_scope() as session:
1814 emails = session.execute(select(Email)).scalars().all()
1815 assert len(emails) == 0, "No reminder email should be sent when host has blocked surfer"
1818def test_send_message_notifications_blocked_users_no_notification(db, moderator, timewarp: Timewarp):
1819 """
1820 Regression test: send_message_notifications should not send notifications
1821 for messages from users who are blocked by the recipient.
1822 """
1823 user1, token1 = generate_user()
1824 user2, token2 = generate_user()
1826 make_friends(user1, user2)
1828 # Create a group chat and send messages
1829 with conversations_session(token1) as c:
1830 group_chat_id = c.CreateGroupChat(
1831 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])
1832 ).group_chat_id
1834 # Approve the group chat so it's visible for notifications
1835 moderator.approve_group_chat(group_chat_id)
1837 with conversations_session(token1) as c:
1838 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 1"))
1839 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Test message 2"))
1841 # Verify that without blocking, a notification would be sent
1842 with session_scope() as session:
1843 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1845 timewarp.advance(MISSED_MESSAGES_DELAY)
1847 send_message_notifications(empty_pb2.Empty())
1848 process_jobs()
1850 assert _count_queued_emails() == 1, "Expected 1 notification email before blocking"
1852 with session_scope() as session:
1853 # Clean up
1854 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1856 # Reset the notification state so user2 will receive notifications for old messages again
1857 with session_scope() as session:
1858 u2 = session.execute(select(User).where(User.id == user2.id)).scalar_one()
1859 u2.last_notified_message_id = 0
1861 # Now have user2 block user1
1862 make_user_block(user2, user1)
1864 # The existing messages from user1 should now NOT trigger notifications
1865 # since user2 has blocked user1
1866 with session_scope() as session:
1867 session.execute(delete(BackgroundJob).execution_options(synchronize_session=False))
1869 send_message_notifications(empty_pb2.Empty())
1870 process_jobs()
1872 assert _count_queued_emails() == 0, "No notification email should be sent when recipient has blocked sender"
1875def test_update_badges_volunteers(db, push_collector: PushCollector):
1876 """Test that volunteer and past_volunteer badges are automatically granted based on Volunteer model."""
1877 # Create 6 users - users 1 and 2 get founder/board_member badges from static_badges
1878 user1, _ = generate_user(last_donated=None)
1879 user2, _ = generate_user(last_donated=None)
1880 user3, _ = generate_user(last_donated=None)
1881 user4, _ = generate_user(last_donated=None)
1882 user5, _ = generate_user(last_donated=None)
1883 user6, _ = generate_user(last_donated=None)
1885 with session_scope() as session:
1886 # user3: active volunteer (stopped_volunteering is null)
1887 session.add(
1888 make_volunteer(
1889 user_id=user3.id,
1890 role="Developer",
1891 started_volunteering=date(2020, 1, 1),
1892 stopped_volunteering=None,
1893 )
1894 )
1896 # user4: past volunteer (stopped_volunteering is set)
1897 session.add(
1898 make_volunteer(
1899 user_id=user4.id,
1900 role="Designer",
1901 started_volunteering=date(2020, 1, 1),
1902 stopped_volunteering=date(2023, 6, 1),
1903 )
1904 )
1906 # user5: has old volunteer badge that should be removed (not a volunteer anymore)
1907 session.add(UserBadge(user_id=user5.id, badge_id="volunteer"))
1909 # user6: has old past_volunteer badge that should be removed
1910 session.add(UserBadge(user_id=user6.id, badge_id="past_volunteer"))
1912 update_badges(empty_pb2.Empty())
1913 process_jobs()
1915 with session_scope() as session:
1916 # Check user3 has volunteer badge
1917 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1918 assert "volunteer" in user3_badges
1919 assert "past_volunteer" not in user3_badges
1921 # Check user4 has past_volunteer badge
1922 user4_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user4.id)).scalars().all()
1923 assert "past_volunteer" in user4_badges
1924 assert "volunteer" not in user4_badges
1926 # Check user5 lost the volunteer badge (not in Volunteer table)
1927 user5_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user5.id)).scalars().all()
1928 assert "volunteer" not in user5_badges
1930 # Check user6 lost the past_volunteer badge (not in Volunteer table)
1931 user6_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user6.id)).scalars().all()
1932 assert "past_volunteer" not in user6_badges
1934 # Check notifications for volunteer badge users
1935 push = push_collector.pop_for_user(user3.id, last=True)
1936 assert push.content.title == "New profile badge: Active Volunteer"
1937 assert push.content.body == "The Active Volunteer badge was added to your profile."
1939 push = push_collector.pop_for_user(user4.id, last=True)
1940 assert push.content.title == "New profile badge: Past Volunteer"
1941 assert push.content.body == "The Past Volunteer badge was added to your profile."
1943 push = push_collector.pop_for_user(user5.id, last=True)
1944 assert push.content.title == "Profile badge removed"
1945 assert push.content.body == "The Active Volunteer badge was removed from your profile."
1947 push = push_collector.pop_for_user(user6.id, last=True)
1948 assert push.content.title == "Profile badge removed"
1949 assert push.content.body == "The Past Volunteer badge was removed from your profile."
1952def test_update_badges_volunteer_status_change(db, push_collector: PushCollector):
1953 """Test that badge is updated when volunteer status changes from active to past."""
1954 # Create users - users 1 and 2 get founder/board_member badges from static_badges
1955 user1, _ = generate_user(last_donated=None)
1956 user2, _ = generate_user(last_donated=None)
1957 user3, _ = generate_user(last_donated=None)
1959 with session_scope() as session:
1960 # user3: start as active volunteer
1961 session.add(
1962 make_volunteer(
1963 user_id=user3.id,
1964 role="Developer",
1965 started_volunteering=date(2020, 1, 1),
1966 stopped_volunteering=None,
1967 show_on_team_page=True,
1968 )
1969 )
1971 update_badges(empty_pb2.Empty())
1972 process_jobs()
1974 with session_scope() as session:
1975 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1976 assert "volunteer" in user3_badges
1977 assert "past_volunteer" not in user3_badges
1979 push = push_collector.pop_for_user(user3.id, last=True)
1980 assert push.content.title == "New profile badge: Active Volunteer"
1981 assert push.content.body == "The Active Volunteer badge was added to your profile."
1983 # Now change the volunteer to past volunteer
1984 with session_scope() as session:
1985 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == user3.id)).scalar_one()
1986 volunteer.stopped_volunteering = date(2023, 12, 1)
1988 update_badges(empty_pb2.Empty())
1989 process_jobs()
1991 with session_scope() as session:
1992 user3_badges = session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == user3.id)).scalars().all()
1993 assert "volunteer" not in user3_badges
1994 assert "past_volunteer" in user3_badges
1996 # Check both badges were updated
1997 push = push_collector.pop_for_user(user3.id, last=False)
1998 assert push.content.title == "Profile badge removed"
1999 assert push.content.body == "The Active Volunteer badge was removed from your profile."
2001 push = push_collector.pop_for_user(user3.id, last=True)
2002 assert push.content.title == "New profile badge: Past Volunteer"
2003 assert push.content.body == "The Past Volunteer badge was added to your profile."
2006def test_send_message_notifications_empty_unseen_simple(monkeypatch):
2007 class DummyUser:
2008 id = 1
2009 is_visible = True
2010 last_notified_message_id = 0
2012 class FirstResult:
2013 def scalars(self):
2014 return self
2016 def unique(self):
2017 return [DummyUser()]
2019 class SecondResult:
2020 def all(self):
2021 return []
2023 class DummySession:
2024 def __init__(self):
2025 self.calls = 0
2027 def execute(self, *a, **k):
2028 self.calls += 1
2029 return FirstResult() if self.calls == 1 else SecondResult()
2031 def commit(self):
2032 pass
2034 def flush(self):
2035 pass
2037 def fake_session_scope():
2038 class Ctx:
2039 def __enter__(self):
2040 return DummySession()
2042 def __exit__(self, exc_type, exc, tb):
2043 pass
2045 return Ctx()
2047 monkeypatch.setattr(handlers, "session_scope", fake_session_scope)
2049 handlers.send_message_notifications(Empty())