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:
170
backend/tests/test_push_subscription_api.py
Normal file
170
backend/tests/test_push_subscription_api.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import os
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.security import generate_password_hash
|
||||
from tinydb import Query
|
||||
|
||||
from api.push_subscription_api import push_subscription_api
|
||||
from api.auth_api import auth_api
|
||||
from db.db import users_db, push_subscriptions_db
|
||||
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||
|
||||
TEST_EMAIL = "pushtest@example.com"
|
||||
TEST_PASSWORD = "pushpassword123"
|
||||
TEST_USER_ID = "push_test_user_id"
|
||||
TEST_ENDPOINT = "https://fcm.googleapis.com/fcm/send/test-endpoint-abc"
|
||||
TEST_KEYS = {"p256dh": "BNgz3XcMv1", "auth": "abc123"}
|
||||
|
||||
|
||||
def seed_user():
|
||||
users_db.remove(Query().email == TEST_EMAIL)
|
||||
users_db.insert({
|
||||
"id": TEST_USER_ID,
|
||||
"first_name": "Push",
|
||||
"last_name": "Tester",
|
||||
"email": TEST_EMAIL,
|
||||
"password": generate_password_hash(TEST_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": None,
|
||||
"marked_for_deletion": False,
|
||||
"marked_for_deletion_at": None,
|
||||
"role": "user",
|
||||
"timezone": None,
|
||||
"email_digest_enabled": True,
|
||||
})
|
||||
|
||||
|
||||
def cleanup():
|
||||
users_db.remove(Query().email == TEST_EMAIL)
|
||||
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(push_subscription_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['VAPID_PUBLIC_KEY'] = 'test-vapid-public-key'
|
||||
app.config['FRONTEND_URL'] = 'http://localhost:5173'
|
||||
cleanup()
|
||||
seed_user()
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
cleanup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client):
|
||||
client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
||||
return client
|
||||
|
||||
|
||||
class TestVapidKey:
|
||||
def test_returns_public_key(self, client):
|
||||
res = client.get('/push-vapid-key')
|
||||
assert res.status_code == 200
|
||||
assert res.get_json()['public_key'] == 'test-vapid-public-key'
|
||||
|
||||
def test_unauthenticated_ok(self, client):
|
||||
# VAPID key endpoint is public
|
||||
res = client.get('/push-vapid-key')
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
class TestSubscribe:
|
||||
def test_subscribe_stores_subscription(self, auth_client):
|
||||
res = auth_client.post('/push-subscription', json={
|
||||
"endpoint": TEST_ENDPOINT,
|
||||
"keys": TEST_KEYS,
|
||||
})
|
||||
assert res.status_code == 200
|
||||
data = res.get_json()
|
||||
assert 'id' in data
|
||||
|
||||
saved = push_subscriptions_db.get(
|
||||
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
|
||||
)
|
||||
assert saved is not None
|
||||
|
||||
def test_subscribe_updates_timezone(self, auth_client):
|
||||
auth_client.post('/push-subscription', json={
|
||||
"endpoint": TEST_ENDPOINT,
|
||||
"keys": TEST_KEYS,
|
||||
"timezone": "America/New_York",
|
||||
})
|
||||
user = users_db.get(Query().id == TEST_USER_ID)
|
||||
assert user['timezone'] == 'America/New_York'
|
||||
|
||||
def test_subscribe_upserts_same_endpoint(self, auth_client):
|
||||
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||
count = len(push_subscriptions_db.search(
|
||||
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
|
||||
))
|
||||
assert count == 1
|
||||
|
||||
def test_subscribe_requires_endpoint(self, auth_client):
|
||||
res = auth_client.post('/push-subscription', json={"keys": TEST_KEYS})
|
||||
assert res.status_code == 400
|
||||
|
||||
def test_subscribe_requires_keys(self, auth_client):
|
||||
res = auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT})
|
||||
assert res.status_code == 400
|
||||
|
||||
def test_subscribe_requires_p256dh_and_auth(self, auth_client):
|
||||
res = auth_client.post('/push-subscription', json={
|
||||
"endpoint": TEST_ENDPOINT,
|
||||
"keys": {"p256dh": "only-one-key"},
|
||||
})
|
||||
assert res.status_code == 400
|
||||
|
||||
def test_subscribe_requires_auth(self, client):
|
||||
res = client.post('/push-subscription', json={
|
||||
"endpoint": TEST_ENDPOINT,
|
||||
"keys": TEST_KEYS,
|
||||
})
|
||||
assert res.status_code == 401
|
||||
|
||||
def test_subscribe_multiple_endpoints_per_user(self, auth_client):
|
||||
"""Multiple subscriptions can coexist for the same user (one per device)."""
|
||||
endpoint2 = "https://fcm.googleapis.com/fcm/send/second-device-endpoint"
|
||||
keys2 = {"p256dh": "BNgz3XcMv2", "auth": "def456"}
|
||||
|
||||
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||
auth_client.post('/push-subscription', json={"endpoint": endpoint2, "keys": keys2})
|
||||
|
||||
subs = push_subscriptions_db.search(Query().user_id == TEST_USER_ID)
|
||||
assert len(subs) == 2
|
||||
endpoints = {s['endpoint'] for s in subs}
|
||||
assert TEST_ENDPOINT in endpoints
|
||||
assert endpoint2 in endpoints
|
||||
|
||||
|
||||
class TestUnsubscribe:
|
||||
def test_unsubscribe_removes_subscription(self, auth_client):
|
||||
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||
res = auth_client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
|
||||
assert res.status_code == 200
|
||||
data = res.get_json()
|
||||
assert data['removed'] >= 1
|
||||
|
||||
saved = push_subscriptions_db.get(
|
||||
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
|
||||
)
|
||||
assert saved is None
|
||||
|
||||
def test_unsubscribe_nonexistent_endpoint_ok(self, auth_client):
|
||||
res = auth_client.delete('/push-subscription', json={"endpoint": "https://nonexistent"})
|
||||
assert res.status_code == 200
|
||||
assert res.get_json()['removed'] == 0
|
||||
|
||||
def test_unsubscribe_requires_endpoint(self, auth_client):
|
||||
res = auth_client.delete('/push-subscription', json={})
|
||||
assert res.status_code == 400
|
||||
|
||||
def test_unsubscribe_requires_auth(self, client):
|
||||
res = client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
|
||||
assert res.status_code == 401
|
||||
Reference in New Issue
Block a user