Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ class CheckinReminder(models.Model):
'JITAILog', on_delete=models.SET_NULL, null=True, blank=True, related_name='outcome_reminders'
)

@property
def prompt_id(self):
return f'EMA-REMINDER-{self.pk}'

@property
def expires_at(self):
# Derived from the immutable send record, never from a read/open time.
from datetime import timedelta
from .ema_catalog import EMA_RESPONSE_WINDOW_MINUTES
return self.sent_at + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES)

class Meta:
ordering = ['-sent_at']

Expand Down
3 changes: 3 additions & 0 deletions backend/app/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ def build_jitai_push_message(user, jitai_log) -> PushMessage:


def send_jitai_prompt(user, jitai_log) -> bool:
# A worker may have loaded this user before the device logged out.
user.refresh_from_db(fields=['push_token'])
if not user.push_token:
logger.warning("No push token for user_id=%s — skipping", user.user_id)
mark_delivery_failed(jitai_log, 'missing push token')
Expand Down Expand Up @@ -295,6 +297,7 @@ def build_checkin_reminder_message(user) -> PushMessage:


def send_checkin_reminder(user) -> bool:
user.refresh_from_db(fields=['push_token'])
if not is_valid_expo_push_token(user.push_token):
if user.push_token:
logger.warning(
Expand Down
178 changes: 178 additions & 0 deletions backend/app/test_checkin_deadline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Notification-based deadlines, including Dr. Chang's five-minute reopen case."""
from datetime import timedelta
from unittest.mock import patch

from django.test import TestCase, override_settings
from django.utils import timezone
from django.utils.dateparse import parse_datetime

from app.models import CheckinReminder, EMA, JITAILog
from app.tests import FAST_HASHERS, authenticated_client, make_user


@override_settings(PASSWORD_HASHERS=FAST_HASHERS)
class CheckinDeadlineTests(TestCase):
def setUp(self):
self.user = make_user(email='deadline@test.com')
self.client = authenticated_client(self.user)
self.reminder = CheckinReminder.objects.create(user=self.user, daily_count_at_send=0)
self.sent = self.reminder.sent_at
self.deadline = self.sent + timedelta(minutes=30)

def at(self, minutes):
return patch('app.views.django_timezone.now', return_value=self.sent + timedelta(minutes=minutes))

def submit(self, **overrides):
payload = {
'prompt_id': self.reminder.prompt_id,
'ema_type': 'scheduled_check_in',
'responses': [{'sub_item_id': 'B1_valence', 'value': 4}],
}
payload.update(overrides)
return self.client.post('/ema/responses/', payload, format='json')

def test_open_at_five_minutes_has_twenty_five_minutes_left(self):
with self.at(5):
data = self.client.get('/ema/next/').json()
self.assertTrue(data['should_show'])
self.assertEqual(data['prompt_id'], self.reminder.prompt_id)
self.assertEqual(parse_datetime(data['expires_at']), self.deadline)
self.assertEqual((parse_datetime(data['expires_at']) - (self.sent + timedelta(minutes=5))).total_seconds(), 1500)

def test_repeated_opens_never_extend_deadline_or_create_survey_rows(self):
for minutes in (0, 0.5, 5, 20, 29.99):
with self.at(minutes):
data = self.client.get('/ema/next/').json()
self.assertTrue(data['should_show'])
self.assertEqual(data['prompt_id'], self.reminder.prompt_id)
self.assertEqual(parse_datetime(data['expires_at']), self.deadline)
self.assertEqual(EMA.objects.count(), 0)

def test_exact_deadline_and_later_hide_and_reject_survey(self):
for minutes in (30, 31, 120):
with self.at(minutes):
self.assertFalse(self.client.get('/ema/next/').json()['should_show'])
self.assertEqual(self.submit().status_code, 410)
self.assertFalse(EMA.objects.exists())

def test_submit_keeps_original_deadline_and_completed_survey_disappears(self):
with self.at(25):
response = self.submit()
self.assertEqual(response.status_code, 201)
self.assertEqual(parse_datetime(response.json()['expires_at']), self.deadline)
self.assertFalse(self.client.get('/ema/next/').json()['should_show'])
self.assertEqual(self.submit().status_code, 409)
self.assertEqual(EMA.objects.count(), 1)

def test_notification_required(self):
self.reminder.delete()
self.assertFalse(self.client.get('/ema/next/').json()['should_show'])
self.assertEqual(self.submit(prompt_id='EMA-invented').status_code, 400)

def test_other_users_notification_cannot_be_read_or_submitted(self):
other = make_user(email='other-deadline@test.com')
self.client = authenticated_client(other)
self.assertFalse(self.client.get('/ema/next/').json()['should_show'])
self.assertEqual(self.submit().status_code, 404)

def test_alias_prompt_id_cannot_bypass_duplicate_checks(self):
alias = f'EMA-REMINDER-0{self.reminder.pk}'
self.assertEqual(self.submit(prompt_id=alias).status_code, 400)
self.assertFalse(EMA.objects.exists())

def test_feedback_type_cannot_bypass_deadline(self):
with self.at(31):
self.assertEqual(self.submit(ema_type='prompt_feedback').status_code, 400)
self.assertFalse(EMA.objects.exists())

def test_new_notification_does_not_extend_old_survey(self):
with self.at(31):
new = CheckinReminder.objects.create(user=self.user, daily_count_at_send=1)
data = self.client.get('/ema/next/').json()
self.assertEqual(data['prompt_id'], new.prompt_id)
self.assertEqual(parse_datetime(data['expires_at']), new.expires_at)
self.assertEqual(self.submit().status_code, 410)

def test_future_notification_is_not_available(self):
with self.at(-1):
self.assertFalse(self.client.get('/ema/next/').json()['should_show'])
self.assertEqual(self.submit().status_code, 410)

def test_outcome_checkin_uses_its_own_reminder_in_both_arms(self):
for draw in (0.2, 0.9):
with self.subTest(draw=draw):
log = JITAILog.objects.create(
user=self.user, trigger_reason='test', decision_point_id=f'ema_{draw}',
decision_made_at=self.sent - timedelta(minutes=65),
randomization_draw=draw, randomization_probability=0.5,
send_prompt=draw < 0.5,
)
reminder = CheckinReminder.objects.create(user=self.user, jitai_log=log)
with self.at(5):
data = self.client.get('/ema/next/').json()
self.assertTrue(data['should_show'])
self.assertEqual(data['prompt_id'], reminder.prompt_id)
self.assertEqual(parse_datetime(data['expires_at']), reminder.expires_at)
response = self.submit(
prompt_id=reminder.prompt_id, ema_type='extra_check_in', jitai_log_id=log.pk,
outcome_window_start=self.sent.isoformat(), outcome_window_end=self.deadline.isoformat(),
)
self.assertEqual(response.status_code, 201)
self.assertEqual(parse_datetime(response.json()['outcome_window_start']), log.decision_made_at)
self.assertEqual(parse_datetime(response.json()['expires_at']), reminder.expires_at)
self.assertFalse(self.client.get('/ema/next/').json()['should_show'])
log.delete()

def test_outcome_window_without_sent_reminder_does_not_create_checkin(self):
JITAILog.objects.create(
user=self.user, trigger_reason='test', decision_point_id='ema_pending',
decision_made_at=self.sent - timedelta(minutes=65), randomization_draw=0.9,
)
with self.at(5):
self.assertFalse(self.client.get('/ema/next/').json()['should_show'])

def _old_feedback(self):
return JITAILog.objects.create(
user=self.user, trigger_reason='earlier intervention', send_prompt=True,
push_sent_at=self.sent - timedelta(days=7),
)

def test_checkin_only_skips_old_feedback_and_preserves_deadline_on_open(self):
feedback = self._old_feedback()
with self.at(5):
default = self.client.get('/ema/next/').json()
self.assertEqual(default['ema_type'], 'prompt_feedback')
self.assertEqual(default['jitai_log_id'], feedback.pk)
# Availability and opening the card both use this request mode.
for _ in range(2):
checkin = self.client.get('/ema/next/', {'checkin_only': '1'}).json()
self.assertEqual(checkin['ema_type'], 'scheduled_check_in')
self.assertEqual(checkin['prompt_id'], self.reminder.prompt_id)
self.assertEqual(parse_datetime(checkin['expires_at']), self.deadline)
self.assertEqual(self.submit().status_code, 201)
self.assertFalse(self.client.get('/ema/next/', {'checkin_only': '1'}).json()['should_show'])
# Bypassing feedback neither answers nor dismisses it.
self.assertEqual(self.client.get('/ema/next/').json()['jitai_log_id'], feedback.pk)
self.assertFalse(EMA.objects.filter(source_jitai_log=feedback).exists())

def test_old_feedback_alone_never_becomes_home_screen_checkin(self):
self._old_feedback()
for minutes in (30, 60):
with self.at(minutes):
self.assertTrue(self.client.get('/ema/next/').json()['should_show'])
self.assertFalse(self.client.get('/ema/next/', {'checkin_only': '1'}).json()['should_show'])
self.reminder.delete()
self.assertFalse(self.client.get('/ema/next/', {'checkin_only': '1'}).json()['should_show'])

def test_old_feedback_does_not_block_outcome_checkin_card(self):
self._old_feedback()
log = JITAILog.objects.create(
user=self.user, trigger_reason='outcome', decision_point_id='ema_outcome',
decision_made_at=self.sent - timedelta(minutes=65), randomization_draw=0.9,
)
reminder = CheckinReminder.objects.create(user=self.user, jitai_log=log)
with self.at(5):
data = self.client.get('/ema/next/', {'checkin_only': '1'}).json()
self.assertEqual(data['ema_type'], 'extra_check_in')
self.assertEqual(data['prompt_id'], reminder.prompt_id)
self.assertEqual(parse_datetime(data['expires_at']), reminder.expires_at)
72 changes: 72 additions & 0 deletions backend/app/test_push_logout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from unittest.mock import patch

from django.test import TestCase, override_settings
from rest_framework.test import APIClient

from app.models import JITAILog, User
from app.notification_service import send_checkin_reminder, send_jitai_prompt
from app.tests import authenticated_client, make_user


@override_settings(API_KEY='test-key', PASSWORD_HASHERS=['django.contrib.auth.hashers.MD5PasswordHasher'])
class PushLogoutTests(TestCase):
def setUp(self):
self.user = make_user(push_token='ExponentPushToken[device-a]')
self.client = APIClient()
self.client.credentials(HTTP_X_API_KEY='test-key')

def unregister(self, **overrides):
body = {'user_id': self.user.pk, 'push_token': self.user.push_token}
body.update(overrides)
return self.client.post('/notifications/unregister/', body, format='json')

def test_expired_jwt_can_revoke_and_repeated_cleanup_is_idempotent(self):
self.client.credentials(HTTP_X_API_KEY='test-key', HTTP_AUTHORIZATION='Bearer expired')
self.assertEqual(self.unregister().status_code, 204)
self.assertEqual(self.unregister().status_code, 204)
self.user.refresh_from_db()
self.assertIsNone(self.user.push_token)
with patch('app.notification_service.PushClient') as expo:
self.assertFalse(send_checkin_reminder(self.user))
log = JITAILog.objects.create(user=self.user, prompt_id='TEMPLATE_001')
self.assertFalse(send_jitai_prompt(self.user, log))
expo.assert_not_called()

def test_wrong_token_cannot_revoke_another_device(self):
self.assertEqual(self.unregister(push_token='ExponentPushToken[other]').status_code, 204)
self.user.refresh_from_db()
self.assertEqual(self.user.push_token, 'ExponentPushToken[device-a]')

def test_worker_with_stale_user_rechecks_token_before_sending(self):
stale_user = User.objects.get(pk=self.user.pk)
self.assertEqual(self.unregister().status_code, 204)
with patch('app.notification_service.PushClient') as expo:
self.assertFalse(send_checkin_reminder(stale_user))
log = JITAILog.objects.create(user=self.user, prompt_id='TEMPLATE_001')
self.assertFalse(send_jitai_prompt(self.user, log))
expo.assert_not_called()

def test_stale_logout_does_not_remove_new_device_registration(self):
User.objects.filter(pk=self.user.pk).update(push_token='ExponentPushToken[device-b]')
self.assertEqual(self.unregister().status_code, 204)
self.user.refresh_from_db()
self.assertEqual(self.user.push_token, 'ExponentPushToken[device-b]')

def test_api_key_is_required(self):
self.client.credentials()
self.assertEqual(self.unregister().status_code, 403)

def test_invalid_payloads_are_rejected(self):
for token in [None, '', ' ', 123, 'x' * 129]:
self.assertEqual(self.unregister(push_token=token).status_code, 400)
for user_id in [None, True, '1', -1, 10**100]:
self.assertEqual(self.unregister(user_id=user_id).status_code, 400)

def test_login_can_register_again(self):
self.assertEqual(self.unregister().status_code, 204)
client = authenticated_client(self.user)
result = client.put(f'/user/{self.user.pk}/', {'push_token': self.user.push_token},
format='json', HTTP_X_API_KEY='test-key')
self.assertEqual(result.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.push_token, 'ExponentPushToken[device-a]')
Loading
Loading