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.
545 lines
20 KiB
Python
545 lines
20 KiB
Python
"""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()
|