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

- 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:
2026-04-15 21:56:10 -04:00
parent 0d50a324a3
commit ad2bdf4c4f
47 changed files with 3177 additions and 197 deletions

View File

@@ -5,7 +5,7 @@ import os
from flask import Flask
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db, pending_confirmations_db
from tinydb import Query
from models.child import Child
import jwt
@@ -514,4 +514,100 @@ def test_list_child_tasks_no_server_side_filtering(client):
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
# Both good tasks must be present; server never filters based on schedule/time
assert TASK_GOOD_ID in returned_ids
assert extra_id in returned_ids
assert extra_id in returned_ids
# ---------------------------------------------------------------------------
# request-reward: duplicate guard
# ---------------------------------------------------------------------------
def test_request_reward_duplicate_returns_409(client):
"""Requesting the same reward twice without resolution returns 409 Conflict."""
reward_db.insert({'id': 'r_dup', 'name': 'Duplicate Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_dup',
'name': 'Dupe Kid',
'age': 8,
'points': 20,
'tasks': [],
'rewards': ['r_dup'],
'user_id': 'testuserid',
})
first = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert first.status_code == 200
second = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert second.status_code == 409
assert second.get_json()['code'] == 'DUPLICATE_REWARD_REQUEST'
# Cleanup
pending_confirmations_db.remove(Query().child_id == 'child_dup')
child_db.remove(Query().id == 'child_dup')
reward_db.remove(Query().id == 'r_dup')
# ---------------------------------------------------------------------------
# deny-reward-request endpoint
# ---------------------------------------------------------------------------
def test_deny_reward_request_removes_pending(client):
"""Denying a reward request removes the pending confirmation and fires REQUEST_CANCELLED."""
reward_db.insert({'id': 'r_deny1', 'name': 'Deny Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_deny1',
'name': 'Deny Kid',
'age': 9,
'points': 10,
'tasks': [],
'rewards': ['r_deny1'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_deny1',
'child_id': 'child_deny1',
'entity_id': 'r_deny1',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
})
resp = client.post('/child/child_deny1/deny-reward-request', json={'reward_id': 'r_deny1'})
assert resp.status_code == 200
remaining = pending_confirmations_db.get(
(Query().child_id == 'child_deny1') & (Query().entity_id == 'r_deny1')
)
assert remaining is None
# Cleanup
child_db.remove(Query().id == 'child_deny1')
reward_db.remove(Query().id == 'r_deny1')
def test_deny_reward_request_already_resolved_returns_200(client):
"""If no pending request exists, denying returns 200 with an informational message."""
resp = client.post('/child/child_gone/deny-reward-request', json={'reward_id': 'r_gone'})
assert resp.status_code == 200
data = resp.get_json()
assert 'already been resolved' in data['message']
def test_deny_reward_request_requires_auth(client):
"""Deny-reward-request endpoint rejects unauthenticated requests."""
# Create a fresh unauthenticated client
from flask import Flask
from api.child_api import child_api as child_api_bp
from api.auth_api import auth_api as auth_api_bp
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
app2 = Flask(__name__)
app2.register_blueprint(child_api_bp)
app2.register_blueprint(auth_api_bp, url_prefix='/auth')
app2.config['TESTING'] = True
app2.config['SECRET_KEY'] = TEST_SECRET_KEY
app2.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app2.test_client() as anon:
resp = anon.post('/child/someid/deny-reward-request', json={'reward_id': 'r1'})
assert resp.status_code == 401