Add TimeSelector and ScheduleModal components with tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s

- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality.
- Created ScheduleModal component for scheduling chores with options for specific days or intervals.
- Added utility functions for scheduling logic in scheduleUtils.ts.
- Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
This commit is contained in:
2026-02-23 15:44:55 -05:00
parent d8822b44be
commit 234adbe05f
26 changed files with 2880 additions and 60 deletions

View File

@@ -4,11 +4,12 @@ import os
from flask import Flask
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
from tinydb import Query
from models.child import Child
import jwt
from werkzeug.security import generate_password_hash
from datetime import date as date_type
# Test user credentials
@@ -348,4 +349,142 @@ def test_assignable_rewards_multiple_user_same_name(client):
ids = [r['id'] for r in data['rewards']]
# Both user rewards should be present, not the system one
assert set(names) == {'Prize'}
assert set(ids) == {'userr1', 'userr2'}
assert set(ids) == {'userr1', 'userr2'}
# ---------------------------------------------------------------------------
# list-tasks: schedule and extension_date fields
# ---------------------------------------------------------------------------
CHILD_SCHED_ID = 'child_sched_test'
TASK_GOOD_ID = 'task_sched_good'
TASK_BAD_ID = 'task_sched_bad'
def _setup_sched_child_and_tasks(task_db, child_db):
task_db.remove(Query().id == TASK_GOOD_ID)
task_db.remove(Query().id == TASK_BAD_ID)
task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'is_good': False, 'user_id': 'testuserid'})
child_db.remove(Query().id == CHILD_SCHED_ID)
child_db.insert({
'id': CHILD_SCHED_ID,
'name': 'SchedKid',
'age': 7,
'points': 0,
'tasks': [TASK_GOOD_ID, TASK_BAD_ID],
'rewards': [],
'user_id': 'testuserid',
})
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
"""Every task in the response must have 'schedule' and 'extension_date' keys."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
for task in resp.get_json()['tasks']:
assert 'schedule' in task
assert 'extension_date' in task
def test_list_child_tasks_returns_schedule_when_set(client):
"""Good chore with a saved schedule returns that schedule object."""
_setup_sched_child_and_tasks(task_db, child_db)
chore_schedules_db.insert({
'id': 'sched-1',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'mode': 'days',
'day_configs': [{'day': 1, 'hour': 8, 'minute': 0}],
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
sched = tasks[TASK_GOOD_ID]['schedule']
assert sched is not None
assert sched['mode'] == 'days'
assert sched['day_configs'] == [{'day': 1, 'hour': 8, 'minute': 0}]
def test_list_child_tasks_schedule_null_when_not_set(client):
"""Good chore with no schedule returns schedule=null."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['schedule'] is None
def test_list_child_tasks_returns_extension_date_when_set(client):
"""Good chore with a TaskExtension for today returns today's ISO date."""
_setup_sched_child_and_tasks(task_db, child_db)
today = date_type.today().isoformat()
task_extensions_db.insert({
'id': 'ext-1',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'date': today,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] == today
def test_list_child_tasks_extension_date_null_when_not_set(client):
"""Good chore with no extension returns extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] is None
def test_list_child_tasks_schedule_and_extension_null_for_penalties(client):
"""Penalty tasks (is_good=False) always return schedule=null and extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
# Even if we insert a schedule entry for the penalty task, the endpoint should ignore it
chore_schedules_db.insert({
'id': 'sched-bad',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_BAD_ID,
'mode': 'days',
'day_configs': [{'day': 0, 'hour': 9, 'minute': 0}],
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_BAD_ID]['schedule'] is None
assert tasks[TASK_BAD_ID]['extension_date'] is None
def test_list_child_tasks_no_server_side_filtering(client):
"""All assigned tasks are returned regardless of schedule — no server-side day/time filtering."""
_setup_sched_child_and_tasks(task_db, child_db)
# Add a second good task that has a schedule for only Sunday (day=0)
extra_id = 'task_sched_extra'
task_db.remove(Query().id == extra_id)
task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
child_db.update({'tasks': [TASK_GOOD_ID, TASK_BAD_ID, extra_id]}, Query().id == CHILD_SCHED_ID)
chore_schedules_db.insert({
'id': 'sched-extra',
'child_id': CHILD_SCHED_ID,
'task_id': extra_id,
'mode': 'days',
'day_configs': [{'day': 0, 'hour': 7, 'minute': 0}], # Sunday only
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
# Both good tasks must be present; server never filters based on schedule/time
assert TASK_GOOD_ID in returned_ids
assert extra_id in returned_ids

View File

@@ -0,0 +1,243 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.chore_schedule_api import chore_schedule_api
from api.auth_api import auth_api
from db.db import users_db, child_db, chore_schedules_db, task_extensions_db
from tinydb import Query
TEST_EMAIL = "sched_test@example.com"
TEST_PASSWORD = "testpass"
TEST_CHILD_ID = "sched-child-1"
TEST_TASK_ID = "sched-task-1"
TEST_USER_ID = "sched-user-1"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Sched",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "",
})
def add_test_child():
child_db.remove(Query().id == TEST_CHILD_ID)
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Test Child",
"points": 0,
"image_id": "",
"tasks": [TEST_TASK_ID],
"rewards": [],
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(chore_schedule_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
add_test_user()
add_test_child()
chore_schedules_db.truncate()
task_extensions_db.truncate()
login_and_set_cookie(client)
yield client
# ---------------------------------------------------------------------------
# GET schedule
# ---------------------------------------------------------------------------
def test_get_schedule_not_found(client):
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 404
def test_get_schedule_returns_404_for_unknown_child(client):
resp = client.get('/child/bad-child/task/bad-task/schedule')
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT (set) schedule days mode
# ---------------------------------------------------------------------------
def test_set_schedule_days_mode(client):
payload = {
"mode": "days",
"day_configs": [
{"day": 1, "hour": 8, "minute": 0},
{"day": 3, "hour": 9, "minute": 30},
],
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "days"
assert len(data["day_configs"]) == 2
assert data["child_id"] == TEST_CHILD_ID
assert data["task_id"] == TEST_TASK_ID
def test_get_schedule_after_set(client):
payload = {"mode": "days", "day_configs": [{"day": 0, "hour": 7, "minute": 0}]}
client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "days"
assert data["day_configs"][0]["day"] == 0
# ---------------------------------------------------------------------------
# PUT (set) schedule interval mode
# ---------------------------------------------------------------------------
def test_set_schedule_interval_mode(client):
payload = {
"mode": "interval",
"interval_days": 3,
"anchor_weekday": 2,
"interval_hour": 14,
"interval_minute": 30,
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "interval"
assert data["interval_days"] == 3
assert data["anchor_weekday"] == 2
assert data["interval_hour"] == 14
assert data["interval_minute"] == 30
def test_set_schedule_interval_bad_days(client):
# interval_days = 1 is out of range [27]
payload = {"mode": "interval", "interval_days": 1, "anchor_weekday": 0}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_interval_bad_weekday(client):
# anchor_weekday = 7 is out of range [06]
payload = {"mode": "interval", "interval_days": 2, "anchor_weekday": 7}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_invalid_mode(client):
payload = {"mode": "weekly", "day_configs": []}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_upserts_existing(client):
# Set once with days mode
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "days", "day_configs": [{"day": 1, "hour": 8, "minute": 0}]},
)
# Overwrite with interval mode
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "interval", "interval_days": 2, "anchor_weekday": 0, "interval_hour": 9, "interval_minute": 0},
)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
assert resp.get_json()["mode"] == "interval"
# ---------------------------------------------------------------------------
# DELETE schedule
# ---------------------------------------------------------------------------
def test_delete_schedule(client):
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "days", "day_configs": []},
)
resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
# Verify gone
resp2 = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp2.status_code == 404
def test_delete_schedule_not_found(client):
chore_schedules_db.truncate()
resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# POST extend
# ---------------------------------------------------------------------------
def test_extend_chore_time(client):
resp = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["child_id"] == TEST_CHILD_ID
assert data["task_id"] == TEST_TASK_ID
assert data["date"] == "2025-01-15"
def test_extend_chore_time_duplicate_returns_409(client):
task_extensions_db.truncate()
client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
resp = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
assert resp.status_code == 409
def test_extend_chore_time_different_dates_allowed(client):
task_extensions_db.truncate()
r1 = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
r2 = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-16"},
)
assert r1.status_code == 200
assert r2.status_code == 200
def test_extend_chore_time_missing_date(client):
resp = client.post(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={})
assert resp.status_code == 400
def test_extend_chore_time_bad_child(client):
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
assert resp.status_code == 404