feat: implement state expiry for chores and rewards, adding scheduler and event handling
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m40s

This commit is contained in:
2026-04-21 23:21:53 -04:00
parent bc481527c8
commit 6982fa561f
8 changed files with 764 additions and 2 deletions

View File

@@ -27,6 +27,7 @@ from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler
from utils.digest_scheduler import start_digest_scheduler
from utils.state_expiry_scheduler import start_state_expiry_scheduler
# Configure logging once at application startup
logging.basicConfig(
@@ -140,6 +141,7 @@ createDefaultRewards()
start_background_threads()
start_deletion_scheduler()
start_digest_scheduler(app)
start_state_expiry_scheduler(app)
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)

View File

@@ -0,0 +1,250 @@
"""Tests for the state expiry scheduler."""
import os
import time
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from tinydb import Query
from utils.state_expiry_scheduler import (
_get_today_start_timestamp,
expire_stale_pending_for_user,
run_state_expiry_check,
)
from db.db import users_db, pending_confirmations_db
from tests.conftest import TEST_SECRET_KEY
EXPIRY_USER_ID = "expiry_test_user"
EXPIRY_USER_ID_2 = "expiry_test_user_2"
EXPIRY_CHILD_ID = "expiry_child_id"
EXPIRY_CHILD_ID_2 = "expiry_child_id_2"
EXPIRY_TASK_ID = "expiry_task_id"
EXPIRY_REWARD_ID = "expiry_reward_id"
def _yesterday_timestamp() -> float:
"""Return a Unix timestamp from 25 hours ago (safely before today's midnight)."""
return time.time() - (25 * 3600)
def _seed_user(user_id: str = EXPIRY_USER_ID, timezone: str = "UTC", verified: bool = True):
users_db.remove(Query().id == user_id)
users_db.insert({
"id": user_id,
"first_name": "Expiry",
"last_name": "Tester",
"email": f"{user_id}@example.com",
"password": "hashed",
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"timezone": timezone,
"email_digest_enabled": False,
})
def _seed_pending(
pending_id: str,
user_id: str = EXPIRY_USER_ID,
child_id: str = EXPIRY_CHILD_ID,
entity_id: str = EXPIRY_TASK_ID,
entity_type: str = "chore",
status: str = "pending",
created_at: float = None,
):
pending_confirmations_db.remove(Query().id == pending_id)
pending_confirmations_db.insert({
"id": pending_id,
"user_id": user_id,
"child_id": child_id,
"entity_id": entity_id,
"entity_type": entity_type,
"status": status,
"approved_at": None,
"created_at": created_at if created_at is not None else _yesterday_timestamp(),
"updated_at": time.time(),
})
def _cleanup():
for uid in (EXPIRY_USER_ID, EXPIRY_USER_ID_2):
users_db.remove(Query().id == uid)
pending_confirmations_db.remove(Query().user_id == uid)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
return flask_app
class TestGetTodayStartTimestamp:
def test_returns_float(self):
ts = _get_today_start_timestamp("UTC")
assert isinstance(ts, float)
def test_today_start_is_before_now(self):
ts = _get_today_start_timestamp("UTC")
assert ts <= time.time()
def test_falls_back_to_utc_on_none(self):
ts_utc = _get_today_start_timestamp("UTC")
ts_none = _get_today_start_timestamp(None)
assert abs(ts_utc - ts_none) < 1 # within 1 second
def test_falls_back_to_utc_on_invalid_timezone(self):
ts_utc = _get_today_start_timestamp("UTC")
ts_bad = _get_today_start_timestamp("Invalid/Timezone")
assert abs(ts_utc - ts_bad) < 1
def test_timezone_shifts_midnight(self):
"""UTC-12 midnight is 12 hours later than UTC midnight in absolute terms."""
ts_utc = _get_today_start_timestamp("UTC")
ts_west = _get_today_start_timestamp("Etc/GMT+12")
# The western timezone's "today start" is at most 12 hours away from UTC's
assert abs(ts_utc - ts_west) <= 12 * 3600 + 1
class TestExpireStaleForUser:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_pending_chore_from_yesterday_is_deleted(self, app):
_seed_pending("exp_chore_1", entity_type="chore", created_at=_yesterday_timestamp())
with app.app_context():
with patch('events.sse.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 1
assert pending_confirmations_db.get(Query().id == "exp_chore_1") is None
def test_pending_chore_fires_reset_sse(self, app):
_seed_pending("exp_chore_2", entity_type="chore", created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
mock_send.assert_called_once()
event = mock_send.call_args[0][1]
assert event.type == "child_chore_confirmation"
assert event.payload.data['operation'] == "RESET"
assert event.payload.data['task_id'] == EXPIRY_TASK_ID
def test_pending_reward_from_yesterday_is_deleted(self, app):
_seed_pending(
"exp_reward_1",
entity_id=EXPIRY_REWARD_ID,
entity_type="reward",
created_at=_yesterday_timestamp(),
)
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user'):
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 1
assert pending_confirmations_db.get(Query().id == "exp_reward_1") is None
def test_pending_reward_fires_cancelled_sse(self, app):
_seed_pending(
"exp_reward_2",
entity_id=EXPIRY_REWARD_ID,
entity_type="reward",
created_at=_yesterday_timestamp(),
)
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
mock_send.assert_called_once()
event = mock_send.call_args[0][1]
assert event.type == "child_reward_request"
assert event.payload.data['operation'] == "CANCELLED"
assert event.payload.data['reward_id'] == EXPIRY_REWARD_ID
def test_pending_record_from_today_is_not_deleted(self, app):
_seed_pending("exp_today_1", created_at=time.time())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
assert pending_confirmations_db.get(Query().id == "exp_today_1") is not None
mock_send.assert_not_called()
def test_approved_record_from_yesterday_is_not_deleted(self, app):
_seed_pending("exp_approved_1", status="approved", created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
assert pending_confirmations_db.get(Query().id == "exp_approved_1") is not None
mock_send.assert_not_called()
def test_no_pending_records_returns_zero(self, app):
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
mock_send.assert_not_called()
def test_multiple_stale_records_all_expired(self, app):
_seed_pending("exp_multi_1", entity_id=EXPIRY_TASK_ID, entity_type="chore",
created_at=_yesterday_timestamp())
_seed_pending("exp_multi_2", entity_id=EXPIRY_REWARD_ID, entity_type="reward",
created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 2
assert mock_send.call_count == 2
class TestRunStateExpiryCheck:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_expires_stale_records_for_all_verified_users(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_user(EXPIRY_USER_ID_2)
_seed_pending("exp_u1", user_id=EXPIRY_USER_ID, created_at=_yesterday_timestamp())
_seed_pending("exp_u2", user_id=EXPIRY_USER_ID_2, child_id=EXPIRY_CHILD_ID_2,
created_at=_yesterday_timestamp())
with patch('utils.state_expiry_scheduler.send_event_to_user'):
run_state_expiry_check(app)
assert pending_confirmations_db.get(Query().id == "exp_u1") is None
assert pending_confirmations_db.get(Query().id == "exp_u2") is None
def test_only_expires_stale_records_not_todays(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_pending("exp_stale", created_at=_yesterday_timestamp())
_seed_pending("exp_fresh", created_at=time.time())
with patch('utils.state_expiry_scheduler.send_event_to_user'):
run_state_expiry_check(app)
assert pending_confirmations_db.get(Query().id == "exp_stale") is None
assert pending_confirmations_db.get(Query().id == "exp_fresh") is not None
def test_skips_when_db_env_is_e2e(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_pending("exp_e2e", created_at=_yesterday_timestamp())
original = os.environ.get('DB_ENV')
try:
os.environ['DB_ENV'] = 'e2e'
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
run_state_expiry_check(app)
mock_send.assert_not_called()
assert pending_confirmations_db.get(Query().id == "exp_e2e") is not None
finally:
if original is not None:
os.environ['DB_ENV'] = original
else:
os.environ.pop('DB_ENV', None)
def test_no_error_when_user_has_no_pending_records(self, app):
_seed_user(EXPIRY_USER_ID)
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
run_state_expiry_check(app)
mock_send.assert_not_called()

View File

@@ -0,0 +1,131 @@
import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_reward_request import ChildRewardRequest
from events.types.event import Event
from events.types.event_types import EventType
from events.sse import send_event_to_user
logger = logging.getLogger(__name__)
def _get_today_start_timestamp(tz_str: str | None) -> float:
"""Return a Unix timestamp for the start of today (midnight) in the user's timezone."""
try:
if tz_str:
from zoneinfo import ZoneInfo
now_local = datetime.now(ZoneInfo(tz_str))
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
return today_local.timestamp()
except Exception:
pass
now_utc = datetime.now(timezone.utc)
today_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
return today_utc.timestamp()
def expire_stale_pending_for_user(user_id: str, tz_str: str | None) -> int:
"""Delete stale pending confirmations for a user and fire SSE events.
A record is stale if status='pending' and created_at is before the start of
today in the user's timezone. Returns the number of records expired.
Must be called within a Flask app context.
"""
from db.db import pending_confirmations_db
today_start = _get_today_start_timestamp(tz_str)
PendingQ = Query()
stale_items = pending_confirmations_db.search(
(PendingQ.user_id == user_id) &
(PendingQ.status == 'pending') &
(PendingQ.created_at < today_start)
)
if not stale_items:
return 0
for item in stale_items:
item_id = item.get('id')
child_id = item.get('child_id')
entity_id = item.get('entity_id')
entity_type = item.get('entity_type')
pending_confirmations_db.remove(PendingQ.id == item_id)
if entity_type == 'chore':
event = Event(
EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(
child_id=child_id,
task_id=entity_id,
operation=ChildChoreConfirmation.OPERATION_RESET,
)
)
else:
event = Event(
EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(
child_id=child_id,
reward_id=entity_id,
operation=ChildRewardRequest.REQUEST_CANCELLED,
)
)
send_event_to_user(user_id, event)
logger.info(
f'State expiry: expired {entity_type} {entity_id} for child {child_id} (user {user_id})'
)
return len(stale_items)
def run_state_expiry_check(app) -> None:
"""Check all verified users and expire stale pending records."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db
UserQ = Query()
users = users_db.search(UserQ.verified == True)
for user_dict in users:
user_id = user_dict.get('id')
tz_str = user_dict.get('timezone')
try:
expired = expire_stale_pending_for_user(user_id, tz_str)
if expired:
logger.info(f'State expiry: expired {expired} record(s) for user {user_id}')
except Exception as e:
logger.error(f'State expiry: failed for user {user_id}: {e}')
except Exception as e:
logger.error(f'Error in run_state_expiry_check: {e}')
def start_state_expiry_scheduler(app) -> BackgroundScheduler:
"""Start the hourly state expiry scheduler. Returns the scheduler instance."""
scheduler = BackgroundScheduler()
scheduler.add_job(
run_state_expiry_check,
'cron',
minute=0,
args=[app],
id='state_expiry_scheduler',
replace_existing=True,
)
scheduler.start()
logger.info('State expiry scheduler started (hourly)')
# Run immediately on startup to handle any records that expired while server was down
run_state_expiry_check(app)
return scheduler