feat: pwa notifications

This commit is contained in:
2026-07-14 20:49:30 +02:00
parent 2cf7b283e0
commit 5198826f83
21 changed files with 1563 additions and 8 deletions
+197 -4
View File
@@ -1,13 +1,24 @@
import json
from datetime import timedelta
from datetime import datetime, timedelta
from io import StringIO
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from pywebpush import WebPushException
from accounts.models import User
from tracker.models import DoseLog, Medication, PulseReading, ScheduledDose, TimeOfDay
from accounts.models import PushSubscription, User
from tracker.models import (
DoseLog,
Medication,
PulseReading,
ReminderSent,
ScheduledDose,
TimeOfDay,
)
class TrackerTestCase(TestCase):
@@ -265,3 +276,185 @@ class PwaTests(TrackerTestCase):
response = self.client.get("/admin/login/")
self.assertContains(response, 'rel="manifest"')
@override_settings(
VAPID_PUBLIC_KEY="BMn-test-public-key",
VAPID_PRIVATE_KEY="DiXsYTkxHVb68y2-9CGUZheAe7Iah9cLZ_vXc9DbJ5w",
)
class PushTests(TrackerTestCase):
def subscribe(self, endpoint="https://push.example/abc") -> PushSubscription:
return PushSubscription.objects.create(
user=self.user,
endpoint=endpoint,
p256dh="p256dh-key",
auth="auth-secret",
)
def test_a_device_can_subscribe_and_unsubscribe(self):
self.client.force_login(self.user)
body = {
"endpoint": "https://push.example/xyz",
"keys": {"p256dh": "key", "auth": "secret"},
}
response = self.client.post(
"/api/push/subscribe", body, content_type="application/json"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.push_subscriptions.count(), 1)
# Subscribing the same endpoint twice must not duplicate the device.
self.client.post("/api/push/subscribe", body, content_type="application/json")
self.assertEqual(self.user.push_subscriptions.count(), 1)
self.client.post(
"/api/push/unsubscribe",
{"endpoint": body["endpoint"]},
content_type="application/json",
)
self.assertEqual(self.user.push_subscriptions.count(), 0)
def test_the_test_button_needs_a_subscribed_device(self):
self.client.force_login(self.user)
response = self.client.post("/api/push/test")
self.assertEqual(response.status_code, 400)
@patch("tracker.push.webpush")
def test_the_test_button_pushes_to_every_device(self, webpush):
self.subscribe("https://push.example/phone")
self.subscribe("https://push.example/tablet")
self.client.force_login(self.user)
response = self.client.post("/api/push/test")
self.assertEqual(response.status_code, 200)
self.assertEqual(webpush.call_count, 2)
payload = json.loads(webpush.call_args.kwargs["data"])
self.assertIn("test", payload["title"])
@patch("tracker.push.webpush")
def test_a_dead_subscription_is_dropped_rather_than_retried(self, webpush):
subscription = self.subscribe()
response = type("R", (), {"status_code": 410})()
webpush.side_effect = WebPushException("gone", response=response)
self.client.force_login(self.user)
self.client.post("/api/push/test")
self.assertFalse(
PushSubscription.objects.filter(pk=subscription.pk).exists(),
"A 410 means the browser discarded the subscription; the row is dead.",
)
@override_settings(
VAPID_PUBLIC_KEY="BMn-test-public-key",
VAPID_PRIVATE_KEY="DiXsYTkxHVb68y2-9CGUZheAe7Iah9cLZ_vXc9DbJ5w",
)
class SendRemindersTests(TrackerTestCase):
def setUp(self):
super().setUp()
PushSubscription.objects.create(
user=self.user,
endpoint="https://push.example/phone",
p256dh="key",
auth="secret",
)
def run_command(self, at: str, **kwargs) -> str:
"""Run send_reminders as if the clock read `at` (local time, today)."""
hour, minute = (int(part) for part in at.split(":"))
now = timezone.make_aware(
datetime.combine(timezone.localdate(), datetime.min.time()).replace(
hour=hour, minute=minute
)
)
out = StringIO()
with patch("django.utils.timezone.localtime", return_value=now):
call_command("send_reminders", stdout=out, stderr=out, **kwargs)
return out.getvalue()
def set_reminder(self, dose: ScheduledDose, at: str):
hour, minute = (int(part) for part in at.split(":"))
dose.reminder_time = datetime.min.time().replace(hour=hour, minute=minute)
dose.save()
@patch("tracker.push.webpush")
def test_a_due_dose_is_reminded_once(self, webpush):
self.set_reminder(self.morning, "08:00")
self.run_command("08:05")
self.assertEqual(webpush.call_count, 1)
payload = json.loads(webpush.call_args.kwargs["data"])
self.assertIn("Fortekor 1/4", payload["body"])
self.assertEqual(ReminderSent.objects.count(), 1)
# A second run the same day must stay quiet.
self.run_command("08:30")
self.assertEqual(webpush.call_count, 1)
@patch("tracker.push.webpush")
def test_doses_due_at_the_same_time_share_one_notification(self, webpush):
self.set_reminder(self.morning, "08:00")
self.set_reminder(self.morning_only, "08:00")
self.run_command("08:01")
self.assertEqual(webpush.call_count, 1, "One push, not one per pill.")
payload = json.loads(webpush.call_args.kwargs["data"])
self.assertIn("Fortekor", payload["body"])
self.assertIn("Vetmedin", payload["body"])
self.assertEqual(ReminderSent.objects.count(), 2)
@patch("tracker.push.webpush")
def test_a_dose_already_given_is_not_reminded(self, webpush):
self.set_reminder(self.morning, "08:00")
DoseLog.objects.create(scheduled_dose=self.morning, taken=True)
self.run_command("08:05")
webpush.assert_not_called()
@patch("tracker.push.webpush")
def test_nothing_fires_before_the_reminder_time(self, webpush):
self.set_reminder(self.evening, "19:00")
self.run_command("18:59")
webpush.assert_not_called()
@patch("tracker.push.webpush")
def test_a_long_missed_reminder_is_not_fired_late(self, webpush):
# The server was down all morning; do not buzz at 23:00 about the 08:00 dose.
self.set_reminder(self.morning, "08:00")
self.run_command("23:00")
webpush.assert_not_called()
self.assertEqual(ReminderSent.objects.count(), 0)
@patch("tracker.push.webpush")
def test_dry_run_sends_and_records_nothing(self, webpush):
self.set_reminder(self.morning, "08:00")
output = self.run_command("08:05", dry_run=True)
webpush.assert_not_called()
self.assertEqual(ReminderSent.objects.count(), 0)
self.assertIn("dry-run", output)
@patch("tracker.push.webpush")
def test_a_failed_delivery_is_retried_on_the_next_run(self, webpush):
self.set_reminder(self.morning, "08:00")
webpush.side_effect = WebPushException("push service down")
self.run_command("08:05")
self.assertEqual(
ReminderSent.objects.count(), 0, "Nothing recorded, so it can retry."
)
webpush.side_effect = None
self.run_command("08:10")
self.assertEqual(ReminderSent.objects.count(), 1)