2 Commits

Author SHA1 Message Date
2e1a0ab2fa feat: add functions to validate today's timestamps and update pending status logic
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m20s
2026-05-03 12:26:21 -04:00
ce3d1b3d54 feat: add functions to validate today's timestamps and update pending status logic
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-05-03 11:53:21 -04:00
7 changed files with 294 additions and 31 deletions

View File

@@ -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/<name>', methods=['GET'])
@child_api.route('/child/<id>', 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

View File

@@ -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:
"""

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "mY6Ehfjz0l0gIgI1-zrA_HMfYQiNmgnNy6g7v2GoT3M",
"value": "pGEIC6CXp8cemhcGtW276YUSMY6zSzOVlhDL-bNhrYk",
"domain": "localhost",
"path": "/api/auth",
"expires": 1785468152.319767,
"expires": 1785601199.302565,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0YmI3ZTQ3ZS0wNGYxLTQyMDctYjZjYy0yNDM3NDVlOGQ0ZDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NTJ9.614NMFQB7JcIJ4k4cqKyxlpcyHmt2Hn4PbjCbvLMJ2A",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNzljMGEzOS1jYmNlLTQzMmQtYTQ1Yy02YjY2N2Q5ZDg3ODMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc4MzU5OTl9.3Aucvm-BqEldAY3FG7Th2Y3-AEdUttqAMM6Z2wC56b8",
"domain": "localhost",
"path": "/",
"expires": 1777702952.31857,
"expires": 1777835999.301909,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777692151952}"
"value": "{\"type\":\"logout\",\"at\":1777825199047}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777864952730}"
"value": "{\"expiresAt\":1777997999649}"
}
]
}

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "fmbL31vJQXFd7NDUWy7eGlRZZoGh0BZipf8CqFOb8zw",
"value": "iFMh4JztGf12rfM3hYnVAxUTxCCaqvf8fXuNt4AcX5E",
"domain": "localhost",
"path": "/api/auth",
"expires": 1785468152.214735,
"expires": 1785601199.525198,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNzc2YjBkYjctYzYyNy00ODBiLTkzZDYtMTRlMTE4MDQ3NTE5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NzAyOTUyfQ.I4sfKTo0nsJgKvwDRSztxtyZpWw-oN1y3L4yCtr_CRo",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNjMwMjY4MzMtOTMwYi00Y2Y4LThkMWQtNGRmYmM4YjZhNDNiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3ODM1OTk5fQ.puE7cpjjfxckURcACEIQRDTdySwJm0gaIwKPAoeg6e4",
"domain": "localhost",
"path": "/",
"expires": 1777702952.21406,
"expires": 1777835999.524669,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777692151844}"
"value": "{\"type\":\"logout\",\"at\":1777825199229}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777864952556}"
"value": "{\"expiresAt\":1777997999821}"
}
]
}

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "YitpHdwm093Mavj6iTYmJOLGLOIKoozGkmYXgmJMTBM",
"value": "o8DJDh2Vlh2aC6VLSIEdO9pY74AceX8kLDiBhzyJ1i4",
"domain": "localhost",
"path": "/api/auth",
"expires": 1785468147.453462,
"expires": 1785601195.078177,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZGNmOWU5Ni1lM2I3LTRkNzYtOWQ1NS01NmE4MjU5ZWQ5NzUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NDd9.zSetTnajvus3N5uJDBqxRYXfLksoU9ZmkWzqCS0GUvc",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI5OWQwMzUxZC03YTg1LTQ4ZDUtOTgzZC01YWJlZTMxOGFhZDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc4MzU5OTV9.QKaquZmzjFHwx08IgGVCyrQeBw2M5-8yVKc30_b9GFM",
"domain": "localhost",
"path": "/",
"expires": 1777702947.452943,
"expires": 1777835995.077514,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777692147220}"
"value": "{\"type\":\"logout\",\"at\":1777825194855}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777864947663}"
"value": "{\"expiresAt\":1777997995266}"
}
]
}

View File

@@ -45,6 +45,28 @@ async function openEditModal(page: Page, card: Locator): Promise<void> {
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
async function seedPendingReward(
request: APIRequestContext,
childId: string,
rewardId: string,
rewardCost: number,
): Promise<void> {
await request.put(`/api/child/${childId}/edit`, { data: { points: rewardCost } })
const requestResp = await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
if (requestResp.status() === 409) {
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: rewardId },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
}
}
test.describe('Reward edit cost', () => {
test.describe.configure({ mode: 'serial' })
@@ -172,9 +194,8 @@ test.describe('Reward edit cost', () => {
page,
request,
}) => {
// Give child enough points to satisfy the original reward cost, then create a pending request
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
await request.post(`/api/child/${childId}/request-reward`, { data: { reward_id: rewardId } })
// Ensure a fresh pending request exists for this test.
await seedPendingReward(request, childId, rewardId, REWARD_COST)
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
@@ -197,8 +218,11 @@ test.describe('Reward edit cost', () => {
test('Editing a pending reward — confirming the warning opens the override modal', async ({
page,
request,
}) => {
// Pending state was established in the previous test; navigate fresh
// Seed pending state in this test to avoid cross-test coupling.
await seedPendingReward(request, childId, rewardId, REWARD_COST)
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })