Files
chore/backend/tests/test_push_subscription_api.py
Ryan Kegel ad2bdf4c4f
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
Add push notification functionality with tests and digest scheduler
- 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.
2026-04-15 21:56:10 -04:00

171 lines
6.1 KiB
Python

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