"""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)