Files
chore/backend/tests/test_digest_scheduler.py
Ryan Kegel ad2bdf4c4f
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
Add push notification functionality with tests and digest scheduler
- 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.
2026-04-15 21:56:10 -04:00

263 lines
10 KiB
Python

"""Tests for the digest scheduler and email HTML generation."""
import os
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from utils.digest_scheduler import send_digests
from utils.email_sender import send_digest_email
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
from tests.conftest import TEST_SECRET_KEY
SCHED_USER_ID = "sched_test_user"
SCHED_EMAIL = "schedtest@example.com"
SCHED_CHILD_ID = "sched_child_id"
SCHED_TASK_ID = "sched_task_id"
SCHED_REWARD_ID = "sched_reward_id"
def seed_scheduler_data(
verified=True,
email_digest_enabled=True,
timezone="UTC",
has_pending=True,
):
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
users_db.insert({
"id": SCHED_USER_ID,
"first_name": "Sched",
"last_name": "Tester",
"email": SCHED_EMAIL,
"password": generate_password_hash("schedpass"),
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": timezone,
"email_digest_enabled": email_digest_enabled,
})
child_db.insert({
"id": SCHED_CHILD_ID,
"user_id": SCHED_USER_ID,
"name": "Sched Child",
"age": 8,
"points": 50,
"tasks": [SCHED_TASK_ID],
"rewards": [SCHED_REWARD_ID],
"image_id": None,
})
task_db.insert({
"id": SCHED_TASK_ID,
"user_id": SCHED_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
reward_db.insert({
"id": SCHED_REWARD_ID,
"user_id": SCHED_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
if has_pending:
pending_confirmations_db.insert({
"id": "sched_pending_id",
"user_id": SCHED_USER_ID,
"child_id": SCHED_CHILD_ID,
"entity_id": SCHED_TASK_ID,
"entity_type": "chore",
"status": "pending",
"approved_at": None,
})
def cleanup_scheduler_data():
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
flask_app.config['FRONTEND_URL'] = 'http://localhost:5173'
flask_app.config['MAIL_DEFAULT_SENDER'] = 'no-reply@reward-app.local'
return flask_app
class TestSendDigests:
def setup_method(self):
cleanup_scheduler_data()
def teardown_method(self):
cleanup_scheduler_data()
def test_sends_digest_to_eligible_user_at_9pm(self, app):
"""Identifies verified, digest-enabled users whose local time is 9 pm and sends digest."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert mock_mail.return_value.send.called
def test_skips_user_with_no_pending_items(self, app):
"""Digest is not sent if there are no pending items."""
seed_scheduler_data(has_pending=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_unverified_user(self, app):
"""Digest is not sent to unverified users."""
seed_scheduler_data(verified=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_with_digest_disabled(self, app):
"""Digest is not sent to users who have email_digest_enabled == False."""
seed_scheduler_data(email_digest_enabled=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_not_at_9pm(self, app):
"""Digest is not sent when the user's local time is not 21."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=10), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_reads_timezone_and_falls_back_to_utc(self, app):
"""_get_local_hour uses User.timezone; None falls back to UTC."""
from utils.digest_scheduler import _get_local_hour
from datetime import datetime, timezone as tz
utc_hour = datetime.now(tz.utc).hour
assert _get_local_hour(None) == utc_hour
# A real timezone that differs from UTC (New York is UTC-4 or UTC-5)
result = _get_local_hour("America/New_York")
assert 0 <= result <= 23
def test_skips_when_db_env_is_e2e(self, app):
"""Digest scheduler does nothing in the e2e test environment."""
seed_scheduler_data()
original = os.environ.get('DB_ENV')
try:
os.environ['DB_ENV'] = 'e2e'
with patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
finally:
if original is not None:
os.environ['DB_ENV'] = original
else:
os.environ.pop('DB_ENV', None)
class TestDigestEmailHtml:
"""Tests for the HTML content of send_digest_email."""
@pytest.fixture
def app_ctx(self, app):
with app.app_context():
yield
def _build_items(self):
return [
{
'child_name': 'Alice',
'entity_name': 'Clean Room',
'entity_type': 'chore',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=task1&entityType=chore',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok',
'child_id': 'child1',
'entity_id': 'task1',
},
{
'child_name': 'Alice',
'entity_name': 'Movie Night',
'entity_type': 'reward',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=reward1&entityType=reward',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok2',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok2',
'child_id': 'child1',
'entity_id': 'reward1',
},
]
def test_email_contains_child_section(self, app_ctx):
"""Email HTML contains a section for each child."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Alice' in msg.html
def test_email_contains_item_names(self, app_ctx):
"""Email HTML lists each pending item's name."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Clean Room' in msg.html
assert 'Movie Night' in msg.html
def test_email_contains_approve_and_deny_links(self, app_ctx):
"""Email HTML contains Approve and Deny links for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'approve_tok' in msg.html
assert 'deny_tok' in msg.html
assert 'Approve' in msg.html
assert 'Deny' in msg.html
def test_email_contains_view_links(self, app_ctx):
"""Email HTML contains a View link for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'View' in msg.html
assert 'scrollTo=task1' in msg.html
def test_email_contains_unsubscribe_link(self, app_ctx):
"""Email HTML footer contains the unsubscribe link."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'unsub_tok' in msg.html
assert 'Unsubscribe' in msg.html
def test_approve_link_styled_green(self, app_ctx):
"""Approve links use green color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
# Find the Approve link and verify green color is adjacent
assert '#22863a' in msg.html # green used for Approve
def test_deny_link_styled_red(self, app_ctx):
"""Deny links use red color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert '#cb2431' in msg.html # red used for Deny