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
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:
@@ -28,6 +28,9 @@ from models.tracking_event import TrackingEvent
|
||||
from api.utils import get_validated_user_id
|
||||
from utils.tracking_logger import log_tracking_event
|
||||
from collections import defaultdict
|
||||
from db.chore_schedules import get_schedule
|
||||
from db.task_extensions import get_extension
|
||||
from datetime import date as date_type
|
||||
import logging
|
||||
|
||||
child_api = Blueprint('child_api', __name__)
|
||||
@@ -273,6 +276,18 @@ def list_child_tasks(id):
|
||||
ct_dict = ct.to_dict()
|
||||
if custom_value is not None:
|
||||
ct_dict['custom_value'] = custom_value
|
||||
|
||||
# Attach schedule and most recent extension_date (client does all time math)
|
||||
if task.get('is_good'):
|
||||
schedule = get_schedule(id, tid)
|
||||
ct_dict['schedule'] = schedule.to_dict() if schedule else None
|
||||
today_str = date_type.today().isoformat()
|
||||
ext = get_extension(id, tid, today_str)
|
||||
ct_dict['extension_date'] = ext.date if ext else None
|
||||
else:
|
||||
ct_dict['schedule'] = None
|
||||
ct_dict['extension_date'] = None
|
||||
|
||||
child_tasks.append(ct_dict)
|
||||
|
||||
return jsonify({'tasks': child_tasks}), 200
|
||||
|
||||
144
backend/api/chore_schedule_api.py
Normal file
144
backend/api/chore_schedule_api.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||
from api.error_codes import ErrorCodes
|
||||
from db.db import child_db
|
||||
from db.chore_schedules import get_schedule, upsert_schedule, delete_schedule
|
||||
from db.task_extensions import get_extension, add_extension
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
from models.task_extension import TaskExtension
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.chore_schedule_modified import ChoreScheduleModified
|
||||
from events.types.chore_time_extended import ChoreTimeExtended
|
||||
import logging
|
||||
|
||||
chore_schedule_api = Blueprint('chore_schedule_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_child(child_id: str, user_id: str):
|
||||
"""Return child dict if found and owned by user, else None."""
|
||||
ChildQuery = Query()
|
||||
result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['GET'])
|
||||
def get_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
schedule = get_schedule(child_id, task_id)
|
||||
if not schedule:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['PUT'])
|
||||
def set_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
mode = data.get('mode')
|
||||
if mode not in ('days', 'interval'):
|
||||
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
if mode == 'days':
|
||||
day_configs = data.get('day_configs', [])
|
||||
if not isinstance(day_configs, list):
|
||||
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
mode='days',
|
||||
day_configs=day_configs,
|
||||
)
|
||||
else:
|
||||
interval_days = data.get('interval_days', 2)
|
||||
anchor_weekday = data.get('anchor_weekday', 0)
|
||||
interval_hour = data.get('interval_hour', 0)
|
||||
interval_minute = data.get('interval_minute', 0)
|
||||
if not isinstance(interval_days, int) or not (2 <= interval_days <= 7):
|
||||
return jsonify({'error': 'interval_days must be an integer between 2 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
if not isinstance(anchor_weekday, int) or not (0 <= anchor_weekday <= 6):
|
||||
return jsonify({'error': 'anchor_weekday must be an integer between 0 and 6', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
mode='interval',
|
||||
interval_days=interval_days,
|
||||
anchor_weekday=anchor_weekday,
|
||||
interval_hour=interval_hour,
|
||||
interval_minute=interval_minute,
|
||||
)
|
||||
|
||||
upsert_schedule(schedule)
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_SCHEDULE_MODIFIED.value,
|
||||
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_SET)
|
||||
))
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['DELETE'])
|
||||
def delete_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
removed = delete_schedule(child_id, task_id)
|
||||
if not removed:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_SCHEDULE_MODIFIED.value,
|
||||
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_DELETED)
|
||||
))
|
||||
|
||||
return jsonify({'message': 'Schedule deleted'}), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/extend', methods=['POST'])
|
||||
def extend_chore_time(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
date = data.get('date')
|
||||
if not date or not isinstance(date, str):
|
||||
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
# 409 if already extended for this date
|
||||
existing = get_extension(child_id, task_id, date)
|
||||
if existing:
|
||||
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
|
||||
|
||||
extension = TaskExtension(child_id=child_id, task_id=task_id, date=date)
|
||||
add_extension(extension)
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_TIME_EXTENDED.value,
|
||||
ChoreTimeExtended(child_id, task_id)
|
||||
))
|
||||
|
||||
return jsonify(extension.to_dict()), 200
|
||||
39
backend/db/chore_schedules.py
Normal file
39
backend/db/chore_schedules.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from db.db import chore_schedules_db
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
from tinydb import Query
|
||||
|
||||
|
||||
def get_schedule(child_id: str, task_id: str) -> ChoreSchedule | None:
|
||||
q = Query()
|
||||
result = chore_schedules_db.search((q.child_id == child_id) & (q.task_id == task_id))
|
||||
if not result:
|
||||
return None
|
||||
return ChoreSchedule.from_dict(result[0])
|
||||
|
||||
|
||||
def upsert_schedule(schedule: ChoreSchedule) -> None:
|
||||
q = Query()
|
||||
existing = chore_schedules_db.get((q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
|
||||
if existing:
|
||||
chore_schedules_db.update(schedule.to_dict(), (q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
|
||||
else:
|
||||
chore_schedules_db.insert(schedule.to_dict())
|
||||
|
||||
|
||||
def delete_schedule(child_id: str, task_id: str) -> bool:
|
||||
q = Query()
|
||||
existing = chore_schedules_db.get((q.child_id == child_id) & (q.task_id == task_id))
|
||||
if not existing:
|
||||
return False
|
||||
chore_schedules_db.remove((q.child_id == child_id) & (q.task_id == task_id))
|
||||
return True
|
||||
|
||||
|
||||
def delete_schedules_for_child(child_id: str) -> None:
|
||||
q = Query()
|
||||
chore_schedules_db.remove(q.child_id == child_id)
|
||||
|
||||
|
||||
def delete_schedules_for_task(task_id: str) -> None:
|
||||
q = Query()
|
||||
chore_schedules_db.remove(q.task_id == task_id)
|
||||
@@ -75,6 +75,8 @@ pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||
users_path = os.path.join(base_dir, 'users.json')
|
||||
tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
|
||||
child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
|
||||
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
|
||||
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
|
||||
|
||||
# Use separate TinyDB instances/files for each collection
|
||||
_child_db = TinyDB(child_path, indent=2)
|
||||
@@ -85,6 +87,8 @@ _pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||
_users_db = TinyDB(users_path, indent=2)
|
||||
_tracking_events_db = TinyDB(tracking_events_path, indent=2)
|
||||
_child_overrides_db = TinyDB(child_overrides_path, indent=2)
|
||||
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
|
||||
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
|
||||
|
||||
# Expose table objects wrapped with locking
|
||||
child_db = LockedTable(_child_db)
|
||||
@@ -95,6 +99,8 @@ pending_reward_db = LockedTable(_pending_rewards_db)
|
||||
users_db = LockedTable(_users_db)
|
||||
tracking_events_db = LockedTable(_tracking_events_db)
|
||||
child_overrides_db = LockedTable(_child_overrides_db)
|
||||
chore_schedules_db = LockedTable(_chore_schedules_db)
|
||||
task_extensions_db = LockedTable(_task_extensions_db)
|
||||
|
||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
child_db.truncate()
|
||||
@@ -105,4 +111,6 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
users_db.truncate()
|
||||
tracking_events_db.truncate()
|
||||
child_overrides_db.truncate()
|
||||
chore_schedules_db.truncate()
|
||||
task_extensions_db.truncate()
|
||||
|
||||
|
||||
27
backend/db/task_extensions.py
Normal file
27
backend/db/task_extensions.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from db.db import task_extensions_db
|
||||
from models.task_extension import TaskExtension
|
||||
from tinydb import Query
|
||||
|
||||
|
||||
def get_extension(child_id: str, task_id: str, date: str) -> TaskExtension | None:
|
||||
q = Query()
|
||||
result = task_extensions_db.search(
|
||||
(q.child_id == child_id) & (q.task_id == task_id) & (q.date == date)
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
return TaskExtension.from_dict(result[0])
|
||||
|
||||
|
||||
def add_extension(extension: TaskExtension) -> None:
|
||||
task_extensions_db.insert(extension.to_dict())
|
||||
|
||||
|
||||
def delete_extensions_for_child(child_id: str) -> None:
|
||||
q = Query()
|
||||
task_extensions_db.remove(q.child_id == child_id)
|
||||
|
||||
|
||||
def delete_extensions_for_task(task_id: str) -> None:
|
||||
q = Query()
|
||||
task_extensions_db.remove(q.task_id == task_id)
|
||||
25
backend/events/types/chore_schedule_modified.py
Normal file
25
backend/events/types/chore_schedule_modified.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChoreScheduleModified(Payload):
|
||||
OPERATION_SET = 'SET'
|
||||
OPERATION_DELETED = 'DELETED'
|
||||
|
||||
def __init__(self, child_id: str, task_id: str, operation: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'task_id': task_id,
|
||||
'operation': operation,
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get('child_id')
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get('task_id')
|
||||
|
||||
@property
|
||||
def operation(self) -> str:
|
||||
return self.get('operation')
|
||||
17
backend/events/types/chore_time_extended.py
Normal file
17
backend/events/types/chore_time_extended.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChoreTimeExtended(Payload):
|
||||
def __init__(self, child_id: str, task_id: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'task_id': task_id,
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get('child_id')
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get('task_id')
|
||||
@@ -23,3 +23,6 @@ class EventType(Enum):
|
||||
CHILD_OVERRIDE_DELETED = "child_override_deleted"
|
||||
|
||||
PROFILE_UPDATED = "profile_updated"
|
||||
|
||||
CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified"
|
||||
CHORE_TIME_EXTENDED = "chore_time_extended"
|
||||
|
||||
@@ -9,6 +9,7 @@ from api.admin_api import admin_api
|
||||
from api.auth_api import auth_api
|
||||
from api.child_api import child_api
|
||||
from api.child_override_api import child_override_api
|
||||
from api.chore_schedule_api import chore_schedule_api
|
||||
from api.image_api import image_api
|
||||
from api.reward_api import reward_api
|
||||
from api.task_api import task_api
|
||||
@@ -37,6 +38,7 @@ app = Flask(__name__)
|
||||
app.register_blueprint(admin_api)
|
||||
app.register_blueprint(child_api)
|
||||
app.register_blueprint(child_override_api)
|
||||
app.register_blueprint(chore_schedule_api)
|
||||
app.register_blueprint(reward_api)
|
||||
app.register_blueprint(task_api)
|
||||
app.register_blueprint(image_api)
|
||||
|
||||
71
backend/models/chore_schedule.py
Normal file
71
backend/models/chore_schedule.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
from models.base import BaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayConfig:
|
||||
day: int # 0=Sun, 1=Mon, ..., 6=Sat
|
||||
hour: int # 0–23 (24h)
|
||||
minute: int # 0, 15, 30, or 45
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'day': self.day,
|
||||
'hour': self.hour,
|
||||
'minute': self.minute,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'DayConfig':
|
||||
return cls(
|
||||
day=d.get('day', 0),
|
||||
hour=d.get('hour', 0),
|
||||
minute=d.get('minute', 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoreSchedule(BaseModel):
|
||||
child_id: str
|
||||
task_id: str
|
||||
mode: Literal['days', 'interval']
|
||||
|
||||
# mode='days' fields
|
||||
day_configs: list = field(default_factory=list) # list of DayConfig dicts
|
||||
|
||||
# mode='interval' fields
|
||||
interval_days: int = 2 # 2–7
|
||||
anchor_weekday: int = 0 # 0=Sun–6=Sat
|
||||
interval_hour: int = 0
|
||||
interval_minute: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'ChoreSchedule':
|
||||
return cls(
|
||||
child_id=d.get('child_id'),
|
||||
task_id=d.get('task_id'),
|
||||
mode=d.get('mode', 'days'),
|
||||
day_configs=d.get('day_configs', []),
|
||||
interval_days=d.get('interval_days', 2),
|
||||
anchor_weekday=d.get('anchor_weekday', 0),
|
||||
interval_hour=d.get('interval_hour', 0),
|
||||
interval_minute=d.get('interval_minute', 0),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
'child_id': self.child_id,
|
||||
'task_id': self.task_id,
|
||||
'mode': self.mode,
|
||||
'day_configs': self.day_configs,
|
||||
'interval_days': self.interval_days,
|
||||
'anchor_weekday': self.anchor_weekday,
|
||||
'interval_hour': self.interval_hour,
|
||||
'interval_minute': self.interval_minute,
|
||||
})
|
||||
return base
|
||||
29
backend/models/task_extension.py
Normal file
29
backend/models/task_extension.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from dataclasses import dataclass
|
||||
from models.base import BaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskExtension(BaseModel):
|
||||
child_id: str
|
||||
task_id: str
|
||||
date: str # ISO date string supplied by client, e.g. '2026-02-22'
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'TaskExtension':
|
||||
return cls(
|
||||
child_id=d.get('child_id'),
|
||||
task_id=d.get('task_id'),
|
||||
date=d.get('date'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
'child_id': self.child_id,
|
||||
'task_id': self.task_id,
|
||||
'date': self.date,
|
||||
})
|
||||
return base
|
||||
@@ -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
|
||||
243
backend/tests/test_chore_schedule_api.py
Normal file
243
backend/tests/test_chore_schedule_api.py
Normal 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 [2–7]
|
||||
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 [0–6]
|
||||
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
|
||||
Reference in New Issue
Block a user