import os import pytest from flask import Flask from werkzeug.security import generate_password_hash from tinydb import Query from api.digest_action_api import digest_action_api from db.db import ( users_db, child_db, task_db, reward_db, pending_confirmations_db, digest_action_tokens_db, tracking_events_db, ) from utils.digest_token import ( create_action_token, create_unsubscribe_token, validate_unsubscribe_token ) from tests.conftest import TEST_SECRET_KEY TEST_USER_ID = "daa_user_id" TEST_CHILD_ID = "daa_child_id" TEST_TASK_ID = "daa_task_id" TEST_REWARD_ID = "daa_reward_id" FRONTEND_URL = "http://localhost:5173" 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) pending_confirmations_db.remove(Query().user_id == TEST_USER_ID) digest_action_tokens_db.truncate() tracking_events_db.remove(Query().user_id == TEST_USER_ID) users_db.insert({ "id": TEST_USER_ID, "first_name": "Digest", "last_name": "Tester", "email": "digest@example.com", "password": generate_password_hash("password"), "verified": True, "role": "user", "image_id": None, "marked_for_deletion": False, "marked_for_deletion_at": None, "email_digest_enabled": True, "timezone": "America/New_York", }) child_db.insert({ "id": TEST_CHILD_ID, "user_id": TEST_USER_ID, "name": "Test Child", "age": 8, "tasks": [TEST_TASK_ID], "rewards": [TEST_REWARD_ID], "points": 100, "image_id": None, }) task_db.insert({ "id": TEST_TASK_ID, "user_id": TEST_USER_ID, "name": "Clean Room", "points": 10, "type": "chore", "description": "", "image_id": None, }) reward_db.insert({ "id": TEST_REWARD_ID, "user_id": TEST_USER_ID, "name": "Extra Screen Time", "cost": 20, "description": "", "image_id": None, }) def add_pending_chore(): pending_confirmations_db.insert({ "id": "pending_chore_id", "user_id": TEST_USER_ID, "child_id": TEST_CHILD_ID, "entity_id": TEST_TASK_ID, "entity_type": "chore", "status": "pending", "created_at": "2024-01-01T10:00:00+00:00", }) def add_pending_reward(): pending_confirmations_db.insert({ "id": "pending_reward_id", "user_id": TEST_USER_ID, "child_id": TEST_CHILD_ID, "entity_id": TEST_REWARD_ID, "entity_type": "reward", "status": "pending", "created_at": "2024-01-01T10:00:00+00:00", }) @pytest.fixture def client(): app = Flask(__name__) app.register_blueprint(digest_action_api) app.config['TESTING'] = True app.config['SECRET_KEY'] = TEST_SECRET_KEY app.config['FRONTEND_URL'] = FRONTEND_URL seed_data() with app.test_client() as c: yield c seed_data() class TestHandleDigestAction: def test_approve_chore_redirects_to_child_view(self, client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') res = client.get(f'/digest-action/{token.id}') assert res.status_code == 302 location = res.headers['Location'] assert f'/parent/{TEST_CHILD_ID}' in location assert 'scrollTo=' in location def test_approve_chore_awards_points(self, client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') client.get(f'/digest-action/{token.id}') child = child_db.get(Query().id == TEST_CHILD_ID) assert child['points'] == 110 # 100 + 10 def test_deny_chore_removes_pending(self, client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny') client.get(f'/digest-action/{token.id}') pending = pending_confirmations_db.get( (Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID) ) assert pending is None def test_approve_reward_deducts_points(self, client): add_pending_reward() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve') client.get(f'/digest-action/{token.id}') child = child_db.get(Query().id == TEST_CHILD_ID) assert child['points'] == 80 # 100 - 20 def test_deny_reward_removes_pending(self, client): add_pending_reward() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny') client.get(f'/digest-action/{token.id}') pending = pending_confirmations_db.get( (Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID) ) assert pending is None def test_invalid_token_returns_400(self, client): res = client.get('/digest-action/nonexistent-token-id') assert res.status_code == 400 def test_used_token_returns_400(self, client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') client.get(f'/digest-action/{token.id}') # first use res = client.get(f'/digest-action/{token.id}') # second use assert res.status_code == 400 def test_approve_already_resolved_chore_still_redirects(self, client): """If the chore was already resolved, action still returns a redirect (idempotent).""" token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') # No pending chore added — already resolved res = client.get(f'/digest-action/{token.id}') assert res.status_code == 302 class TestHandleDigestUnsubscribe: def test_valid_token_unsubscribes_user(self, client): token = create_unsubscribe_token(TEST_USER_ID) res = client.get(f'/digest-unsubscribe/{token}') assert res.status_code == 200 user = users_db.get(Query().id == TEST_USER_ID) assert user['email_digest_enabled'] is False def test_invalid_token_returns_400(self, client): res = client.get('/digest-unsubscribe/garbage-token') assert res.status_code == 400 def test_response_contains_unsubscribed_message(self, client): token = create_unsubscribe_token(TEST_USER_ID) res = client.get(f'/digest-unsubscribe/{token}') assert b'unsubscribed' in res.data.lower()