Add chore expiry notification system with scheduling and tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m59s

- Implemented `trigger_chore_expiry.py` script to manually trigger chore expiry notifications for users via the admin API.
- Developed `chore_expiry_notification_scheduler.py` to handle the logic for sending notifications for chores expiring within the next 75 minutes.
- Created utility functions in `schedule_utils.py` to determine scheduling and deadlines for chores.
- Added comprehensive tests for the chore expiry notification system in `test_chore_expiry_notification_scheduler.py`, covering various scenarios including scheduled chores, confirmations, and user settings.
This commit is contained in:
2026-04-22 15:37:40 -04:00
parent 6982fa561f
commit 8907184fde
9 changed files with 1181 additions and 4 deletions

View File

@@ -0,0 +1,544 @@
"""Tests for the chore expiry notification scheduler."""
import os
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch, MagicMock
from flask import Flask
from tinydb import Query
from utils.chore_expiry_notification_scheduler import (
get_expiring_chores_for_user,
_build_push_payload,
send_chore_expiry_notifications_for_user,
run_chore_expiry_check,
)
from utils.schedule_utils import interval_hits_today, is_scheduled_today, get_due_time_today
from db.db import users_db, child_db, task_db, chore_schedules_db, pending_confirmations_db
from tests.conftest import TEST_SECRET_KEY
USER_ID = "expiry_notif_user"
CHILD_ID = "expiry_notif_child"
CHILD_ID_2 = "expiry_notif_child_2"
TASK_ID = "expiry_notif_task"
TASK_ID_2 = "expiry_notif_task_2"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_user(push_enabled: bool = True, verified: bool = True):
users_db.remove(Query().id == USER_ID)
users_db.insert({
"id": USER_ID,
"first_name": "Notify",
"last_name": "Tester",
"email": f"{USER_ID}@example.com",
"password": "hashed",
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"timezone": "UTC",
"email_digest_enabled": False,
"push_notifications_enabled": push_enabled,
})
def _seed_child(child_id: str = CHILD_ID, task_ids: list = None):
if task_ids is None:
task_ids = [TASK_ID]
child_db.remove(Query().id == child_id)
child_db.insert({
"id": child_id,
"user_id": USER_ID,
"name": "Alex" if child_id == CHILD_ID else "Sam",
"age": 8,
"points": 0,
"tasks": task_ids,
"rewards": [],
"image_id": None,
})
def _seed_task(task_id: str = TASK_ID, name: str = "Clean Room"):
task_db.remove(Query().id == task_id)
task_db.insert({
"id": task_id,
"user_id": USER_ID,
"name": name,
"points": 10,
"type": "chore",
"image_id": None,
})
def _seed_schedule(
child_id: str = CHILD_ID,
task_id: str = TASK_ID,
mode: str = "days",
day_configs: list = None,
default_has_deadline: bool = True,
default_hour: int = 21,
default_minute: int = 0,
enabled: bool = True,
interval_days: int = 1,
anchor_date: str = "",
interval_has_deadline: bool = True,
interval_hour: int = 21,
interval_minute: int = 0,
):
"""Seed a chore schedule. For 'days' mode, day_configs defaults to all 7 days."""
if day_configs is None:
day_configs = [
{"day": d, "hour": default_hour, "minute": default_minute}
for d in range(7)
]
chore_schedules_db.remove(
(Query().child_id == child_id) & (Query().task_id == task_id)
)
chore_schedules_db.insert({
"id": f"sched_{child_id}_{task_id}",
"child_id": child_id,
"task_id": task_id,
"mode": mode,
"day_configs": day_configs,
"default_hour": default_hour,
"default_minute": default_minute,
"default_has_deadline": default_has_deadline,
"interval_days": interval_days,
"anchor_date": anchor_date,
"interval_has_deadline": interval_has_deadline,
"interval_hour": interval_hour,
"interval_minute": interval_minute,
"enabled": enabled,
})
def _seed_confirmation(child_id: str = CHILD_ID, task_id: str = TASK_ID, status: str = "pending"):
pending_confirmations_db.remove(
(Query().child_id == child_id) & (Query().entity_id == task_id)
)
pending_confirmations_db.insert({
"id": f"conf_{child_id}_{task_id}",
"user_id": USER_ID,
"child_id": child_id,
"entity_id": task_id,
"entity_type": "chore",
"status": status,
"approved_at": None,
"created_at": 0,
"updated_at": 0,
})
def _cleanup():
for cid in (CHILD_ID, CHILD_ID_2):
child_db.remove(Query().id == cid)
chore_schedules_db.remove(Query().child_id == cid)
pending_confirmations_db.remove(Query().child_id == cid)
for tid in (TASK_ID, TASK_ID_2):
task_db.remove(Query().id == tid)
users_db.remove(Query().id == USER_ID)
def _now_with_deadline_in_window(minutes_ahead: int = 30) -> tuple[datetime, int, int]:
"""Return (now_dt, hour, minute) so that deadline = now + minutes_ahead."""
now = datetime.now(timezone.utc)
deadline = now + timedelta(minutes=minutes_ahead)
return now, deadline.hour, deadline.minute
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
return flask_app
# ---------------------------------------------------------------------------
# schedule_utils unit tests
# ---------------------------------------------------------------------------
class TestIntervalHitsToday:
def test_same_day_as_anchor_hits(self):
from datetime import date
d = date(2026, 4, 22)
assert interval_hits_today("2026-04-22", 3, d) is True
def test_interval_day_hits(self):
from datetime import date
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 25)) is True
def test_non_interval_day_misses(self):
from datetime import date
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 24)) is False
def test_before_anchor_misses(self):
from datetime import date
assert interval_hits_today("2026-04-22", 1, date(2026, 4, 21)) is False
def test_empty_anchor_hits_today(self):
from datetime import date
d = date(2026, 4, 22)
assert interval_hits_today("", 1, d) is True
class TestIsScheduledToday:
def test_days_mode_matching_weekday(self):
from datetime import date
# 2026-04-22 is Wednesday → JS weekday 3 (Sun=0..Sat=6)
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 3}]}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
def test_days_mode_non_matching_weekday(self):
from datetime import date
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 1}]}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is False
def test_paused_schedule_always_true(self):
from datetime import date
schedule = {"mode": "days", "enabled": False, "day_configs": []}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
def test_interval_mode_hit(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 1, "anchor_date": "2026-04-22",
}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
def test_interval_mode_miss(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 3, "anchor_date": "2026-04-22",
}
assert is_scheduled_today(schedule, date(2026, 4, 23)) is False
class TestGetDueTimeToday:
def test_days_mode_returns_due_time(self):
from datetime import date
# Wednesday → JS 3 (Sun=0..Sat=6)
schedule = {
"mode": "days", "enabled": True,
"default_has_deadline": True,
"day_configs": [{"day": 3, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) == (21, 0)
def test_days_mode_anytime_returns_none(self):
from datetime import date
schedule = {
"mode": "days", "enabled": True,
"default_has_deadline": False,
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
def test_days_mode_wrong_day_returns_none(self):
from datetime import date
schedule = {
"mode": "days", "enabled": True,
"default_has_deadline": True,
"day_configs": [{"day": 1, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
def test_paused_returns_none(self):
from datetime import date
schedule = {
"mode": "days", "enabled": False,
"default_has_deadline": True,
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
def test_interval_mode_returns_due_time(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 1, "anchor_date": "2026-04-22",
"interval_has_deadline": True,
"interval_hour": 20, "interval_minute": 30,
}
assert get_due_time_today(schedule, date(2026, 4, 22)) == (20, 30)
def test_interval_mode_anytime_returns_none(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 1, "anchor_date": "2026-04-22",
"interval_has_deadline": False,
"interval_hour": 20, "interval_minute": 30,
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
# ---------------------------------------------------------------------------
# get_expiring_chores_for_user tests
# ---------------------------------------------------------------------------
class TestGetExpiringChoresForUser:
def setup_method(self):
_cleanup()
_seed_user()
_seed_child()
_seed_task()
def teardown_method(self):
_cleanup()
def test_chore_in_window_included(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert len(result) == 1
assert result[0]['task_id'] == TASK_ID
assert result[0]['child_id'] == CHILD_ID
def test_chore_deadline_past_excluded(self, app):
# Deadline 10 minutes in the past
now = datetime.now(timezone.utc)
past = now - timedelta(minutes=10)
_seed_schedule(default_hour=past.hour, default_minute=past.minute)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_chore_deadline_beyond_window_excluded(self, app):
# Deadline 80 minutes ahead (beyond 75-min window)
now, h, m = _now_with_deadline_in_window(minutes_ahead=80)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_anytime_chore_excluded(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_has_deadline=False, default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_wrong_day_excluded(self, app):
from datetime import date
now = datetime.now(timezone.utc)
# Schedule only on a day that is NOT today
today_js = (now.weekday() + 1) % 7
wrong_day = (today_js + 1) % 7
h = (now + timedelta(minutes=30)).hour
m = (now + timedelta(minutes=30)).minute
_seed_schedule(
day_configs=[{"day": wrong_day, "hour": h, "minute": m}],
default_hour=h,
default_minute=m,
)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_pending_confirmation_excludes_chore(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
_seed_confirmation(status="pending")
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_approved_confirmation_excludes_chore(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
_seed_confirmation(status="approved")
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_no_schedule_excluded(self, app):
now = datetime.now(timezone.utc)
# No schedule seeded
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_paused_schedule_excluded(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(enabled=False, default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_interval_mode_hit_included(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
today_iso = now.strftime("%Y-%m-%d")
_seed_schedule(
mode="interval",
interval_days=1,
anchor_date=today_iso,
interval_has_deadline=True,
interval_hour=h,
interval_minute=m,
)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert len(result) == 1
def test_interval_mode_miss_excluded(self, app):
now = datetime.now(timezone.utc)
h = (now + timedelta(minutes=30)).hour
m = (now + timedelta(minutes=30)).minute
# Anchor yesterday with interval_days=2 → not today
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
_seed_schedule(
mode="interval",
interval_days=2,
anchor_date=yesterday,
interval_has_deadline=True,
interval_hour=h,
interval_minute=m,
)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_multiple_children_grouped(self, app):
_seed_child(CHILD_ID_2, task_ids=[TASK_ID_2])
_seed_task(TASK_ID_2, name="Take Out Trash")
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(child_id=CHILD_ID, task_id=TASK_ID, default_hour=h, default_minute=m)
_seed_schedule(child_id=CHILD_ID_2, task_id=TASK_ID_2, default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert len(result) == 2
child_ids = {r['child_id'] for r in result}
assert CHILD_ID in child_ids
assert CHILD_ID_2 in child_ids
# ---------------------------------------------------------------------------
# _build_push_payload tests
# ---------------------------------------------------------------------------
class TestBuildPushPayload:
def test_single_chore_sets_title_and_deep_link(self):
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
payload = _build_push_payload(expiring)
assert payload['title'] == "Chore ending soon: Clean Room"
assert payload['child_id'] == "c1"
assert payload['entity_id'] == "t1"
assert payload['type'] == 'chore_expiring_soon'
def test_multiple_chores_uses_generic_title(self):
expiring = [
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
]
payload = _build_push_payload(expiring)
assert payload['title'] == "Chores ending soon"
assert payload['child_id'] is None
assert payload['entity_id'] is None
def test_body_groups_by_child(self):
expiring = [
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
{"child_id": "c2", "child_name": "Sam", "task_id": "t3", "task_name": "Trash"},
]
payload = _build_push_payload(expiring)
assert "Alex: Clean Room, Make Bed" in payload['body']
assert "Sam: Trash" in payload['body']
def test_no_approve_deny_tokens(self):
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
payload = _build_push_payload(expiring)
assert 'approve_token' not in payload
assert 'deny_token' not in payload
# ---------------------------------------------------------------------------
# send_chore_expiry_notifications_for_user tests
# ---------------------------------------------------------------------------
class TestSendChoreExpiryNotificationsForUser:
def setup_method(self):
_cleanup()
_seed_user()
_seed_child()
_seed_task()
def teardown_method(self):
_cleanup()
def test_sends_push_when_chore_expiring(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
assert count == 1
mock_push.assert_called_once()
payload = mock_push.call_args[0][1]
assert payload['type'] == 'chore_expiring_soon'
def test_no_push_when_no_expiring_chores(self, app):
# No schedule seeded → nothing expiring
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
assert count == 0
mock_push.assert_not_called()
# ---------------------------------------------------------------------------
# run_chore_expiry_check tests
# ---------------------------------------------------------------------------
class TestRunChoreExpiryCheck:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_skips_when_db_env_is_e2e(self, app):
with patch.dict(os.environ, {'DB_ENV': 'e2e'}):
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_not_called()
def test_skips_user_with_push_disabled(self, app):
_seed_user(push_enabled=False)
_seed_child()
_seed_task()
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_not_called()
def test_skips_unverified_user(self, app):
_seed_user(verified=False)
_seed_child()
_seed_task()
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_not_called()
def test_sends_push_for_eligible_user(self, app):
_seed_user(push_enabled=True, verified=True)
_seed_child()
_seed_task()
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_called_once()