Add push notification functionality with tests and digest scheduler
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
- 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.
This commit is contained in:
190
backend/tests/test_digest_action_api.py
Normal file
190
backend/tests/test_digest_action_api.py
Normal file
@@ -0,0 +1,190 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user