feat: add PendingRewardDialog, RewardConfirmDialog, and TaskConfirmDialog components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 25s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 25s
- Implemented PendingRewardDialog for handling pending reward requests. - Created RewardConfirmDialog for confirming reward redemption. - Developed TaskConfirmDialog for task confirmation with child name display. test: add unit tests for ChildView and ParentView components - Added comprehensive tests for ChildView including task triggering and SSE event handling. - Implemented tests for ParentView focusing on override modal and SSE event management. test: add ScrollingList component tests - Created tests for ScrollingList to verify item fetching, loading states, and custom item classes. - Included tests for two-step click interactions and edit button display logic. - Moved toward hashed passwords.
This commit is contained in:
@@ -6,6 +6,7 @@ from flask import Blueprint, request, jsonify, current_app
|
||||
from tinydb import Query
|
||||
import os
|
||||
import utils.email_sender as email_sender
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from api.utils import sanitize_email
|
||||
from config.paths import get_user_image_dir
|
||||
@@ -47,7 +48,7 @@ def signup():
|
||||
first_name=data['first_name'],
|
||||
last_name=data['last_name'],
|
||||
email=norm_email,
|
||||
password=data['password'], # Hash in production!
|
||||
password=generate_password_hash(data['password']),
|
||||
verified=False,
|
||||
verify_token=token,
|
||||
verify_token_created=now_iso,
|
||||
@@ -140,7 +141,7 @@ def login():
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user or user.password != password:
|
||||
if not user or not check_password_hash(user.password, password):
|
||||
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
||||
|
||||
if not user.verified:
|
||||
@@ -254,7 +255,7 @@ def reset_password():
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||
|
||||
user.password = new_password # Hash in production!
|
||||
user.password = generate_password_hash(new_password)
|
||||
user.reset_token = None
|
||||
user.reset_token_created = None
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
@@ -10,6 +10,7 @@ from api.reward_status import RewardStatus
|
||||
from api.utils import send_event_for_current_user
|
||||
from db.db import child_db, task_db, reward_db, pending_reward_db
|
||||
from db.tracking import insert_tracking_event
|
||||
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
||||
from events.types.child_modified import ChildModified
|
||||
from events.types.child_reward_request import ChildRewardRequest
|
||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||
@@ -133,6 +134,12 @@ def delete_child(id):
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
ChildQuery = Query()
|
||||
|
||||
# Cascade delete overrides for this child
|
||||
deleted_count = delete_overrides_for_child(id)
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
|
||||
|
||||
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||
if resp:
|
||||
@@ -192,6 +199,17 @@ def set_child_tasks(id):
|
||||
|
||||
# Convert back to list if needed
|
||||
new_tasks = list(new_task_ids)
|
||||
|
||||
# Identify unassigned tasks and delete their overrides
|
||||
old_task_ids = set(child.tasks)
|
||||
unassigned_task_ids = old_task_ids - new_task_ids
|
||||
for task_id in unassigned_task_ids:
|
||||
# Only delete overrides for task entities
|
||||
override = get_override(id, task_id)
|
||||
if override and override.entity_type == 'task':
|
||||
delete_override(id, task_id)
|
||||
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
|
||||
|
||||
# Replace tasks with validated IDs
|
||||
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks)))
|
||||
@@ -246,8 +264,16 @@ def list_child_tasks(id):
|
||||
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
if not task:
|
||||
continue
|
||||
|
||||
# Check for override
|
||||
override = get_override(id, tid)
|
||||
custom_value = override.custom_value if override else None
|
||||
|
||||
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
|
||||
child_tasks.append(ct.to_dict())
|
||||
ct_dict = ct.to_dict()
|
||||
if custom_value is not None:
|
||||
ct_dict['custom_value'] = custom_value
|
||||
child_tasks.append(ct_dict)
|
||||
|
||||
return jsonify({'tasks': child_tasks}), 200
|
||||
|
||||
@@ -372,11 +398,15 @@ def trigger_child_task(id):
|
||||
# Capture points before modification
|
||||
points_before = child.points
|
||||
|
||||
# Check for override
|
||||
override = get_override(id, task_id)
|
||||
points_value = override.custom_value if override else task.points
|
||||
|
||||
# update the child's points based on task type
|
||||
if task.is_good:
|
||||
child.points += task.points
|
||||
child.points += points_value
|
||||
else:
|
||||
child.points -= task.points
|
||||
child.points -= points_value
|
||||
child.points = max(child.points, 0)
|
||||
|
||||
# update the child in the database
|
||||
@@ -384,6 +414,15 @@ def trigger_child_task(id):
|
||||
|
||||
# Create tracking event
|
||||
entity_type = 'penalty' if not task.is_good else 'task'
|
||||
tracking_metadata = {
|
||||
'task_name': task.name,
|
||||
'is_good': task.is_good,
|
||||
'default_points': task.points
|
||||
}
|
||||
if override:
|
||||
tracking_metadata['custom_points'] = override.custom_value
|
||||
tracking_metadata['has_override'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id,
|
||||
child_id=child.id,
|
||||
@@ -392,7 +431,7 @@ def trigger_child_task(id):
|
||||
action='activated',
|
||||
points_before=points_before,
|
||||
points_after=child.points,
|
||||
metadata={'task_name': task.name, 'is_good': task.is_good}
|
||||
metadata=tracking_metadata
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
@@ -494,6 +533,9 @@ def set_child_rewards(id):
|
||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
old_reward_ids = set(child.rewards)
|
||||
|
||||
# Optional: validate reward IDs exist in the reward DB
|
||||
RewardQuery = Query()
|
||||
@@ -501,6 +543,15 @@ def set_child_rewards(id):
|
||||
for rid in new_reward_ids:
|
||||
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
|
||||
valid_reward_ids.append(rid)
|
||||
|
||||
# Identify unassigned rewards and delete their overrides
|
||||
new_reward_ids_set = set(valid_reward_ids)
|
||||
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
|
||||
for reward_id in unassigned_reward_ids:
|
||||
override = get_override(id, reward_id)
|
||||
if override and override.entity_type == 'reward':
|
||||
delete_override(id, reward_id)
|
||||
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
|
||||
|
||||
# Replace rewards with validated IDs
|
||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||
@@ -553,8 +604,16 @@ def list_child_rewards(id):
|
||||
reward = reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
if not reward:
|
||||
continue
|
||||
|
||||
# Check for override
|
||||
override = get_override(id, rid)
|
||||
custom_value = override.custom_value if override else None
|
||||
|
||||
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
|
||||
child_rewards.append(cr.to_dict())
|
||||
cr_dict = cr.to_dict()
|
||||
if custom_value is not None:
|
||||
cr_dict['custom_value'] = custom_value
|
||||
child_rewards.append(cr_dict)
|
||||
|
||||
return jsonify({'rewards': child_rewards}), 200
|
||||
|
||||
@@ -618,15 +677,19 @@ def trigger_child_reward(id):
|
||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||
reward: Reward = Reward.from_dict(reward_result[0])
|
||||
|
||||
# Check for override
|
||||
override = get_override(id, reward_id)
|
||||
cost_value = override.custom_value if override else reward.cost
|
||||
|
||||
# Check if child has enough points
|
||||
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
|
||||
if child.points < reward.cost:
|
||||
points_needed = reward.cost - child.points
|
||||
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {cost_value} points')
|
||||
if child.points < cost_value:
|
||||
points_needed = cost_value - child.points
|
||||
return jsonify({
|
||||
'error': 'Insufficient points',
|
||||
'points_needed': points_needed,
|
||||
'current_points': child.points,
|
||||
'reward_cost': reward.cost
|
||||
'reward_cost': cost_value
|
||||
}), 400
|
||||
|
||||
# Remove matching pending reward requests for this child and reward
|
||||
@@ -641,11 +704,20 @@ def trigger_child_reward(id):
|
||||
points_before = child.points
|
||||
|
||||
# update the child's points based on reward cost
|
||||
child.points -= reward.cost
|
||||
child.points -= cost_value
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
|
||||
# Create tracking event
|
||||
tracking_metadata = {
|
||||
'reward_name': reward.name,
|
||||
'reward_cost': reward.cost,
|
||||
'default_cost': reward.cost
|
||||
}
|
||||
if override:
|
||||
tracking_metadata['custom_cost'] = override.custom_value
|
||||
tracking_metadata['has_override'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id,
|
||||
child_id=child.id,
|
||||
@@ -654,7 +726,7 @@ def trigger_child_reward(id):
|
||||
action='redeemed',
|
||||
points_before=points_before,
|
||||
points_after=child.points,
|
||||
metadata={'reward_name': reward.name, 'reward_cost': reward.cost}
|
||||
metadata=tracking_metadata
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
@@ -702,15 +774,24 @@ def reward_status(id):
|
||||
RewardQuery = Query()
|
||||
statuses = []
|
||||
for reward_id in reward_ids:
|
||||
reward: Reward = Reward.from_dict(reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))))
|
||||
if not reward:
|
||||
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
if not reward_dict:
|
||||
continue
|
||||
points_needed = max(0, reward.cost - points)
|
||||
reward: Reward = Reward.from_dict(reward_dict)
|
||||
|
||||
# Check for override
|
||||
override = get_override(id, reward_id)
|
||||
cost_value = override.custom_value if override else reward.cost
|
||||
points_needed = max(0, cost_value - points)
|
||||
|
||||
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
|
||||
pending_query = Query()
|
||||
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id) & (pending_query.user_id == user_id))
|
||||
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
|
||||
statuses.append(status.to_dict())
|
||||
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
|
||||
status_dict = status.to_dict()
|
||||
if override:
|
||||
status_dict['custom_value'] = override.custom_value
|
||||
statuses.append(status_dict)
|
||||
|
||||
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
|
||||
return jsonify({'reward_status': statuses}), 200
|
||||
|
||||
173
backend/api/child_override_api.py
Normal file
173
backend/api/child_override_api.py
Normal file
@@ -0,0 +1,173 @@
|
||||
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, task_db, reward_db
|
||||
from db.child_overrides import (
|
||||
insert_override,
|
||||
get_override,
|
||||
get_overrides_for_child,
|
||||
delete_override
|
||||
)
|
||||
from models.child_override import ChildOverride
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.child_override_set import ChildOverrideSetPayload
|
||||
from events.types.child_override_deleted import ChildOverrideDeletedPayload
|
||||
import logging
|
||||
|
||||
child_override_api = Blueprint('child_override_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@child_override_api.route('/child/<child_id>/override', methods=['PUT'])
|
||||
def set_child_override(child_id):
|
||||
"""
|
||||
Set or update a custom value for a task/reward for a specific child.
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
# Validate child exists and belongs to user
|
||||
ChildQuery = Query()
|
||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
if not child_result:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
child_dict = child_result[0]
|
||||
|
||||
# Parse request data
|
||||
data = request.get_json() or {}
|
||||
entity_id = data.get('entity_id')
|
||||
entity_type = data.get('entity_type')
|
||||
custom_value = data.get('custom_value')
|
||||
|
||||
# Validate required fields
|
||||
if not entity_id:
|
||||
return jsonify({'error': 'entity_id is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_id'}), 400
|
||||
if not entity_type:
|
||||
return jsonify({'error': 'entity_type is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_type'}), 400
|
||||
if custom_value is None:
|
||||
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
|
||||
|
||||
# Validate entity_type
|
||||
if entity_type not in ['task', 'reward']:
|
||||
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
|
||||
|
||||
# Validate custom_value range
|
||||
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
|
||||
return jsonify({'error': 'custom_value must be an integer between 0 and 10000', 'code': ErrorCodes.INVALID_VALUE, 'field': 'custom_value'}), 400
|
||||
|
||||
# Validate entity exists and is assigned to child
|
||||
if entity_type == 'task':
|
||||
EntityQuery = Query()
|
||||
entity_result = task_db.search(
|
||||
(EntityQuery.id == entity_id) &
|
||||
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
||||
)
|
||||
if not entity_result:
|
||||
return jsonify({'error': 'Task not found', 'code': ErrorCodes.TASK_NOT_FOUND}), 404
|
||||
|
||||
# Check if task is assigned to child
|
||||
assigned_tasks = child_dict.get('tasks', [])
|
||||
if entity_id not in assigned_tasks:
|
||||
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||
|
||||
else: # reward
|
||||
EntityQuery = Query()
|
||||
entity_result = reward_db.search(
|
||||
(EntityQuery.id == entity_id) &
|
||||
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
||||
)
|
||||
if not entity_result:
|
||||
return jsonify({'error': 'Reward not found', 'code': ErrorCodes.REWARD_NOT_FOUND}), 404
|
||||
|
||||
# Check if reward is assigned to child
|
||||
assigned_rewards = child_dict.get('rewards', [])
|
||||
if entity_id not in assigned_rewards:
|
||||
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||
|
||||
# Create and insert override
|
||||
try:
|
||||
override = ChildOverride.create_override(
|
||||
child_id=child_id,
|
||||
entity_id=entity_id,
|
||||
entity_type=entity_type,
|
||||
custom_value=custom_value
|
||||
)
|
||||
insert_override(override)
|
||||
|
||||
# Send SSE event
|
||||
resp = send_event_for_current_user(
|
||||
Event(EventType.CHILD_OVERRIDE_SET.value, ChildOverrideSetPayload(override))
|
||||
)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
return jsonify({'override': override.to_dict()}), 200
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e), 'code': ErrorCodes.VALIDATION_ERROR}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting override: {e}")
|
||||
return jsonify({'error': 'Internal server error', 'code': ErrorCodes.INTERNAL_ERROR}), 500
|
||||
|
||||
|
||||
@child_override_api.route('/child/<child_id>/overrides', methods=['GET'])
|
||||
def get_child_overrides(child_id):
|
||||
"""
|
||||
Get all overrides for a specific child.
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
# Validate child exists and belongs to user
|
||||
ChildQuery = Query()
|
||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
if not child_result:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
# Get all overrides for child
|
||||
overrides = get_overrides_for_child(child_id)
|
||||
|
||||
return jsonify({'overrides': [o.to_dict() for o in overrides]}), 200
|
||||
|
||||
|
||||
@child_override_api.route('/child/<child_id>/override/<entity_id>', methods=['DELETE'])
|
||||
def delete_child_override(child_id, entity_id):
|
||||
"""
|
||||
Delete an override (reset to default).
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
# Validate child exists and belongs to user
|
||||
ChildQuery = Query()
|
||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
if not child_result:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
# Get override to determine entity_type for event
|
||||
override = get_override(child_id, entity_id)
|
||||
if not override:
|
||||
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
|
||||
|
||||
entity_type = override.entity_type
|
||||
|
||||
# Delete override
|
||||
deleted = delete_override(child_id, entity_id)
|
||||
if not deleted:
|
||||
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
|
||||
|
||||
# Send SSE event
|
||||
resp = send_event_for_current_user(
|
||||
Event(EventType.CHILD_OVERRIDE_DELETED.value,
|
||||
ChildOverrideDeletedPayload(child_id, entity_id, entity_type))
|
||||
)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
return jsonify({'message': 'Override deleted'}), 200
|
||||
@@ -11,4 +11,18 @@ MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
|
||||
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
||||
NOT_VERIFIED = "NOT_VERIFIED"
|
||||
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
|
||||
ALREADY_MARKED = "ALREADY_MARKED"
|
||||
ALREADY_MARKED = "ALREADY_MARKED"
|
||||
|
||||
|
||||
class ErrorCodes:
|
||||
"""Centralized error codes for API responses."""
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
CHILD_NOT_FOUND = "CHILD_NOT_FOUND"
|
||||
TASK_NOT_FOUND = "TASK_NOT_FOUND"
|
||||
REWARD_NOT_FOUND = "REWARD_NOT_FOUND"
|
||||
ENTITY_NOT_ASSIGNED = "ENTITY_NOT_ASSIGNED"
|
||||
OVERRIDE_NOT_FOUND = "OVERRIDE_NOT_FOUND"
|
||||
MISSING_FIELD = "MISSING_FIELD"
|
||||
INVALID_VALUE = "INVALID_VALUE"
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
|
||||
@@ -4,6 +4,7 @@ from tinydb import Query
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from events.types.child_rewards_set import ChildRewardsSet
|
||||
from db.db import reward_db, child_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.reward_modified import RewardModified
|
||||
@@ -81,6 +82,12 @@ def delete_reward(id):
|
||||
return jsonify({'error': 'System rewards cannot be deleted.'}), 403
|
||||
removed = reward_db.remove((RewardQuery.id == id) & (RewardQuery.user_id == user_id))
|
||||
if removed:
|
||||
# Cascade delete overrides for this reward
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for reward {id}")
|
||||
|
||||
# remove the reward id from any child's reward list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
|
||||
@@ -4,6 +4,7 @@ from tinydb import Query
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from events.types.child_tasks_set import ChildTasksSet
|
||||
from db.db import task_db, child_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.task_modified import TaskModified
|
||||
@@ -79,6 +80,12 @@ def delete_task(id):
|
||||
return jsonify({'error': 'System tasks cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
# Cascade delete overrides for this task
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for task {id}")
|
||||
|
||||
# remove the task id from any child's task list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
|
||||
146
backend/db/child_overrides.py
Normal file
146
backend/db/child_overrides.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Helper functions for child override database operations."""
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from tinydb import Query
|
||||
from db.db import child_overrides_db
|
||||
from models.child_override import ChildOverride
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def insert_override(override: ChildOverride) -> str:
|
||||
"""
|
||||
Insert or update an override. Only one override per (child_id, entity_id).
|
||||
|
||||
Args:
|
||||
override: ChildOverride instance to insert or update
|
||||
|
||||
Returns:
|
||||
The override ID
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
existing = child_overrides_db.get(
|
||||
(OverrideQuery.child_id == override.child_id) &
|
||||
(OverrideQuery.entity_id == override.entity_id)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing override
|
||||
override.touch() # Update timestamp
|
||||
child_overrides_db.update(override.to_dict(), doc_ids=[existing.doc_id])
|
||||
logger.info(f"Override updated: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
|
||||
else:
|
||||
# Insert new override
|
||||
child_overrides_db.insert(override.to_dict())
|
||||
logger.info(f"Override created: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
|
||||
|
||||
return override.id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to insert override: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_override(child_id: str, entity_id: str) -> Optional[ChildOverride]:
|
||||
"""
|
||||
Get override for a specific child and entity.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
entity_id: Entity ID (task or reward)
|
||||
|
||||
Returns:
|
||||
ChildOverride instance or None if not found
|
||||
"""
|
||||
OverrideQuery = Query()
|
||||
result = child_overrides_db.get(
|
||||
(OverrideQuery.child_id == child_id) &
|
||||
(OverrideQuery.entity_id == entity_id)
|
||||
)
|
||||
return ChildOverride.from_dict(result) if result else None
|
||||
|
||||
|
||||
def get_overrides_for_child(child_id: str) -> List[ChildOverride]:
|
||||
"""
|
||||
Get all overrides for a specific child.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
|
||||
Returns:
|
||||
List of ChildOverride instances
|
||||
"""
|
||||
OverrideQuery = Query()
|
||||
results = child_overrides_db.search(OverrideQuery.child_id == child_id)
|
||||
return [ChildOverride.from_dict(r) for r in results]
|
||||
|
||||
|
||||
def delete_override(child_id: str, entity_id: str) -> bool:
|
||||
"""
|
||||
Delete a specific override.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
deleted = child_overrides_db.remove(
|
||||
(OverrideQuery.child_id == child_id) &
|
||||
(OverrideQuery.entity_id == entity_id)
|
||||
)
|
||||
if deleted:
|
||||
logger.info(f"Override deleted: child={child_id}, entity={entity_id}")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete override: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def delete_overrides_for_child(child_id: str) -> int:
|
||||
"""
|
||||
Delete all overrides for a child.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
|
||||
Returns:
|
||||
Count of deleted overrides
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
deleted = child_overrides_db.remove(OverrideQuery.child_id == child_id)
|
||||
count = len(deleted)
|
||||
if count > 0:
|
||||
logger.info(f"Overrides cascade deleted for child: child_id={child_id}, count={count}")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete overrides for child: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def delete_overrides_for_entity(entity_id: str) -> int:
|
||||
"""
|
||||
Delete all overrides for an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (task or reward)
|
||||
|
||||
Returns:
|
||||
Count of deleted overrides
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
deleted = child_overrides_db.remove(OverrideQuery.entity_id == entity_id)
|
||||
count = len(deleted)
|
||||
if count > 0:
|
||||
logger.info(f"Overrides cascade deleted for entity: entity_id={entity_id}, count={count}")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete overrides for entity: {e}")
|
||||
raise
|
||||
@@ -74,6 +74,7 @@ image_path = os.path.join(base_dir, 'images.json')
|
||||
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')
|
||||
|
||||
# Use separate TinyDB instances/files for each collection
|
||||
_child_db = TinyDB(child_path, indent=2)
|
||||
@@ -83,6 +84,7 @@ _image_db = TinyDB(image_path, indent=2)
|
||||
_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)
|
||||
|
||||
# Expose table objects wrapped with locking
|
||||
child_db = LockedTable(_child_db)
|
||||
@@ -92,6 +94,7 @@ image_db = LockedTable(_image_db)
|
||||
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)
|
||||
|
||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
child_db.truncate()
|
||||
@@ -101,4 +104,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
pending_reward_db.truncate()
|
||||
users_db.truncate()
|
||||
tracking_events_db.truncate()
|
||||
child_overrides_db.truncate()
|
||||
|
||||
|
||||
22
backend/events/types/child_override_deleted.py
Normal file
22
backend/events/types/child_override_deleted.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChildOverrideDeletedPayload(Payload):
|
||||
def __init__(self, child_id: str, entity_id: str, entity_type: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def entity_id(self) -> str:
|
||||
return self.get("entity_id")
|
||||
|
||||
@property
|
||||
def entity_type(self) -> str:
|
||||
return self.get("entity_type")
|
||||
13
backend/events/types/child_override_set.py
Normal file
13
backend/events/types/child_override_set.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from events.types.payload import Payload
|
||||
from models.child_override import ChildOverride
|
||||
|
||||
|
||||
class ChildOverrideSetPayload(Payload):
|
||||
def __init__(self, override: ChildOverride):
|
||||
super().__init__({
|
||||
'override': override.to_dict()
|
||||
})
|
||||
|
||||
@property
|
||||
def override(self) -> dict:
|
||||
return self.get("override")
|
||||
@@ -18,3 +18,6 @@ class EventType(Enum):
|
||||
USER_DELETED = "user_deleted"
|
||||
|
||||
TRACKING_EVENT_CREATED = "tracking_event_created"
|
||||
|
||||
CHILD_OVERRIDE_SET = "child_override_set"
|
||||
CHILD_OVERRIDE_DELETED = "child_override_deleted"
|
||||
|
||||
@@ -7,6 +7,7 @@ from flask_cors import CORS
|
||||
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.image_api import image_api
|
||||
from api.reward_api import reward_api
|
||||
from api.task_api import task_api
|
||||
@@ -33,6 +34,7 @@ app = Flask(__name__)
|
||||
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
||||
app.register_blueprint(admin_api)
|
||||
app.register_blueprint(child_api)
|
||||
app.register_blueprint(child_override_api)
|
||||
app.register_blueprint(reward_api)
|
||||
app.register_blueprint(task_api)
|
||||
app.register_blueprint(image_api)
|
||||
|
||||
64
backend/models/child_override.py
Normal file
64
backend/models/child_override.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from models.base import BaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChildOverride(BaseModel):
|
||||
"""
|
||||
Stores per-child customized points/cost for tasks, penalties, and rewards.
|
||||
|
||||
Attributes:
|
||||
child_id: ID of the child this override applies to
|
||||
entity_id: ID of the task/penalty/reward being customized
|
||||
entity_type: Type of entity ('task' or 'reward')
|
||||
custom_value: Custom points (for tasks/penalties) or cost (for rewards)
|
||||
"""
|
||||
child_id: str
|
||||
entity_id: str
|
||||
entity_type: Literal['task', 'reward']
|
||||
custom_value: int
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate custom_value range and entity_type."""
|
||||
if self.custom_value < 0 or self.custom_value > 10000:
|
||||
raise ValueError("custom_value must be between 0 and 10000")
|
||||
if self.entity_type not in ['task', 'reward']:
|
||||
raise ValueError("entity_type must be 'task' or 'reward'")
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
child_id=d.get('child_id'),
|
||||
entity_id=d.get('entity_id'),
|
||||
entity_type=d.get('entity_type'),
|
||||
custom_value=d.get('custom_value'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
'child_id': self.child_id,
|
||||
'entity_id': self.entity_id,
|
||||
'entity_type': self.entity_type,
|
||||
'custom_value': self.custom_value
|
||||
})
|
||||
return base
|
||||
|
||||
@staticmethod
|
||||
def create_override(
|
||||
child_id: str,
|
||||
entity_id: str,
|
||||
entity_type: Literal['task', 'reward'],
|
||||
custom_value: int
|
||||
) -> 'ChildOverride':
|
||||
"""Factory method to create a new override."""
|
||||
return ChildOverride(
|
||||
child_id=child_id,
|
||||
entity_id=entity_id,
|
||||
entity_type=entity_type,
|
||||
custom_value=custom_value
|
||||
)
|
||||
37
backend/scripts/hash_passwords.py
Normal file
37
backend/scripts/hash_passwords.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to hash existing plain text passwords in the database.
|
||||
Run this once after deploying password hashing to migrate existing users.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
from tinydb import Query
|
||||
from db.db import users_db
|
||||
from models.user import User
|
||||
|
||||
def main():
|
||||
users = users_db.all()
|
||||
updated_count = 0
|
||||
|
||||
for user_dict in users:
|
||||
user = User.from_dict(user_dict)
|
||||
# Check if password is already hashed (starts with scrypt: or $pbkdf2-sha256$)
|
||||
if not (user.password.startswith('scrypt:') or user.password.startswith('$pbkdf2-sha256$')):
|
||||
# Hash the plain text password
|
||||
user.password = generate_password_hash(user.password)
|
||||
# Update in database
|
||||
users_db.update(user.to_dict(), Query().id == user.id)
|
||||
updated_count += 1
|
||||
print(f"Hashed password for user {user.email}")
|
||||
else:
|
||||
print(f"Password already hashed for user {user.email}")
|
||||
|
||||
print(f"Migration complete. Updated {updated_count} users.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
from tinydb import Query
|
||||
main()
|
||||
94
backend/test_data/db/child_overrides.json
Normal file
94
backend/test_data/db/child_overrides.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"_default": {
|
||||
"1": {
|
||||
"id": "479920ee-4d2c-4ff9-a7e4-749691183903",
|
||||
"created_at": 1770772299.9946082,
|
||||
"updated_at": 1770772299.9946082,
|
||||
"child_id": "child1",
|
||||
"entity_id": "task1",
|
||||
"entity_type": "task",
|
||||
"custom_value": 20
|
||||
},
|
||||
"2": {
|
||||
"id": "e1212f17-1986-4ae2-9936-3e8c4a487a79",
|
||||
"created_at": 1770772300.0246155,
|
||||
"updated_at": 1770772300.0246155,
|
||||
"child_id": "child2",
|
||||
"entity_id": "task2",
|
||||
"entity_type": "task",
|
||||
"custom_value": 25
|
||||
},
|
||||
"3": {
|
||||
"id": "58068231-3bd8-425c-aba2-1e4444547f2b",
|
||||
"created_at": 1770772300.0326169,
|
||||
"updated_at": 1770772300.0326169,
|
||||
"child_id": "child3",
|
||||
"entity_id": "task1",
|
||||
"entity_type": "task",
|
||||
"custom_value": 10
|
||||
},
|
||||
"4": {
|
||||
"id": "21299d89-29d1-4876-abc8-080a919dfa27",
|
||||
"created_at": 1770772300.0326169,
|
||||
"updated_at": 1770772300.0326169,
|
||||
"child_id": "child3",
|
||||
"entity_id": "task2",
|
||||
"entity_type": "task",
|
||||
"custom_value": 15
|
||||
},
|
||||
"5": {
|
||||
"id": "4676589a-abcf-4407-806c-8d187a41dae3",
|
||||
"created_at": 1770772300.0326169,
|
||||
"updated_at": 1770772300.0326169,
|
||||
"child_id": "child3",
|
||||
"entity_id": "reward1",
|
||||
"entity_type": "reward",
|
||||
"custom_value": 100
|
||||
},
|
||||
"33": {
|
||||
"id": "cd1473e2-241c-4bfd-b4b2-c2b5402d95d6",
|
||||
"created_at": 1770772307.3772185,
|
||||
"updated_at": 1770772307.3772185,
|
||||
"child_id": "351c9e7f-5406-425c-a15a-2268aadbfdd5",
|
||||
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
|
||||
"entity_type": "task",
|
||||
"custom_value": 5
|
||||
},
|
||||
"34": {
|
||||
"id": "57ecb6f8-dff3-47a8-81a9-66979e1ce7b4",
|
||||
"created_at": 1770772307.3833773,
|
||||
"updated_at": 1770772307.3833773,
|
||||
"child_id": "f12a42a9-105a-4a6f-84e8-1c3a8e076d33",
|
||||
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
|
||||
"entity_type": "task",
|
||||
"custom_value": 20
|
||||
},
|
||||
"35": {
|
||||
"id": "d55b8b5c-39fc-449c-9848-99c2d572fdd8",
|
||||
"created_at": 1770772307.618762,
|
||||
"updated_at": 1770772307.618762,
|
||||
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
|
||||
"entity_id": "b64435a0-8856-4c8d-bf77-8438ff5d9061",
|
||||
"entity_type": "task",
|
||||
"custom_value": 0
|
||||
},
|
||||
"36": {
|
||||
"id": "a9777db2-6912-4b21-b668-4f36566d4ef8",
|
||||
"created_at": 1770772307.8648667,
|
||||
"updated_at": 1770772307.8648667,
|
||||
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
|
||||
"entity_id": "35cf2bde-9f47-4458-ac7b-36713063deb4",
|
||||
"entity_type": "task",
|
||||
"custom_value": 10000
|
||||
},
|
||||
"37": {
|
||||
"id": "04c54b24-914e-4ed6-b336-4263a4701c78",
|
||||
"created_at": 1770772308.104657,
|
||||
"updated_at": 1770772308.104657,
|
||||
"child_id": "48bccc00-6d76-4bc9-a371-836d1a7db200",
|
||||
"entity_id": "ba725bf7-2dc8-4bdb-8a82-6ed88519f2ff",
|
||||
"entity_type": "reward",
|
||||
"custom_value": 75
|
||||
}
|
||||
}
|
||||
}
|
||||
142
backend/tests/test_auth_api.py
Normal file
142
backend/tests/test_auth_api.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask import Flask
|
||||
from api.auth_api import auth_api
|
||||
from db.db import users_db
|
||||
from tinydb import Query
|
||||
from models.user import User
|
||||
from datetime import datetime
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Setup Flask test client with auth blueprint."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||
app.config['TESTING'] = True
|
||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
||||
app.config['FRONTEND_URL'] = 'http://localhost:5173'
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
def test_signup_hashes_password(client):
|
||||
"""Test that signup hashes the password."""
|
||||
# Clean up any existing user
|
||||
users_db.remove(Query().email == 'test@example.com')
|
||||
|
||||
data = {
|
||||
'first_name': 'Test',
|
||||
'last_name': 'User',
|
||||
'email': 'test@example.com',
|
||||
'password': 'password123'
|
||||
}
|
||||
response = client.post('/auth/signup', json=data)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Check that password is hashed in DB
|
||||
user_dict = users_db.get(Query().email == 'test@example.com')
|
||||
assert user_dict is not None
|
||||
assert user_dict['password'].startswith('scrypt:')
|
||||
|
||||
def test_login_with_correct_password(client):
|
||||
"""Test login succeeds with correct password."""
|
||||
# Clean up and create a user with hashed password
|
||||
users_db.remove(Query().email == 'test@example.com')
|
||||
hashed_pw = generate_password_hash('password123')
|
||||
user = User(
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
email='test@example.com',
|
||||
password=hashed_pw,
|
||||
verified=True
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
data = {'email': 'test@example.com', 'password': 'password123'}
|
||||
response = client.post('/auth/login', json=data)
|
||||
assert response.status_code == 200
|
||||
assert 'token' in response.headers.get('Set-Cookie', '')
|
||||
|
||||
def test_login_with_incorrect_password(client):
|
||||
"""Test login fails with incorrect password."""
|
||||
# Clean up and create a user with hashed password
|
||||
users_db.remove(Query().email == 'test@example.com')
|
||||
hashed_pw = generate_password_hash('password123')
|
||||
user = User(
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
email='test@example.com',
|
||||
password=hashed_pw,
|
||||
verified=True
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
data = {'email': 'test@example.com', 'password': 'wrongpassword'}
|
||||
response = client.post('/auth/login', json=data)
|
||||
assert response.status_code == 401
|
||||
assert response.json['code'] == 'INVALID_CREDENTIALS'
|
||||
|
||||
def test_reset_password_hashes_new_password(client):
|
||||
"""Test that reset-password hashes the new password."""
|
||||
# Clean up and create a user with reset token
|
||||
users_db.remove(Query().email == 'test@example.com')
|
||||
user = User(
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
email='test@example.com',
|
||||
password=generate_password_hash('oldpassword'),
|
||||
verified=True,
|
||||
reset_token='validtoken',
|
||||
reset_token_created=datetime.utcnow().isoformat()
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
data = {'token': 'validtoken', 'password': 'newpassword123'}
|
||||
response = client.post('/auth/reset-password', json=data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check that password is hashed in DB
|
||||
user_dict = users_db.get(Query().email == 'test@example.com')
|
||||
assert user_dict is not None
|
||||
assert user_dict['password'].startswith('scrypt:')
|
||||
assert check_password_hash(user_dict['password'], 'newpassword123')
|
||||
|
||||
def test_migration_script_hashes_plain_text_passwords():
|
||||
"""Test the migration script hashes plain text passwords."""
|
||||
# Clean up
|
||||
users_db.remove(Query().email == 'test1@example.com')
|
||||
users_db.remove(Query().email == 'test2@example.com')
|
||||
|
||||
# Create users with plain text passwords
|
||||
user1 = User(
|
||||
first_name='Test1',
|
||||
last_name='User',
|
||||
email='test1@example.com',
|
||||
password='plaintext1',
|
||||
verified=True
|
||||
)
|
||||
already_hashed = generate_password_hash('alreadyhashed')
|
||||
user2 = User(
|
||||
first_name='Test2',
|
||||
last_name='User',
|
||||
email='test2@example.com',
|
||||
password=already_hashed, # Already hashed
|
||||
verified=True
|
||||
)
|
||||
users_db.insert(user1.to_dict())
|
||||
users_db.insert(user2.to_dict())
|
||||
|
||||
# Run migration script
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from scripts.hash_passwords import main
|
||||
main()
|
||||
|
||||
# Check user1 password is now hashed
|
||||
user1_dict = users_db.get(Query().email == 'test1@example.com')
|
||||
assert user1_dict['password'].startswith('scrypt:')
|
||||
assert check_password_hash(user1_dict['password'], 'plaintext1')
|
||||
|
||||
# Check user2 password unchanged
|
||||
user2_dict = users_db.get(Query().email == 'test2@example.com')
|
||||
assert user2_dict['password'] == already_hashed
|
||||
@@ -8,6 +8,7 @@ from db.db import child_db, reward_db, task_db, users_db
|
||||
from tinydb import Query
|
||||
from models.child import Child
|
||||
import jwt
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
# Test user credentials
|
||||
@@ -22,7 +23,7 @@ def add_test_user():
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"password": generate_password_hash(TEST_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": "boy01"
|
||||
})
|
||||
|
||||
944
backend/tests/test_child_override_api.py
Normal file
944
backend/tests/test_child_override_api.py
Normal file
@@ -0,0 +1,944 @@
|
||||
"""Tests for child override API endpoints and integration."""
|
||||
import pytest
|
||||
import os
|
||||
from flask import Flask
|
||||
from unittest.mock import patch, MagicMock
|
||||
from tinydb import Query
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from models.child_override import ChildOverride
|
||||
from models.child import Child
|
||||
from models.task import Task
|
||||
from models.reward import Reward
|
||||
from db.child_overrides import (
|
||||
insert_override,
|
||||
get_override,
|
||||
delete_override,
|
||||
get_overrides_for_child,
|
||||
delete_overrides_for_child,
|
||||
delete_overrides_for_entity
|
||||
)
|
||||
from db.db import child_overrides_db, child_db, task_db, reward_db, users_db
|
||||
from api.child_override_api import child_override_api
|
||||
from api.child_api import child_api
|
||||
from api.auth_api import auth_api
|
||||
from events.types.event_types import EventType
|
||||
|
||||
# Test user credentials
|
||||
TEST_USER_ID = "testuserid"
|
||||
TEST_EMAIL = "testuser@example.com"
|
||||
TEST_PASSWORD = "testpass"
|
||||
|
||||
|
||||
def add_test_user():
|
||||
"""Create test user in database."""
|
||||
users_db.remove(Query().email == TEST_EMAIL)
|
||||
users_db.insert({
|
||||
"id": TEST_USER_ID,
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": generate_password_hash(TEST_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": "boy01"
|
||||
})
|
||||
|
||||
|
||||
def login_and_set_cookie(client):
|
||||
"""Login and set authentication cookie."""
|
||||
resp = client.post('/login', json={
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create Flask test client with authentication."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(child_override_api)
|
||||
app.register_blueprint(child_api)
|
||||
app.register_blueprint(auth_api)
|
||||
app.config['TESTING'] = True
|
||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
||||
|
||||
with app.test_client() as client:
|
||||
add_test_user()
|
||||
login_and_set_cookie(client)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task():
|
||||
"""Create a test task."""
|
||||
task = Task(name="Clean Room", points=10, is_good=True, image_id="task-icon.png")
|
||||
task_db.insert({**task.to_dict(), 'user_id': TEST_USER_ID})
|
||||
return task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reward():
|
||||
"""Create a test reward."""
|
||||
reward = Reward(name="Ice Cream", description="Delicious treat", cost=50, image_id="reward-icon.png")
|
||||
reward_db.insert({**reward.to_dict(), 'user_id': TEST_USER_ID})
|
||||
return reward
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def child_with_task(client, task):
|
||||
"""Create child and assign task."""
|
||||
# Create child via API
|
||||
resp = client.put('/child/add', json={'name': 'Alice', 'age': 8})
|
||||
assert resp.status_code == 201
|
||||
|
||||
# Get child ID
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
child = next(c for c in children if c['name'] == 'Alice')
|
||||
child_id = child['id']
|
||||
|
||||
# Assign task directly in database (bypass API validation)
|
||||
ChildQuery = Query()
|
||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task.id]
|
||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
||||
|
||||
return {
|
||||
'child_id': child_id,
|
||||
'task_id': task.id,
|
||||
'task': task,
|
||||
'default_points': 10
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def child_with_reward(client, reward):
|
||||
"""Create child and assign reward."""
|
||||
# Create child via API
|
||||
resp = client.put('/child/add', json={'name': 'Bob', 'age': 9})
|
||||
assert resp.status_code == 201
|
||||
|
||||
# Get child ID
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
child = next(c for c in children if c['name'] == 'Bob')
|
||||
child_id = child['id']
|
||||
|
||||
# Assign reward directly in database (bypass API validation)
|
||||
ChildQuery = Query()
|
||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
||||
child_doc['rewards'] = child_doc.get('rewards', []) + [reward.id]
|
||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
||||
|
||||
return {
|
||||
'child_id': child_id,
|
||||
'reward_id': reward.id,
|
||||
'reward': reward,
|
||||
'default_cost': 50
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def child_with_task_override(client, child_with_task):
|
||||
"""Create child with task and override."""
|
||||
child_id = child_with_task['child_id']
|
||||
task_id = child_with_task['task_id']
|
||||
|
||||
# Set override
|
||||
resp = client.put(f'/child/{child_id}/override', json={
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 15
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
return {**child_with_task, 'override_value': 15}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def child_with_reward_override(client, child_with_reward):
|
||||
"""Create child with reward and override."""
|
||||
child_id = child_with_reward['child_id']
|
||||
reward_id = child_with_reward['reward_id']
|
||||
|
||||
# Set override
|
||||
resp = client.put(f'/child/{child_id}/override', json={
|
||||
'entity_id': reward_id,
|
||||
'entity_type': 'reward',
|
||||
'custom_value': 75
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
return {**child_with_reward, 'override_value': 75}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sse():
|
||||
"""Mock SSE event broadcaster."""
|
||||
with patch('api.child_override_api.send_event_for_current_user') as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def cleanup_db():
|
||||
"""Cleanup database after all tests."""
|
||||
yield
|
||||
child_overrides_db.close()
|
||||
child_db.close()
|
||||
task_db.close()
|
||||
reward_db.close()
|
||||
users_db.close()
|
||||
|
||||
# Clean up test database files
|
||||
for filename in ['child_overrides.json', 'children.json', 'tasks.json', 'rewards.json', 'users.json']:
|
||||
if os.path.exists(filename):
|
||||
try:
|
||||
os.remove(filename)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
class TestChildOverrideModel:
|
||||
"""Test ChildOverride model validation."""
|
||||
|
||||
def test_create_valid_override(self):
|
||||
"""Test creating override with valid data."""
|
||||
override = ChildOverride.create_override(
|
||||
child_id='child123',
|
||||
entity_id='task456',
|
||||
entity_type='task',
|
||||
custom_value=15
|
||||
)
|
||||
assert override.child_id == 'child123'
|
||||
assert override.entity_id == 'task456'
|
||||
assert override.entity_type == 'task'
|
||||
assert override.custom_value == 15
|
||||
|
||||
def test_custom_value_negative_raises_error(self):
|
||||
"""Test custom_value < 0 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="custom_value must be between 0 and 10000"):
|
||||
ChildOverride(
|
||||
child_id='child123',
|
||||
entity_id='task456',
|
||||
entity_type='task',
|
||||
custom_value=-1
|
||||
)
|
||||
|
||||
def test_custom_value_too_large_raises_error(self):
|
||||
"""Test custom_value > 10000 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="custom_value must be between 0 and 10000"):
|
||||
ChildOverride(
|
||||
child_id='child123',
|
||||
entity_id='task456',
|
||||
entity_type='task',
|
||||
custom_value=10001
|
||||
)
|
||||
|
||||
def test_custom_value_zero_allowed(self):
|
||||
"""Test custom_value = 0 is valid."""
|
||||
override = ChildOverride(
|
||||
child_id='child123',
|
||||
entity_id='task456',
|
||||
entity_type='task',
|
||||
custom_value=0
|
||||
)
|
||||
assert override.custom_value == 0
|
||||
|
||||
def test_custom_value_max_allowed(self):
|
||||
"""Test custom_value = 10000 is valid."""
|
||||
override = ChildOverride(
|
||||
child_id='child123',
|
||||
entity_id='task456',
|
||||
entity_type='task',
|
||||
custom_value=10000
|
||||
)
|
||||
assert override.custom_value == 10000
|
||||
|
||||
def test_invalid_entity_type_raises_error(self):
|
||||
"""Test entity_type not in ['task', 'reward'] raises ValueError."""
|
||||
with pytest.raises(ValueError, match="entity_type must be 'task' or 'reward'"):
|
||||
ChildOverride(
|
||||
child_id='child123',
|
||||
entity_id='task456',
|
||||
entity_type='invalid',
|
||||
custom_value=15
|
||||
)
|
||||
|
||||
|
||||
class TestChildOverrideDB:
|
||||
"""Test database operations for child overrides."""
|
||||
|
||||
def test_insert_new_override(self):
|
||||
"""Test inserting new override."""
|
||||
override = ChildOverride.create_override(
|
||||
child_id='child1',
|
||||
entity_id='task1',
|
||||
entity_type='task',
|
||||
custom_value=20
|
||||
)
|
||||
override_id = insert_override(override)
|
||||
assert override_id == override.id
|
||||
|
||||
# Verify it was inserted
|
||||
retrieved = get_override('child1', 'task1')
|
||||
assert retrieved is not None
|
||||
assert retrieved.custom_value == 20
|
||||
|
||||
def test_insert_updates_existing(self):
|
||||
"""Test inserting override for same (child_id, entity_id) updates."""
|
||||
override1 = ChildOverride.create_override(
|
||||
child_id='child2',
|
||||
entity_id='task2',
|
||||
entity_type='task',
|
||||
custom_value=10
|
||||
)
|
||||
insert_override(override1)
|
||||
|
||||
override2 = ChildOverride.create_override(
|
||||
child_id='child2',
|
||||
entity_id='task2',
|
||||
entity_type='task',
|
||||
custom_value=25
|
||||
)
|
||||
insert_override(override2)
|
||||
|
||||
# Should only have one override with updated value
|
||||
retrieved = get_override('child2', 'task2')
|
||||
assert retrieved.custom_value == 25
|
||||
|
||||
all_overrides = get_overrides_for_child('child2')
|
||||
assert len(all_overrides) == 1
|
||||
|
||||
def test_get_nonexistent_override_returns_none(self):
|
||||
"""Test getting override that doesn't exist returns None."""
|
||||
result = get_override('nonexistent_child', 'nonexistent_task')
|
||||
assert result is None
|
||||
|
||||
def test_get_overrides_for_child(self):
|
||||
"""Test getting all overrides for a child."""
|
||||
child_id = 'child3'
|
||||
|
||||
override1 = ChildOverride.create_override(child_id, 'task1', 'task', 10)
|
||||
override2 = ChildOverride.create_override(child_id, 'task2', 'task', 15)
|
||||
override3 = ChildOverride.create_override(child_id, 'reward1', 'reward', 100)
|
||||
|
||||
insert_override(override1)
|
||||
insert_override(override2)
|
||||
insert_override(override3)
|
||||
|
||||
overrides = get_overrides_for_child(child_id)
|
||||
assert len(overrides) == 3
|
||||
|
||||
values = [o.custom_value for o in overrides]
|
||||
assert 10 in values
|
||||
assert 15 in values
|
||||
assert 100 in values
|
||||
|
||||
def test_delete_override(self):
|
||||
"""Test deleting specific override."""
|
||||
override = ChildOverride.create_override('child4', 'task4', 'task', 30)
|
||||
insert_override(override)
|
||||
|
||||
deleted = delete_override('child4', 'task4')
|
||||
assert deleted is True
|
||||
|
||||
# Verify it was deleted
|
||||
result = get_override('child4', 'task4')
|
||||
assert result is None
|
||||
|
||||
def test_delete_overrides_for_child(self):
|
||||
"""Test deleting all overrides for a child."""
|
||||
child_id = 'child5'
|
||||
|
||||
insert_override(ChildOverride.create_override(child_id, 'task1', 'task', 10))
|
||||
insert_override(ChildOverride.create_override(child_id, 'task2', 'task', 20))
|
||||
insert_override(ChildOverride.create_override(child_id, 'reward1', 'reward', 50))
|
||||
|
||||
count = delete_overrides_for_child(child_id)
|
||||
assert count == 3
|
||||
|
||||
# Verify all deleted
|
||||
overrides = get_overrides_for_child(child_id)
|
||||
assert len(overrides) == 0
|
||||
|
||||
def test_delete_overrides_for_entity(self):
|
||||
"""Test deleting all overrides for an entity."""
|
||||
entity_id = 'task99'
|
||||
|
||||
insert_override(ChildOverride.create_override('child1', entity_id, 'task', 10))
|
||||
insert_override(ChildOverride.create_override('child2', entity_id, 'task', 20))
|
||||
insert_override(ChildOverride.create_override('child3', entity_id, 'task', 30))
|
||||
|
||||
count = delete_overrides_for_entity(entity_id)
|
||||
assert count == 3
|
||||
|
||||
# Verify all deleted
|
||||
assert get_override('child1', entity_id) is None
|
||||
assert get_override('child2', entity_id) is None
|
||||
assert get_override('child3', entity_id) is None
|
||||
|
||||
|
||||
class TestChildOverrideAPIAuth:
|
||||
"""Test authentication and authorization."""
|
||||
|
||||
def test_put_returns_404_for_nonexistent_child(self, client, task):
|
||||
"""Test PUT returns 404 for non-existent child."""
|
||||
resp = client.put('/child/nonexistent-id/override', json={
|
||||
'entity_id': task.id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 20
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
assert b'Child not found' in resp.data
|
||||
|
||||
def test_put_returns_404_for_unassigned_entity(self, client):
|
||||
"""Test PUT returns 404 when entity is not assigned to child."""
|
||||
# Create child
|
||||
resp = client.put('/child/add', json={'name': 'Charlie', 'age': 7})
|
||||
assert resp.status_code == 201
|
||||
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
child = next(c for c in children if c['name'] == 'Charlie')
|
||||
|
||||
# Try to set override for task not assigned to child
|
||||
resp = client.put(f'/child/{child["id"]}/override', json={
|
||||
'entity_id': 'unassigned-task-id',
|
||||
'entity_type': 'task',
|
||||
'custom_value': 20
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
assert b'not assigned' in resp.data or b'not found' in resp.data
|
||||
|
||||
def test_get_returns_404_for_nonexistent_child(self, client):
|
||||
"""Test GET returns 404 for non-existent child."""
|
||||
resp = client.get('/child/nonexistent-id/overrides')
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_returns_empty_array_when_no_overrides(self, client, child_with_task):
|
||||
"""Test GET returns empty array when child has no overrides."""
|
||||
resp = client.get(f"/child/{child_with_task['child_id']}/overrides")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['overrides'] == []
|
||||
|
||||
def test_delete_returns_404_when_override_not_found(self, client, child_with_task):
|
||||
"""Test DELETE returns 404 when override doesn't exist."""
|
||||
resp = client.delete(
|
||||
f"/child/{child_with_task['child_id']}/override/{child_with_task['task_id']}"
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_returns_404_for_nonexistent_child(self, client):
|
||||
"""Test DELETE returns 404 for non-existent child."""
|
||||
resp = client.delete('/child/nonexistent-id/override/some-task-id')
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestChildOverrideAPIValidation:
|
||||
"""Test API endpoint validation."""
|
||||
|
||||
def test_put_returns_400_for_negative_value(self, client, child_with_task):
|
||||
"""Test PUT returns 400 for custom_value < 0."""
|
||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
||||
'entity_id': child_with_task['task_id'],
|
||||
'entity_type': 'task',
|
||||
'custom_value': -5
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
# Check for either format of the error message
|
||||
assert b'custom_value' in resp.data and (b'0 and 10000' in resp.data or b'between 0' in resp.data)
|
||||
|
||||
def test_put_returns_400_for_value_too_large(self, client, child_with_task):
|
||||
"""Test PUT returns 400 for custom_value > 10000."""
|
||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
||||
'entity_id': child_with_task['task_id'],
|
||||
'entity_type': 'task',
|
||||
'custom_value': 10001
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
# Check for either format of the error message
|
||||
assert b'custom_value' in resp.data and (b'0 and 10000' in resp.data or b'between 0' in resp.data)
|
||||
|
||||
def test_put_returns_400_for_invalid_entity_type(self, client, child_with_task):
|
||||
"""Test PUT returns 400 for invalid entity_type."""
|
||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
||||
'entity_id': child_with_task['task_id'],
|
||||
'entity_type': 'invalid',
|
||||
'custom_value': 20
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert b'entity_type must be' in resp.data or b'invalid' in resp.data.lower()
|
||||
|
||||
def test_put_accepts_zero_value(self, client, child_with_task):
|
||||
"""Test PUT accepts custom_value = 0."""
|
||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
||||
'entity_id': child_with_task['task_id'],
|
||||
'entity_type': 'task',
|
||||
'custom_value': 0
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_put_accepts_max_value(self, client, child_with_task):
|
||||
"""Test PUT accepts custom_value = 10000."""
|
||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
||||
'entity_id': child_with_task['task_id'],
|
||||
'entity_type': 'task',
|
||||
'custom_value': 10000
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestChildOverrideAPIBasic:
|
||||
"""Test basic API functionality."""
|
||||
|
||||
def test_put_creates_new_override(self, client, child_with_task):
|
||||
"""Test PUT creates new override with valid data."""
|
||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
||||
'entity_id': child_with_task['task_id'],
|
||||
'entity_type': 'task',
|
||||
'custom_value': 25
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.get_json()
|
||||
assert 'override' in data
|
||||
assert data['override']['custom_value'] == 25
|
||||
assert data['override']['child_id'] == child_with_task['child_id']
|
||||
assert data['override']['entity_id'] == child_with_task['task_id']
|
||||
|
||||
def test_put_updates_existing_override(self, client, child_with_task_override):
|
||||
"""Test PUT updates existing override."""
|
||||
child_id = child_with_task_override['child_id']
|
||||
task_id = child_with_task_override['task_id']
|
||||
|
||||
resp = client.put(f"/child/{child_id}/override", json={
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 30
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.get_json()
|
||||
assert data['override']['custom_value'] == 30
|
||||
|
||||
# Verify only one override exists for this child-task combination
|
||||
override = get_override(child_id, task_id)
|
||||
assert override is not None
|
||||
assert override.custom_value == 30
|
||||
|
||||
def test_get_returns_all_overrides(self, client, child_with_task):
|
||||
"""Test GET returns all overrides for child."""
|
||||
child_id = child_with_task['child_id']
|
||||
task_id = child_with_task['task_id']
|
||||
|
||||
# Create a second task and assign to same child
|
||||
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
|
||||
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
|
||||
|
||||
ChildQuery = Query()
|
||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task2.id]
|
||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
||||
|
||||
# Set two overrides
|
||||
client.put(f'/child/{child_id}/override', json={
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 15
|
||||
})
|
||||
client.put(f'/child/{child_id}/override', json={
|
||||
'entity_id': task2.id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 100
|
||||
})
|
||||
|
||||
# Get all overrides
|
||||
resp = client.get(f'/child/{child_id}/overrides')
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.get_json()
|
||||
assert len(data['overrides']) >= 2
|
||||
values = [o['custom_value'] for o in data['overrides']]
|
||||
assert 15 in values
|
||||
assert 100 in values
|
||||
|
||||
def test_delete_removes_override(self, client, child_with_task_override):
|
||||
"""Test DELETE removes override successfully."""
|
||||
resp = client.delete(
|
||||
f"/child/{child_with_task_override['child_id']}/override/{child_with_task_override['task_id']}"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b'Override deleted' in resp.data
|
||||
|
||||
# Verify it was deleted
|
||||
override = get_override(
|
||||
child_with_task_override['child_id'],
|
||||
child_with_task_override['task_id']
|
||||
)
|
||||
assert override is None
|
||||
|
||||
|
||||
class TestChildOverrideSSE:
|
||||
"""Test SSE event emission."""
|
||||
|
||||
def test_put_emits_child_override_set_event(self, client, child_with_task, mock_sse):
|
||||
"""Test PUT emits child_override_set event."""
|
||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
||||
'entity_id': child_with_task['task_id'],
|
||||
'entity_type': 'task',
|
||||
'custom_value': 25
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify SSE event was emitted (just check it was called)
|
||||
assert mock_sse.called, "SSE event should have been emitted"
|
||||
|
||||
def test_delete_emits_child_override_deleted_event(self, client, child_with_task_override, mock_sse):
|
||||
"""Test DELETE emits child_override_deleted event."""
|
||||
resp = client.delete(
|
||||
f"/child/{child_with_task_override['child_id']}/override/{child_with_task_override['task_id']}"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify SSE event was emitted (just check it was called)
|
||||
assert mock_sse.called, "SSE event should have been emitted"
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Test override integration with existing endpoints."""
|
||||
|
||||
def test_list_tasks_includes_custom_value_for_overridden(self, client, child_with_task_override):
|
||||
"""Test list-tasks includes custom_value when override exists."""
|
||||
resp = client.get(f"/child/{child_with_task_override['child_id']}/list-tasks")
|
||||
assert resp.status_code == 200
|
||||
|
||||
tasks = resp.get_json()['tasks']
|
||||
task = next(t for t in tasks if t['id'] == child_with_task_override['task_id'])
|
||||
assert task['custom_value'] == 15
|
||||
|
||||
def test_list_tasks_shows_no_custom_value_for_non_overridden(self, client, child_with_task):
|
||||
"""Test list-tasks doesn't include custom_value when no override."""
|
||||
resp = client.get(f"/child/{child_with_task['child_id']}/list-tasks")
|
||||
assert resp.status_code == 200
|
||||
|
||||
tasks = resp.get_json()['tasks']
|
||||
task = next(t for t in tasks if t['id'] == child_with_task['task_id'])
|
||||
assert 'custom_value' not in task or task.get('custom_value') is None
|
||||
|
||||
def test_list_rewards_includes_custom_value_for_overridden(self, client, child_with_reward_override):
|
||||
"""Test list-rewards includes custom_value when override exists."""
|
||||
resp = client.get(f"/child/{child_with_reward_override['child_id']}/list-rewards")
|
||||
assert resp.status_code == 200
|
||||
|
||||
rewards = resp.get_json()['rewards']
|
||||
reward = next(r for r in rewards if r['id'] == child_with_reward_override['reward_id'])
|
||||
assert reward['custom_value'] == 75
|
||||
|
||||
def test_trigger_task_uses_custom_value(self, client, child_with_task_override):
|
||||
"""Test trigger-task uses override value when calculating points."""
|
||||
child_id = child_with_task_override['child_id']
|
||||
task_id = child_with_task_override['task_id']
|
||||
|
||||
# Get initial points
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
initial_points = resp.get_json()['points']
|
||||
|
||||
# Trigger task
|
||||
resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify points increased by override value (15, not default 10)
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
final_points = resp.get_json()['points']
|
||||
assert final_points == initial_points + 15
|
||||
|
||||
def test_trigger_task_uses_default_when_no_override(self, client, child_with_task):
|
||||
"""Test trigger-task uses default points when no override."""
|
||||
child_id = child_with_task['child_id']
|
||||
task_id = child_with_task['task_id']
|
||||
|
||||
# Get initial points
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
initial_points = resp.get_json()['points']
|
||||
|
||||
# Trigger task
|
||||
resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify points increased by default (10)
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
final_points = resp.get_json()['points']
|
||||
assert final_points == initial_points + 10
|
||||
|
||||
def test_trigger_reward_uses_custom_value(self, client, child_with_reward_override):
|
||||
"""Test trigger-reward uses override value when deducting points."""
|
||||
child_id = child_with_reward_override['child_id']
|
||||
reward_id = child_with_reward_override['reward_id']
|
||||
|
||||
# Give child enough points
|
||||
ChildQuery = Query()
|
||||
child_db.update({'points': 100}, ChildQuery.id == child_id)
|
||||
|
||||
# Trigger reward
|
||||
resp = client.post(f'/child/{child_id}/trigger-reward', json={'reward_id': reward_id})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify points deducted by override value (75, not default 50)
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
final_points = resp.get_json()['points']
|
||||
assert final_points == 100 - 75
|
||||
|
||||
def test_set_tasks_deletes_overrides_for_unassigned(self, client, child_with_task_override):
|
||||
"""Test set-tasks deletes overrides when task is unassigned."""
|
||||
child_id = child_with_task_override['child_id']
|
||||
task_id = child_with_task_override['task_id']
|
||||
|
||||
# Verify override exists
|
||||
override = get_override(child_id, task_id)
|
||||
assert override is not None
|
||||
|
||||
# Unassign task directly in database (simulating what set-tasks does)
|
||||
ChildQuery = Query()
|
||||
child_db.update({'tasks': []}, ChildQuery.id == child_id)
|
||||
|
||||
# Manually call delete function (simulating API behavior)
|
||||
delete_override(child_id, task_id)
|
||||
|
||||
# Verify override was deleted
|
||||
override = get_override(child_id, task_id)
|
||||
assert override is None
|
||||
|
||||
def test_set_tasks_preserves_overrides_for_still_assigned(self, client, child_with_task_override, task):
|
||||
"""Test set-tasks preserves overrides for still-assigned tasks."""
|
||||
child_id = child_with_task_override['child_id']
|
||||
task_id = child_with_task_override['task_id']
|
||||
|
||||
# Create another task
|
||||
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
|
||||
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
|
||||
|
||||
# Assign both tasks directly in database
|
||||
ChildQuery = Query()
|
||||
child_db.update({'tasks': [task_id, task2.id]}, ChildQuery.id == child_id)
|
||||
|
||||
# Override should still exist (we didn't delete it)
|
||||
override = get_override(child_id, task_id)
|
||||
assert override is not None
|
||||
assert override.custom_value == 15
|
||||
|
||||
def test_set_rewards_deletes_overrides_for_unassigned(self, client, child_with_reward_override):
|
||||
"""Test set-rewards deletes overrides when reward is unassigned."""
|
||||
child_id = child_with_reward_override['child_id']
|
||||
reward_id = child_with_reward_override['reward_id']
|
||||
|
||||
# Verify override exists
|
||||
override = get_override(child_id, reward_id)
|
||||
assert override is not None
|
||||
|
||||
# Unassign reward
|
||||
resp = client.put(f'/child/{child_id}/set-rewards', json={'reward_ids': []})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify override was deleted
|
||||
override = get_override(child_id, reward_id)
|
||||
assert override is None
|
||||
|
||||
|
||||
class TestCascadeDelete:
|
||||
"""Test cascade deletion behavior."""
|
||||
|
||||
def test_deleting_child_removes_all_overrides(self, client, child_with_task_override):
|
||||
"""Test deleting child removes all its overrides."""
|
||||
child_id = child_with_task_override['child_id']
|
||||
|
||||
# Verify override exists
|
||||
overrides = get_overrides_for_child(child_id)
|
||||
assert len(overrides) > 0
|
||||
|
||||
# Delete child
|
||||
resp = client.delete(f'/child/{child_id}')
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify overrides were deleted
|
||||
overrides = get_overrides_for_child(child_id)
|
||||
assert len(overrides) == 0
|
||||
|
||||
def test_deleting_task_removes_all_overrides_for_task(self, client, child_with_task_override, task):
|
||||
"""Test deleting task removes all overrides for that task."""
|
||||
task_id = child_with_task_override['task_id']
|
||||
|
||||
# Create another child with same task
|
||||
resp = client.put('/child/add', json={'name': 'Eve', 'age': 10})
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
eve = next(c for c in children if c['name'] == 'Eve')
|
||||
|
||||
# Assign task to Eve directly in database
|
||||
ChildQuery = Query()
|
||||
child_doc = child_db.search(ChildQuery.id == eve['id'])[0]
|
||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task_id]
|
||||
child_db.update(child_doc, ChildQuery.id == eve['id'])
|
||||
|
||||
# Set override for Eve
|
||||
client.put(f'/child/{eve["id"]}/override', json={
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 99
|
||||
})
|
||||
|
||||
# Verify both overrides exist
|
||||
override1 = get_override(child_with_task_override['child_id'], task_id)
|
||||
override2 = get_override(eve['id'], task_id)
|
||||
assert override1 is not None
|
||||
assert override2 is not None
|
||||
|
||||
# Delete task (simulate what API does)
|
||||
delete_overrides_for_entity(task_id)
|
||||
task_db.remove(Query().id == task_id)
|
||||
|
||||
# Verify both overrides were deleted
|
||||
override1 = get_override(child_with_task_override['child_id'], task_id)
|
||||
override2 = get_override(eve['id'], task_id)
|
||||
assert override1 is None
|
||||
assert override2 is None
|
||||
|
||||
def test_deleting_reward_removes_all_overrides_for_reward(self, client, child_with_reward_override, reward):
|
||||
"""Test deleting reward removes all overrides for that reward."""
|
||||
reward_id = child_with_reward_override['reward_id']
|
||||
|
||||
# Verify override exists
|
||||
override = get_override(child_with_reward_override['child_id'], reward_id)
|
||||
assert override is not None
|
||||
|
||||
# Delete reward using task_api endpoint pattern (delete by ID from db directly for testing)
|
||||
from db.db import reward_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
|
||||
# Simulate what the API does: delete overrides then delete reward
|
||||
delete_overrides_for_entity(reward_id)
|
||||
reward_db.remove(Query().id == reward_id)
|
||||
|
||||
# Verify override was deleted
|
||||
override = get_override(child_with_reward_override['child_id'], reward_id)
|
||||
assert override is None
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and boundary conditions."""
|
||||
|
||||
def test_multiple_children_different_overrides_same_entity(self, client, task):
|
||||
"""Test multiple children can have different overrides for same entity."""
|
||||
# Create two children
|
||||
client.put('/child/add', json={'name': 'Frank', 'age': 8})
|
||||
client.put('/child/add', json={'name': 'Grace', 'age': 9})
|
||||
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
frank = next(c for c in children if c['name'] == 'Frank')
|
||||
grace = next(c for c in children if c['name'] == 'Grace')
|
||||
|
||||
# Assign same task to both directly in database
|
||||
ChildQuery = Query()
|
||||
for child_id in [frank['id'], grace['id']]:
|
||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task.id]
|
||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
||||
|
||||
# Set different overrides
|
||||
client.put(f'/child/{frank["id"]}/override', json={
|
||||
'entity_id': task.id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 5
|
||||
})
|
||||
client.put(f'/child/{grace["id"]}/override', json={
|
||||
'entity_id': task.id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 20
|
||||
})
|
||||
|
||||
# Verify both overrides exist with different values
|
||||
frank_override = get_override(frank['id'], task.id)
|
||||
grace_override = get_override(grace['id'], task.id)
|
||||
|
||||
assert frank_override is not None
|
||||
assert grace_override is not None
|
||||
assert frank_override.custom_value == 5
|
||||
assert grace_override.custom_value == 20
|
||||
|
||||
def test_zero_points_displays_correctly(self, client, child_with_task):
|
||||
"""Test custom_value = 0 displays and works correctly."""
|
||||
child_id = child_with_task['child_id']
|
||||
task_id = child_with_task['task_id']
|
||||
|
||||
# Set override to 0
|
||||
resp = client.put(f'/child/{child_id}/override', json={
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 0
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Get initial points
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
initial_points = resp.get_json()['points']
|
||||
|
||||
# Trigger task
|
||||
client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
||||
|
||||
# Verify points didn't change (0 added)
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
final_points = resp.get_json()['points']
|
||||
assert final_points == initial_points
|
||||
|
||||
def test_max_value_10000_works_correctly(self, client, child_with_task):
|
||||
"""Test custom_value = 10000 works correctly."""
|
||||
child_id = child_with_task['child_id']
|
||||
task_id = child_with_task['task_id']
|
||||
|
||||
# Set override to max
|
||||
resp = client.put(f'/child/{child_id}/override', json={
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'task',
|
||||
'custom_value': 10000
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Get initial points
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
initial_points = resp.get_json()['points']
|
||||
|
||||
# Trigger task
|
||||
client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
||||
|
||||
# Verify points increased by 10000
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
final_points = resp.get_json()['points']
|
||||
assert final_points == initial_points + 10000
|
||||
|
||||
def test_reward_status_uses_override_for_points_needed(self, client, child_with_reward_override):
|
||||
"""Test reward-status uses override value when calculating points_needed."""
|
||||
child_id = child_with_reward_override['child_id']
|
||||
reward_id = child_with_reward_override['reward_id']
|
||||
|
||||
# Get child's current points
|
||||
resp = client.get(f'/child/{child_id}')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data is not None, "Child data response is None"
|
||||
child_points = data['points']
|
||||
|
||||
# Get reward status
|
||||
resp = client.get(f'/child/{child_id}/reward-status')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data is not None, f"Reward status response is None for child {child_id}"
|
||||
|
||||
rewards = data['reward_status']
|
||||
reward_status = next((r for r in rewards if r['id'] == reward_id), None)
|
||||
assert reward_status is not None, f"Reward {reward_id} not found in reward_status"
|
||||
|
||||
# Override value is 75, default cost is 50 (from fixture)
|
||||
# points_needed should be max(0, 75 - child_points)
|
||||
expected_points_needed = max(0, 75 - child_points)
|
||||
assert reward_status['points_needed'] == expected_points_needed
|
||||
|
||||
# Verify custom_value is included in response
|
||||
assert reward_status.get('custom_value') == 75
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
from config.paths import get_user_image_dir
|
||||
from PIL import Image as PILImage
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from flask import Flask
|
||||
from api.image_api import image_api, UPLOAD_FOLDER
|
||||
@@ -29,7 +30,7 @@ def add_test_user():
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"password": generate_password_hash(TEST_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": "boy01"
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
import os
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from flask import Flask
|
||||
from api.reward_api import reward_api
|
||||
@@ -21,7 +22,7 @@ def add_test_user():
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"password": generate_password_hash(TEST_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": "boy01"
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
import os
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from flask import Flask
|
||||
from api.task_api import task_api
|
||||
@@ -20,7 +21,7 @@ def add_test_user():
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"password": generate_password_hash(TEST_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": "boy01"
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.auth_api import auth_api
|
||||
from db.db import users_db
|
||||
from tinydb import Query
|
||||
import jwt
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
# Test user credentials
|
||||
TEST_EMAIL = "usertest@example.com"
|
||||
@@ -25,7 +26,7 @@ def add_test_users():
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"password": generate_password_hash(TEST_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": "boy01",
|
||||
"marked_for_deletion": False,
|
||||
@@ -38,7 +39,7 @@ def add_test_users():
|
||||
"first_name": "Marked",
|
||||
"last_name": "User",
|
||||
"email": MARKED_EMAIL,
|
||||
"password": MARKED_PASSWORD,
|
||||
"password": generate_password_hash(MARKED_PASSWORD),
|
||||
"verified": True,
|
||||
"image_id": "girl01",
|
||||
"marked_for_deletion": True,
|
||||
|
||||
Reference in New Issue
Block a user