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.
202 lines
7.3 KiB
Python
202 lines
7.3 KiB
Python
"""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)
|