feat: add admin endpoint to send digest emails for users
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m12s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m12s
- Implemented a new endpoint `/admin/test/send-digest` in `admin_api.py` to trigger digest emails for specific users. - Added a script `send_digest.py` to facilitate sending digest emails via the admin API. - Enhanced the digest action handling in `digest_action_api.py` to support token peeking without consuming it. - Updated the `send_digests` function in `digest_scheduler.py` to utilize the new `send_digest_for_user` function for sending emails. - Introduced a new utility function `peek_token` in `digest_token.py` to validate tokens without consuming them. - Modified the `ParentView.vue` component to handle digest actions upon receiving a digest token in the URL. - Updated `.gitignore` to exclude sensitive certificate files.
This commit is contained in:
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -38,4 +38,9 @@
|
|||||||
"editor.fontFamily": "JetBrains Mono",
|
"editor.fontFamily": "JetBrains Mono",
|
||||||
"editor.fontSize": 13,
|
"editor.fontSize": 13,
|
||||||
"editor.fontLigatures": true,
|
"editor.fontLigatures": true,
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"backend"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
@@ -16,14 +18,26 @@ from utils.digest_token import (
|
|||||||
from tests.conftest import TEST_SECRET_KEY
|
from tests.conftest import TEST_SECRET_KEY
|
||||||
|
|
||||||
TEST_USER_ID = "daa_user_id"
|
TEST_USER_ID = "daa_user_id"
|
||||||
|
OTHER_USER_ID = "daa_other_user_id"
|
||||||
TEST_CHILD_ID = "daa_child_id"
|
TEST_CHILD_ID = "daa_child_id"
|
||||||
TEST_TASK_ID = "daa_task_id"
|
TEST_TASK_ID = "daa_task_id"
|
||||||
TEST_REWARD_ID = "daa_reward_id"
|
TEST_REWARD_ID = "daa_reward_id"
|
||||||
FRONTEND_URL = "http://localhost:5173"
|
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():
|
def seed_data():
|
||||||
users_db.remove(Query().id == TEST_USER_ID)
|
users_db.remove(Query().id == TEST_USER_ID)
|
||||||
|
users_db.remove(Query().id == OTHER_USER_ID)
|
||||||
child_db.remove(Query().id == TEST_CHILD_ID)
|
child_db.remove(Query().id == TEST_CHILD_ID)
|
||||||
task_db.remove(Query().id == TEST_TASK_ID)
|
task_db.remove(Query().id == TEST_TASK_ID)
|
||||||
reward_db.remove(Query().id == TEST_REWARD_ID)
|
reward_db.remove(Query().id == TEST_REWARD_ID)
|
||||||
@@ -44,6 +58,22 @@ def seed_data():
|
|||||||
"marked_for_deletion_at": None,
|
"marked_for_deletion_at": None,
|
||||||
"email_digest_enabled": True,
|
"email_digest_enabled": True,
|
||||||
"timezone": "America/New_York",
|
"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({
|
child_db.insert({
|
||||||
"id": TEST_CHILD_ID,
|
"id": TEST_CHILD_ID,
|
||||||
@@ -111,67 +141,139 @@ def client():
|
|||||||
seed_data()
|
seed_data()
|
||||||
|
|
||||||
|
|
||||||
class TestHandleDigestAction:
|
@pytest.fixture
|
||||||
def test_approve_chore_redirects_to_child_view(self, client):
|
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/<token_id>: validates token, redirects with digestToken param, executes nothing."""
|
||||||
|
|
||||||
|
def test_redirects_to_parent_view_with_digest_token(self, client):
|
||||||
add_pending_chore()
|
add_pending_chore()
|
||||||
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
res = client.get(f'/digest-action/{token.id}')
|
res = client.get(f'/digest-action/{token.id}')
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
location = res.headers['Location']
|
location = res.headers['Location']
|
||||||
assert f'/parent/{TEST_CHILD_ID}' in location
|
assert f'/parent/{TEST_CHILD_ID}' in location
|
||||||
|
assert f'digestToken={token.id}' in location
|
||||||
assert 'scrollTo=' in location
|
assert 'scrollTo=' in location
|
||||||
|
|
||||||
def test_approve_chore_awards_points(self, client):
|
def test_does_not_execute_action(self, client):
|
||||||
|
"""GET must not change any data; action is deferred to the authenticated POST."""
|
||||||
add_pending_chore()
|
add_pending_chore()
|
||||||
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
client.get(f'/digest-action/{token.id}')
|
client.get(f'/digest-action/{token.id}')
|
||||||
child = child_db.get(Query().id == TEST_CHILD_ID)
|
child = child_db.get(Query().id == TEST_CHILD_ID)
|
||||||
assert child['points'] == 110 # 100 + 10
|
assert child['points'] == 100 # unchanged
|
||||||
|
|
||||||
def test_deny_chore_removes_pending(self, client):
|
def test_does_not_consume_token(self, client):
|
||||||
|
"""GET can be called multiple times without consuming the token."""
|
||||||
add_pending_chore()
|
add_pending_chore()
|
||||||
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny')
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
client.get(f'/digest-action/{token.id}')
|
res1 = client.get(f'/digest-action/{token.id}')
|
||||||
pending = pending_confirmations_db.get(
|
res2 = client.get(f'/digest-action/{token.id}')
|
||||||
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID)
|
assert res1.status_code == 302
|
||||||
)
|
assert res2.status_code == 302
|
||||||
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):
|
def test_invalid_token_returns_400(self, client):
|
||||||
res = client.get('/digest-action/nonexistent-token-id')
|
res = client.get('/digest-action/nonexistent-token-id')
|
||||||
assert res.status_code == 400
|
assert res.status_code == 400
|
||||||
|
|
||||||
def test_used_token_returns_400(self, client):
|
def test_already_resolved_chore_still_redirects(self, client):
|
||||||
add_pending_chore()
|
"""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')
|
|
||||||
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')
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
# No pending chore added — already resolved
|
# No pending chore added — already resolved
|
||||||
res = client.get(f'/digest-action/{token.id}')
|
res = client.get(f'/digest-action/{token.id}')
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteDigestActionPost:
|
||||||
|
"""POST /digest-action/<token_id>: 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:
|
class TestHandleDigestUnsubscribe:
|
||||||
def test_valid_token_unsubscribes_user(self, client):
|
def test_valid_token_unsubscribes_user(self, client):
|
||||||
token = create_unsubscribe_token(TEST_USER_ID)
|
token = create_unsubscribe_token(TEST_USER_ID)
|
||||||
|
|||||||
Reference in New Issue
Block a user