import os import jwt import pytest from datetime import datetime, timedelta, timezone 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" OTHER_USER_ID = "daa_other_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 make_auth_token(user_id: str) -> str: """Create a JWT access token for the given user_id, for use in tests.""" payload = { 'user_id': user_id, 'token_version': 0, 'exp': datetime.now(timezone.utc) + timedelta(minutes=15), } return jwt.encode(payload, TEST_SECRET_KEY, algorithm='HS256') def seed_data(): users_db.remove(Query().id == TEST_USER_ID) users_db.remove(Query().id == OTHER_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", "token_version": 0, }) users_db.insert({ "id": OTHER_USER_ID, "first_name": "Other", "last_name": "User", "email": "other@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", "token_version": 0, }) 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() @pytest.fixture def auth_client(client): """Client pre-authenticated as TEST_USER_ID via JWT cookie.""" client.set_cookie('access_token', make_auth_token(TEST_USER_ID)) return client class TestHandleDigestActionGet: """GET /digest-action/: validates token, redirects with digestToken param, executes nothing.""" def test_redirects_to_parent_view_with_digest_token(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 f'digestToken={token.id}' in location assert 'scrollTo=' in location def test_does_not_execute_action(self, client): """GET must not change any data; action is deferred to the authenticated POST.""" 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'] == 100 # unchanged def test_does_not_consume_token(self, client): """GET can be called multiple times without consuming the token.""" add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') res1 = client.get(f'/digest-action/{token.id}') res2 = client.get(f'/digest-action/{token.id}') assert res1.status_code == 302 assert res2.status_code == 302 def test_invalid_token_returns_400(self, client): res = client.get('/digest-action/nonexistent-token-id') assert res.status_code == 400 def test_already_resolved_chore_still_redirects(self, client): """GET still redirects even if the pending chore no longer exists; token is valid.""" 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 TestExecuteDigestActionPost: """POST /digest-action/: requires auth, consumes token, executes action.""" def test_approve_chore_awards_points(self, auth_client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') res = auth_client.post(f'/digest-action/{token.id}') assert res.status_code == 200 child = child_db.get(Query().id == TEST_CHILD_ID) assert child['points'] == 110 # 100 + 10 def test_deny_chore_removes_pending(self, auth_client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny') res = auth_client.post(f'/digest-action/{token.id}') assert res.status_code == 200 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, auth_client): add_pending_reward() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve') res = auth_client.post(f'/digest-action/{token.id}') assert res.status_code == 200 child = child_db.get(Query().id == TEST_CHILD_ID) assert child['points'] == 80 # 100 - 20 def test_deny_reward_removes_pending(self, auth_client): add_pending_reward() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny') res = auth_client.post(f'/digest-action/{token.id}') assert res.status_code == 200 pending = pending_confirmations_db.get( (Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID) ) assert pending is None def test_response_contains_success_payload(self, auth_client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') res = auth_client.post(f'/digest-action/{token.id}') assert res.status_code == 200 data = res.get_json() assert data['success'] is True assert data['child_id'] == TEST_CHILD_ID assert data['entity_id'] == TEST_TASK_ID assert data['action'] == 'approve' def test_unauthenticated_returns_401(self, client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') res = client.post(f'/digest-action/{token.id}') assert res.status_code == 401 def test_wrong_user_returns_403(self, client): """A token created for TEST_USER_ID cannot be used by OTHER_USER_ID.""" add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') client.set_cookie('access_token', make_auth_token(OTHER_USER_ID)) res = client.post(f'/digest-action/{token.id}') assert res.status_code == 403 def test_invalid_token_returns_400(self, auth_client): res = auth_client.post('/digest-action/nonexistent-token-id') assert res.status_code == 400 def test_used_token_returns_400(self, auth_client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') auth_client.post(f'/digest-action/{token.id}') # first use — consumes token res = auth_client.post(f'/digest-action/{token.id}') # second use assert res.status_code == 400 def test_get_after_post_returns_400(self, auth_client): """Once the token is consumed via POST, the GET redirect should also fail.""" add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') auth_client.post(f'/digest-action/{token.id}') # consumes token res = auth_client.get(f'/digest-action/{token.id}') assert res.status_code == 400 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()