"""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 '#22c55e' 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 '#ef4444' in msg.html # red used for Deny