From ce3d1b3d54407efb27129c8b7aaf4290dfc7f803 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Sun, 3 May 2026 11:53:21 -0400 Subject: [PATCH] feat: add functions to validate today's timestamps and update pending status logic --- backend/api/child_api.py | 69 ++++++++++-- backend/config/version.py | 2 +- backend/tests/test_child_api.py | 186 +++++++++++++++++++++++++++++++- 3 files changed, 248 insertions(+), 9 deletions(-) diff --git a/backend/api/child_api.py b/backend/api/child_api.py index dd8dc45..06bb185 100644 --- a/backend/api/child_api.py +++ b/backend/api/child_api.py @@ -40,6 +40,19 @@ import logging child_api = Blueprint('child_api', __name__) logger = logging.getLogger(__name__) + +def _is_iso_timestamp_today_utc(timestamp: str | None, today_utc: str) -> bool: + return bool(timestamp) and timestamp[:10] == today_utc + + +def _is_epoch_timestamp_today_utc(epoch_ts, today_utc: str) -> bool: + if epoch_ts is None: + return False + try: + return datetime.fromtimestamp(float(epoch_ts), timezone.utc).strftime('%Y-%m-%d') == today_utc + except (TypeError, ValueError, OSError): + return False + @child_api.route('/child/', methods=['GET']) @child_api.route('/child/', methods=['GET']) def get_child(id): @@ -313,8 +326,23 @@ def list_child_tasks(id): (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) if pending: - ct_dict['pending_status'] = pending.get('status') - ct_dict['approved_at'] = pending.get('approved_at') + status = pending.get('status') + approved_at = pending.get('approved_at') + created_at = pending.get('created_at') + today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') + + if status == 'approved' and _is_iso_timestamp_today_utc(approved_at, today_utc): + ct_dict['pending_status'] = 'approved' + ct_dict['approved_at'] = approved_at + elif status == 'pending' and _is_epoch_timestamp_today_utc(created_at, today_utc): + ct_dict['pending_status'] = 'pending' + ct_dict['approved_at'] = None + else: + pending_id = pending.get('id') + if pending_id: + pending_confirmations_db.remove(PendingQuery.id == pending_id) + ct_dict['pending_status'] = None + ct_dict['approved_at'] = None else: ct_dict['pending_status'] = None ct_dict['approved_at'] = None @@ -872,7 +900,17 @@ def reward_status(id): (pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) & (pending_query.entity_type == 'reward') & (pending_query.user_id == user_id) ) - status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id) + redeeming = False + if pending and pending.get('status') == 'pending': + today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') + if _is_epoch_timestamp_today_utc(pending.get('created_at'), today_utc): + redeeming = True + else: + pending_id = pending.get('id') + if pending_id: + pending_confirmations_db.remove(pending_query.id == pending_id) + + status = RewardStatus(reward.id, reward.name, points_needed, cost_value, redeeming, reward.image_id) status_dict = status.to_dict() if override: status_dict['custom_value'] = override.custom_value @@ -927,7 +965,12 @@ def request_reward(id): (DupQuery.user_id == user_id) ) if duplicate: - return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409 + today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') + if _is_epoch_timestamp_today_utc(duplicate.get('created_at'), today_utc): + return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409 + pending_id = duplicate.get('id') + if pending_id: + pending_confirmations_db.remove(DupQuery.id == pending_id) pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id) pending_confirmations_db.insert(pending.to_dict()) @@ -1053,6 +1096,7 @@ def list_pending_confirmations(): if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 PendingQuery = Query() + today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') pending_items = pending_confirmations_db.search( (PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending') ) @@ -1065,6 +1109,10 @@ def list_pending_confirmations(): for pr in pending_items: pending = PendingConfirmation.from_dict(pr) + if not _is_epoch_timestamp_today_utc(pending.created_at, today_utc): + pending_confirmations_db.remove(PendingQuery.id == pending.id) + continue + # Look up child details child_result = child_db.get(ChildQuery.id == pending.child_id) if not child_result: @@ -1137,13 +1185,20 @@ def confirm_chore(id): (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) if existing: + today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') if existing.get('status') == 'pending': - return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400 + if _is_epoch_timestamp_today_utc(existing.get('created_at'), today_utc): + return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400 + pending_id = existing.get('id') + if pending_id: + pending_confirmations_db.remove(PendingQuery.id == pending_id) if existing.get('status') == 'approved': approved_at = existing.get('approved_at', '') - today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') - if approved_at and approved_at[:10] == today_utc: + if _is_iso_timestamp_today_utc(approved_at, today_utc): return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400 + pending_id = existing.get('id') + if pending_id: + pending_confirmations_db.remove(PendingQuery.id == pending_id) confirmation = PendingConfirmation( child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id diff --git a/backend/config/version.py b/backend/config/version.py index f571e02..103c334 100644 --- a/backend/config/version.py +++ b/backend/config/version.py @@ -2,7 +2,7 @@ # file: config/version.py import os -BASE_VERSION = "1.0.13" # update manually when releasing features +BASE_VERSION = "1.0.14" # update manually when releasing features def get_full_version() -> str: """ diff --git a/backend/tests/test_child_api.py b/backend/tests/test_child_api.py index c177259..a051e3d 100644 --- a/backend/tests/test_child_api.py +++ b/backend/tests/test_child_api.py @@ -10,7 +10,7 @@ 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 +from datetime import date as date_type, datetime, timedelta, timezone # Test user credentials @@ -382,6 +382,7 @@ def _setup_sched_child_and_tasks(task_db, child_db): }) chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID) task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID) + pending_confirmations_db.remove(Query().child_id == CHILD_SCHED_ID) def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client): @@ -517,6 +518,113 @@ def test_list_child_tasks_no_server_side_filtering(client): assert extra_id in returned_ids +def test_list_child_tasks_shows_pending_for_today(client): + """A chore confirmed today should return pending_status='pending'.""" + _setup_sched_child_and_tasks(task_db, child_db) + now_ts = datetime.now(timezone.utc).timestamp() + pending_confirmations_db.insert({ + 'id': 'pend_today_chore', + 'child_id': CHILD_SCHED_ID, + 'entity_id': TASK_GOOD_ID, + 'entity_type': 'chore', + 'user_id': 'testuserid', + 'status': 'pending', + 'approved_at': None, + 'created_at': now_ts, + 'updated_at': now_ts, + }) + + resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks') + assert resp.status_code == 200 + tasks = {t['id']: t for t in resp.get_json()['tasks']} + assert tasks[TASK_GOOD_ID]['pending_status'] == 'pending' + assert tasks[TASK_GOOD_ID]['approved_at'] is None + + +def test_list_child_tasks_clears_stale_approved_and_pending(client): + """Yesterday's chore pending/approved records should be reset and ignored.""" + _setup_sched_child_and_tasks(task_db, child_db) + old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp() + old_approved = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + + pending_confirmations_db.insert({ + 'id': 'pend_old_chore_pending', + 'child_id': CHILD_SCHED_ID, + 'entity_id': TASK_GOOD_ID, + 'entity_type': 'chore', + 'user_id': 'testuserid', + 'status': 'pending', + 'approved_at': None, + 'created_at': old_ts, + 'updated_at': old_ts, + }) + + resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks') + assert resp.status_code == 200 + tasks = {t['id']: t for t in resp.get_json()['tasks']} + assert tasks[TASK_GOOD_ID]['pending_status'] is None + assert tasks[TASK_GOOD_ID]['approved_at'] is None + + # Reinsert as stale approved and ensure it is also cleared. + pending_confirmations_db.insert({ + 'id': 'pend_old_chore_approved', + 'child_id': CHILD_SCHED_ID, + 'entity_id': TASK_GOOD_ID, + 'entity_type': 'chore', + 'user_id': 'testuserid', + 'status': 'approved', + 'approved_at': old_approved, + 'created_at': old_ts, + 'updated_at': old_ts, + }) + + resp2 = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks') + assert resp2.status_code == 200 + tasks2 = {t['id']: t for t in resp2.get_json()['tasks']} + assert tasks2[TASK_GOOD_ID]['pending_status'] is None + assert tasks2[TASK_GOOD_ID]['approved_at'] is None + + +def test_confirm_chore_allows_when_previous_pending_is_stale(client): + """A stale pending chore record from a prior day must not block confirm-chore.""" + old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp() + task_db.insert({'id': 't_stale_chore', 'name': 'Stale Chore', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'}) + child_db.insert({ + 'id': 'child_stale_chore', + 'name': 'Stale Chore Kid', + 'age': 8, + 'points': 0, + 'tasks': ['t_stale_chore'], + 'rewards': [], + 'user_id': 'testuserid', + }) + pending_confirmations_db.insert({ + 'id': 'pend_stale_chore', + 'child_id': 'child_stale_chore', + 'entity_id': 't_stale_chore', + 'entity_type': 'chore', + 'user_id': 'testuserid', + 'status': 'pending', + 'approved_at': None, + 'created_at': old_ts, + 'updated_at': old_ts, + }) + + resp = client.post('/child/child_stale_chore/confirm-chore', json={'task_id': 't_stale_chore'}) + assert resp.status_code == 200 + + active_pending = pending_confirmations_db.search( + (Query().child_id == 'child_stale_chore') & (Query().entity_id == 't_stale_chore') & + (Query().entity_type == 'chore') & (Query().status == 'pending') + ) + assert len(active_pending) == 1 + assert active_pending[0].get('id') != 'pend_stale_chore' + + pending_confirmations_db.remove(Query().child_id == 'child_stale_chore') + child_db.remove(Query().id == 'child_stale_chore') + task_db.remove(Query().id == 't_stale_chore') + + # --------------------------------------------------------------------------- # request-reward: duplicate guard # --------------------------------------------------------------------------- @@ -547,6 +655,82 @@ def test_request_reward_duplicate_returns_409(client): reward_db.remove(Query().id == 'r_dup') +def test_request_reward_allows_new_when_stale_pending_exists(client): + """A stale pending reward from a prior day must not block a new request.""" + old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp() + reward_db.insert({'id': 'r_stale', 'name': 'Stale Reward', 'cost': 5, 'user_id': 'testuserid'}) + child_db.insert({ + 'id': 'child_stale', + 'name': 'Stale Kid', + 'age': 8, + 'points': 20, + 'tasks': [], + 'rewards': ['r_stale'], + 'user_id': 'testuserid', + }) + pending_confirmations_db.insert({ + 'id': 'pend_stale_reward', + 'child_id': 'child_stale', + 'entity_id': 'r_stale', + 'entity_type': 'reward', + 'user_id': 'testuserid', + 'status': 'pending', + 'approved_at': None, + 'created_at': old_ts, + 'updated_at': old_ts, + }) + + resp = client.post('/child/child_stale/request-reward', json={'reward_id': 'r_stale'}) + assert resp.status_code == 200 + + active_pending = pending_confirmations_db.search( + (Query().child_id == 'child_stale') & (Query().entity_id == 'r_stale') & + (Query().entity_type == 'reward') & (Query().status == 'pending') + ) + assert len(active_pending) == 1 + assert active_pending[0].get('id') != 'pend_stale_reward' + + pending_confirmations_db.remove(Query().child_id == 'child_stale') + child_db.remove(Query().id == 'child_stale') + reward_db.remove(Query().id == 'r_stale') + + +def test_reward_status_ignores_stale_pending_reward(client): + """reward-status should not mark a reward as redeeming if pending is stale.""" + old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp() + reward_db.insert({'id': 'r_status_stale', 'name': 'Status Reward', 'cost': 4, 'user_id': 'testuserid'}) + child_db.insert({ + 'id': 'child_status_stale', + 'name': 'Status Kid', + 'age': 9, + 'points': 10, + 'tasks': [], + 'rewards': ['r_status_stale'], + 'user_id': 'testuserid', + }) + pending_confirmations_db.insert({ + 'id': 'pend_status_stale', + 'child_id': 'child_status_stale', + 'entity_id': 'r_status_stale', + 'entity_type': 'reward', + 'user_id': 'testuserid', + 'status': 'pending', + 'approved_at': None, + 'created_at': old_ts, + 'updated_at': old_ts, + }) + + resp = client.get('/child/child_status_stale/reward-status') + assert resp.status_code == 200 + statuses = {s['id']: s for s in resp.get_json()['reward_status']} + assert statuses['r_status_stale']['redeeming'] is False + assert pending_confirmations_db.get(Query().id == 'pend_status_stale') is None + + pending_confirmations_db.remove(Query().child_id == 'child_status_stale') + child_db.remove(Query().id == 'child_status_stale') + reward_db.remove(Query().id == 'r_status_stale') + + # --------------------------------------------------------------------------- # deny-reward-request endpoint # ---------------------------------------------------------------------------