Add push notification functionality with tests and digest scheduler
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s

- Implemented push subscription API with tests for subscribing and unsubscribing users.
- Created web push notification tests triggered by child actions.
- Added digest scheduler to send email digests to users at 9 PM local time.
- Developed utility functions for creating and validating digest action tokens.
- Integrated web push sender to handle sending notifications to users.
- Added service worker for handling push notifications in the frontend.
- Created a push opt-in component for user notification preferences.
- Implemented tests for the push opt-in component to ensure correct behavior.
- Updated frontend services to manage push subscriptions and permissions.
This commit is contained in:
2026-04-15 21:56:10 -04:00
parent 0d50a324a3
commit ad2bdf4c4f
47 changed files with 3177 additions and 197 deletions

View File

@@ -0,0 +1,201 @@
"""Tests for web push notifications triggered by child actions.
Patches `utils.push_sender.webpush` so no real HTTP requests are made.
A push subscription is seeded in push_subscriptions_db so send_push_to_user
actually calls webpush (rather than short-circuiting at "no subscriptions").
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import (
child_db, task_db, reward_db, users_db,
pending_confirmations_db, push_subscriptions_db,
)
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
TEST_USER_ID = "wp_test_user_id"
TEST_EMAIL = "wptest@example.com"
TEST_PASSWORD = "wptestpass"
TEST_CHILD_ID = "wp_child_id"
TEST_TASK_ID = "wp_task_id"
TEST_REWARD_ID_AFFORD = "wp_reward_afford"
TEST_REWARD_ID_CANT = "wp_reward_cant"
TEST_ENDPOINT = "https://push.example.com/wp_endpoint"
def seed_data():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Push",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": "America/New_York",
"email_digest_enabled": True,
})
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Push Child",
"age": 8,
"points": 50,
"tasks": [TEST_TASK_ID],
"rewards": [TEST_REWARD_ID_AFFORD, TEST_REWARD_ID_CANT],
"image_id": None,
})
task_db.insert({
"id": TEST_TASK_ID,
"user_id": TEST_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
# Affordable reward (cost <= child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_AFFORD,
"user_id": TEST_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
# Unaffordable reward (cost > child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_CANT,
"user_id": TEST_USER_ID,
"name": "New Bike",
"cost": 200,
"image_id": None,
})
def seed_subscription():
push_subscriptions_db.insert({
"id": "wp_sub_id",
"user_id": TEST_USER_ID,
"endpoint": TEST_ENDPOINT,
"keys": {"p256dh": "BNgz3test", "auth": "authtest"},
"created_at": "2024-01-01T00:00:00+00:00",
})
def cleanup():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173'
cleanup()
seed_data()
with app.test_client() as c:
c.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
yield c
cleanup()
class TestWebPushOnChoreConfirm:
def test_push_fired_to_all_subscriptions_on_chore_confirm(self, client):
"""Web push is sent to all stored subscriptions when a chore is confirmed."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert mock_webpush.called
def test_push_payload_includes_required_fields(self, client):
"""Push payload includes user_id, approve_token, and deny_token."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
call_kwargs = mock_webpush.call_args[1]
payload = json.loads(call_kwargs['data'])
assert payload['user_id'] == TEST_USER_ID
assert 'approve_token' in payload
assert 'deny_token' in payload
def test_push_not_fired_when_no_subscriptions(self, client):
"""No error is raised when the parent has no push subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert not mock_webpush.called
class TestWebPushOnRewardRequest:
def test_push_fired_for_affordable_reward_request(self, client):
"""Web push is fired when a child requests a reward they can afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)
def test_push_not_fired_for_unaffordable_reward(self, client):
"""Web push is NOT fired when a child requests a reward they cannot afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_CANT},
)
# request-reward rejects unaffordable requests with 400
assert resp.status_code == 400
assert not mock_webpush.called
def test_push_not_fired_for_reward_when_no_subscriptions(self, client):
"""No error is raised for reward requests when the parent has no subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert not mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)