Compare commits
4 Commits
8308d205e8
...
v1.0.14
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e1a0ab2fa | |||
| ce3d1b3d54 | |||
| 75d3d6dc39 | |||
| 3d882656e3 |
@@ -394,7 +394,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
frontend_ok=false
|
frontend_ok=false
|
||||||
if curl -kfsS "https://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
||||||
frontend_ok=true
|
frontend_ok=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ import logging
|
|||||||
child_api = Blueprint('child_api', __name__)
|
child_api = Blueprint('child_api', __name__)
|
||||||
logger = logging.getLogger(__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/<name>', methods=['GET'])
|
||||||
@child_api.route('/child/<id>', methods=['GET'])
|
@child_api.route('/child/<id>', methods=['GET'])
|
||||||
def get_child(id):
|
def get_child(id):
|
||||||
@@ -313,8 +326,23 @@ def list_child_tasks(id):
|
|||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||||
)
|
)
|
||||||
if pending:
|
if pending:
|
||||||
ct_dict['pending_status'] = pending.get('status')
|
status = pending.get('status')
|
||||||
ct_dict['approved_at'] = pending.get('approved_at')
|
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:
|
else:
|
||||||
ct_dict['pending_status'] = None
|
ct_dict['pending_status'] = None
|
||||||
ct_dict['approved_at'] = 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.child_id == child.id) & (pending_query.entity_id == reward.id) &
|
||||||
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_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()
|
status_dict = status.to_dict()
|
||||||
if override:
|
if override:
|
||||||
status_dict['custom_value'] = override.custom_value
|
status_dict['custom_value'] = override.custom_value
|
||||||
@@ -927,7 +965,12 @@ def request_reward(id):
|
|||||||
(DupQuery.user_id == user_id)
|
(DupQuery.user_id == user_id)
|
||||||
)
|
)
|
||||||
if duplicate:
|
if duplicate:
|
||||||
|
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
|
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 = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
|
||||||
pending_confirmations_db.insert(pending.to_dict())
|
pending_confirmations_db.insert(pending.to_dict())
|
||||||
@@ -1053,6 +1096,7 @@ def list_pending_confirmations():
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
PendingQuery = Query()
|
PendingQuery = Query()
|
||||||
|
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||||
pending_items = pending_confirmations_db.search(
|
pending_items = pending_confirmations_db.search(
|
||||||
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
|
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
|
||||||
)
|
)
|
||||||
@@ -1065,6 +1109,10 @@ def list_pending_confirmations():
|
|||||||
for pr in pending_items:
|
for pr in pending_items:
|
||||||
pending = PendingConfirmation.from_dict(pr)
|
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
|
# Look up child details
|
||||||
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
||||||
if not child_result:
|
if not child_result:
|
||||||
@@ -1137,13 +1185,20 @@ def confirm_chore(id):
|
|||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
|
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||||
if existing.get('status') == 'pending':
|
if existing.get('status') == 'pending':
|
||||||
|
if _is_epoch_timestamp_today_utc(existing.get('created_at'), today_utc):
|
||||||
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
|
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':
|
if existing.get('status') == 'approved':
|
||||||
approved_at = existing.get('approved_at', '')
|
approved_at = existing.get('approved_at', '')
|
||||||
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
if _is_iso_timestamp_today_utc(approved_at, today_utc):
|
||||||
if approved_at and approved_at[:10] == today_utc:
|
|
||||||
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
|
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(
|
confirmation = PendingConfirmation(
|
||||||
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
|
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# file: config/version.py
|
# file: config/version.py
|
||||||
import os
|
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:
|
def get_full_version() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from tinydb import Query
|
|||||||
from models.child import Child
|
from models.child import Child
|
||||||
import jwt
|
import jwt
|
||||||
from werkzeug.security import generate_password_hash
|
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
|
# 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)
|
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
|
||||||
task_extensions_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):
|
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
|
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
|
# request-reward: duplicate guard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -547,6 +655,82 @@ def test_request_reward_duplicate_returns_409(client):
|
|||||||
reward_db.remove(Query().id == 'r_dup')
|
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
|
# deny-reward-request endpoint
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ services:
|
|||||||
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
|
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
|
||||||
container_name: chores-app-frontend-prod # Added for easy identification
|
container_name: chores-app-frontend-prod # Added for easy identification
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_HOST_PORT:-4601}:${FRONTEND_CONTAINER_PORT:-443}" # Host port -> Container 443 (HTTPS) or 80 (HTTP)
|
- "${FRONTEND_HOST_PORT:-4601}:${FRONTEND_CONTAINER_PORT:-80}" # Host port -> Container 80 (HTTP; SSL terminated by external proxy)
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_HOST=chores-app-backend # Points to internal backend service
|
- BACKEND_HOST=chores-app-backend # Points to internal backend service
|
||||||
- FRONTEND_SSL_ENABLED=${FRONTEND_SSL_ENABLED:-true}
|
- FRONTEND_SSL_ENABLED=false
|
||||||
depends_on:
|
depends_on:
|
||||||
- chores-app-backend
|
- chores-app-backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "mY6Ehfjz0l0gIgI1-zrA_HMfYQiNmgnNy6g7v2GoT3M",
|
"value": "pGEIC6CXp8cemhcGtW276YUSMY6zSzOVlhDL-bNhrYk",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1785468152.319767,
|
"expires": 1785601199.302565,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0YmI3ZTQ3ZS0wNGYxLTQyMDctYjZjYy0yNDM3NDVlOGQ0ZDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NTJ9.614NMFQB7JcIJ4k4cqKyxlpcyHmt2Hn4PbjCbvLMJ2A",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNzljMGEzOS1jYmNlLTQzMmQtYTQ1Yy02YjY2N2Q5ZDg3ODMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc4MzU5OTl9.3Aucvm-BqEldAY3FG7Th2Y3-AEdUttqAMM6Z2wC56b8",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1777702952.31857,
|
"expires": 1777835999.301909,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1777692151952}"
|
"value": "{\"type\":\"logout\",\"at\":1777825199047}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1777864952730}"
|
"value": "{\"expiresAt\":1777997999649}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "fmbL31vJQXFd7NDUWy7eGlRZZoGh0BZipf8CqFOb8zw",
|
"value": "iFMh4JztGf12rfM3hYnVAxUTxCCaqvf8fXuNt4AcX5E",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1785468152.214735,
|
"expires": 1785601199.525198,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNzc2YjBkYjctYzYyNy00ODBiLTkzZDYtMTRlMTE4MDQ3NTE5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NzAyOTUyfQ.I4sfKTo0nsJgKvwDRSztxtyZpWw-oN1y3L4yCtr_CRo",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNjMwMjY4MzMtOTMwYi00Y2Y4LThkMWQtNGRmYmM4YjZhNDNiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3ODM1OTk5fQ.puE7cpjjfxckURcACEIQRDTdySwJm0gaIwKPAoeg6e4",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1777702952.21406,
|
"expires": 1777835999.524669,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1777692151844}"
|
"value": "{\"type\":\"logout\",\"at\":1777825199229}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1777864952556}"
|
"value": "{\"expiresAt\":1777997999821}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "YitpHdwm093Mavj6iTYmJOLGLOIKoozGkmYXgmJMTBM",
|
"value": "o8DJDh2Vlh2aC6VLSIEdO9pY74AceX8kLDiBhzyJ1i4",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1785468147.453462,
|
"expires": 1785601195.078177,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZGNmOWU5Ni1lM2I3LTRkNzYtOWQ1NS01NmE4MjU5ZWQ5NzUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NDd9.zSetTnajvus3N5uJDBqxRYXfLksoU9ZmkWzqCS0GUvc",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI5OWQwMzUxZC03YTg1LTQ4ZDUtOTgzZC01YWJlZTMxOGFhZDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc4MzU5OTV9.QKaquZmzjFHwx08IgGVCyrQeBw2M5-8yVKc30_b9GFM",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1777702947.452943,
|
"expires": 1777835995.077514,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1777692147220}"
|
"value": "{\"type\":\"logout\",\"at\":1777825194855}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1777864947663}"
|
"value": "{\"expiresAt\":1777997995266}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,28 @@ async function openEditModal(page: Page, card: Locator): Promise<void> {
|
|||||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
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('Reward edit cost', () => {
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
@@ -172,9 +194,8 @@ test.describe('Reward edit cost', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
// Give child enough points to satisfy the original reward cost, then create a pending request
|
// Ensure a fresh pending request exists for this test.
|
||||||
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
|
await seedPendingReward(request, childId, rewardId, REWARD_COST)
|
||||||
await request.post(`/api/child/${childId}/request-reward`, { data: { reward_id: rewardId } })
|
|
||||||
|
|
||||||
await page.goto(`/parent/${childId}`)
|
await page.goto(`/parent/${childId}`)
|
||||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
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 ({
|
test('Editing a pending reward — confirming the warning opens the override modal', async ({
|
||||||
page,
|
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}`)
|
await page.goto(`/parent/${childId}`)
|
||||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||||
await card.waitFor({ state: 'visible' })
|
await card.waitFor({ state: 'visible' })
|
||||||
|
|||||||
Reference in New Issue
Block a user