diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index 901e9d5..5ee7c4e 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -39,7 +39,11 @@ def signup(): email = data.get('email', '') norm_email = normalize_email(email) - if users_db.search(UserQuery.email == norm_email): + existing = users_db.get(UserQuery.email == norm_email) + if existing: + user = User.from_dict(existing) + if user.marked_for_deletion: + return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403 return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400 token = secrets.token_urlsafe(32) @@ -78,6 +82,10 @@ def verify(): status = 'error' reason = 'Invalid token' code = INVALID_TOKEN + elif user.marked_for_deletion: + status = 'error' + reason = 'Account marked for deletion' + code = ACCOUNT_MARKED_FOR_DELETION else: created_str = user.verify_token_created if not created_str: @@ -175,6 +183,8 @@ def me(): user = User.from_dict(user_dict) if user_dict else None if not user: return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404 + if user.marked_for_deletion: + return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403 return jsonify({ 'email': user.email, 'id': user_id, @@ -201,14 +211,14 @@ def request_password_reset(): user_dict = users_db.get(UserQuery.email == norm_email) user = User.from_dict(user_dict) if user_dict else None if user: - # Silently ignore reset requests for marked accounts (don't leak account status) - if not user.marked_for_deletion: - token = secrets.token_urlsafe(32) - now_iso = datetime.utcnow().isoformat() - user.reset_token = token - user.reset_token_created = now_iso - users_db.update(user.to_dict(), UserQuery.email == norm_email) - send_reset_password_email(norm_email, token) + if user.marked_for_deletion: + return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403 + token = secrets.token_urlsafe(32) + now_iso = datetime.utcnow().isoformat() + user.reset_token = token + user.reset_token_created = now_iso + users_db.update(user.to_dict(), UserQuery.email == norm_email) + send_reset_password_email(norm_email, token) return jsonify({'message': success_msg}), 200 diff --git a/backend/api/reward_api.py b/backend/api/reward_api.py index 3779833..5523f6c 100644 --- a/backend/api/reward_api.py +++ b/backend/api/reward_api.py @@ -65,7 +65,13 @@ def list_rewards(): if r.get('user_id') is None and r['name'].strip().lower() in user_rewards: continue # Skip default if user version exists filtered_rewards.append(r) - return jsonify({'rewards': filtered_rewards}), 200 + + # Sort: user-created items first (by name), then default items (by name) + user_created = sorted([r for r in filtered_rewards if r.get('user_id') == user_id], key=lambda x: x['name'].lower()) + default_items = sorted([r for r in filtered_rewards if r.get('user_id') is None], key=lambda x: x['name'].lower()) + sorted_rewards = user_created + default_items + + return jsonify({'rewards': sorted_rewards}), 200 @reward_api.route('/reward/', methods=['DELETE']) def delete_reward(id): diff --git a/backend/api/task_api.py b/backend/api/task_api.py index dd54f7e..4e95359 100644 --- a/backend/api/task_api.py +++ b/backend/api/task_api.py @@ -63,7 +63,13 @@ def list_tasks(): if t.get('user_id') is None and t['name'].strip().lower() in user_tasks: continue # Skip default if user version exists filtered_tasks.append(t) - return jsonify({'tasks': filtered_tasks}), 200 + + # Sort: user-created items first (by name), then default items (by name) + user_created = sorted([t for t in filtered_tasks if t.get('user_id') == user_id], key=lambda x: x['name'].lower()) + default_items = sorted([t for t in filtered_tasks if t.get('user_id') is None], key=lambda x: x['name'].lower()) + sorted_tasks = user_created + default_items + + return jsonify({'tasks': sorted_tasks}), 200 @task_api.route('/task/', methods=['DELETE']) def delete_task(id): diff --git a/backend/api/user_api.py b/backend/api/user_api.py index cd6fa53..b365da6 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -231,6 +231,13 @@ def mark_for_deletion(): # Mark for deletion user.marked_for_deletion = True user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat() + + # Invalidate any outstanding verification/reset tokens so they cannot be used after marking + user.verify_token = None + user.verify_token_created = None + user.reset_token = None + user.reset_token_created = None + users_db.update(user.to_dict(), UserQuery.id == user.id) # Trigger SSE event diff --git a/backend/tests/test_auth_api_marked.py b/backend/tests/test_auth_api_marked.py new file mode 100644 index 0000000..821ccf3 --- /dev/null +++ b/backend/tests/test_auth_api_marked.py @@ -0,0 +1,82 @@ +import pytest +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 werkzeug.security import generate_password_hash +from datetime import datetime, timedelta +import jwt + +@pytest.fixture +def client(): + app = Flask(__name__) + app.register_blueprint(auth_api, url_prefix='/auth') + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'supersecretkey' + with app.test_client() as client: + yield client + +def setup_marked_user(email, verified=False, verify_token=None, reset_token=None): + users_db.remove(Query().email == email) + user = User( + first_name='Marked', + last_name='User', + email=email, + password=generate_password_hash('password123'), + verified=verified, + marked_for_deletion=True, + verify_token=verify_token, + verify_token_created=datetime.utcnow().isoformat() if verify_token else None, + reset_token=reset_token, + reset_token_created=datetime.utcnow().isoformat() if reset_token else None + ) + users_db.insert(user.to_dict()) + + +def test_signup_marked_for_deletion(client): + setup_marked_user('marked@example.com') + data = { + 'first_name': 'Marked', + 'last_name': 'User', + 'email': 'marked@example.com', + 'password': 'password123' + } + response = client.post('/auth/signup', json=data) + assert response.status_code == 403 + assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION' + +def test_verify_marked_for_deletion(client): + setup_marked_user('marked2@example.com', verify_token='verifytoken123') + response = client.get('/auth/verify', query_string={'token': 'verifytoken123'}) + assert response.status_code == 400 + assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION' + +def test_request_password_reset_marked_for_deletion(client): + setup_marked_user('marked3@example.com') + response = client.post('/auth/request-password-reset', json={'email': 'marked3@example.com'}) + assert response.status_code == 403 + assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION' + +def test_me_marked_for_deletion(client): + email = 'marked4@example.com' + setup_marked_user(email, verified=True) + + # Get the user to access the ID + user_dict = users_db.get(Query().email == email) + user = User.from_dict(user_dict) + + # Create a valid JWT token for the marked user + payload = { + 'email': email, + 'user_id': user.id, + 'exp': datetime.utcnow() + timedelta(hours=24) + } + token = jwt.encode(payload, 'supersecretkey', algorithm='HS256') + + # Make request with token cookie + client.set_cookie('token', token) + response = client.get('/auth/me') + + assert response.status_code == 403 + assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION' diff --git a/backend/tests/test_deletion_scheduler.py b/backend/tests/test_deletion_scheduler.py index 852937b..3e6b947 100644 --- a/backend/tests/test_deletion_scheduler.py +++ b/backend/tests/test_deletion_scheduler.py @@ -15,6 +15,7 @@ from utils.account_deletion_scheduler import ( delete_user_data, process_deletion_queue, check_interrupted_deletions, + trigger_deletion_manually, MAX_DELETION_ATTEMPTS ) from models.user import User @@ -953,3 +954,163 @@ class TestIntegration: assert users_db.get(Query_.id == user_id) is None assert child_db.get(Query_.id == child_id) is None assert not os.path.exists(user_image_dir) + + +class TestManualDeletionTrigger: + """Tests for manually triggered deletion (admin endpoint).""" + + def setup_method(self): + """Clear test databases before each test.""" + users_db.truncate() + child_db.truncate() + task_db.truncate() + reward_db.truncate() + image_db.truncate() + pending_reward_db.truncate() + + def teardown_method(self): + """Clean up test directories after each test.""" + for user_id in ['manual_user_1', 'manual_user_2', 'manual_user_3', 'manual_user_retry', 'recent_user']: + user_dir = get_user_image_dir(user_id) + if os.path.exists(user_dir): + try: + shutil.rmtree(user_dir) + except: + pass + + def test_manual_trigger_deletes_immediately(self): + """Test that manual trigger deletes users marked recently (not past threshold).""" + user_id = 'manual_user_1' + + # Create user marked only 1 hour ago (well before 720 hour threshold) + marked_time = (datetime.now() - timedelta(hours=1)).isoformat() + user = User( + id=user_id, + email='manual1@example.com', + first_name='Manual', + last_name='Test', + password='hash', + marked_for_deletion=True, + marked_for_deletion_at=marked_time, + deletion_in_progress=False, + deletion_attempted_at=None + ) + users_db.insert(user.to_dict()) + + # Verify user is NOT due for deletion under normal circumstances + assert is_user_due_for_deletion(user) is False + + # Manually trigger deletion + result = trigger_deletion_manually() + + # Verify user was deleted despite not being past threshold + Query_ = Query() + assert users_db.get(Query_.id == user_id) is None + assert result['triggered'] is True + + def test_manual_trigger_deletes_multiple_users(self): + """Test that manual trigger deletes all marked users regardless of time.""" + # Create multiple users marked at different times + users_data = [ + ('manual_user_1', 1), # 1 hour ago + ('manual_user_2', 100), # 100 hours ago + ('manual_user_3', 800), # 800 hours ago (past threshold) + ] + + for user_id, hours_ago in users_data: + marked_time = (datetime.now() - timedelta(hours=hours_ago)).isoformat() + user = User( + id=user_id, + email=f'{user_id}@example.com', + first_name='Manual', + last_name='Test', + password='hash', + marked_for_deletion=True, + marked_for_deletion_at=marked_time, + deletion_in_progress=False, + deletion_attempted_at=None + ) + users_db.insert(user.to_dict()) + + # Verify only one is due under normal circumstances + all_users = users_db.all() + due_count = sum(1 for u in all_users if is_user_due_for_deletion(User.from_dict(u))) + assert due_count == 1 # Only the 800 hour old one + + # Manually trigger deletion + trigger_deletion_manually() + + # Verify ALL marked users were deleted + Query_ = Query() + assert len(users_db.all()) == 0 + + def test_manual_trigger_respects_retry_limit(self): + """Test that manual trigger still respects max retry limit.""" + user_id = 'manual_user_retry' + + # Create user marked recently with max attempts already + marked_time = (datetime.now() - timedelta(hours=1)).isoformat() + attempted_time = (datetime.now() - timedelta(hours=1)).isoformat() + + user = User( + id=user_id, + email='retry@example.com', + first_name='Retry', + last_name='Test', + password='hash', + marked_for_deletion=True, + marked_for_deletion_at=marked_time, + deletion_in_progress=False, + deletion_attempted_at=attempted_time # Has 1 attempt + ) + users_db.insert(user.to_dict()) + + # Mock delete_user_data to fail consistently + with patch('utils.account_deletion_scheduler.delete_user_data', return_value=False): + # Trigger multiple times to exceed retry limit + for _ in range(MAX_DELETION_ATTEMPTS): + trigger_deletion_manually() + + # User should still exist after max attempts + Query_ = Query() + remaining_user = users_db.get(Query_.id == user_id) + assert remaining_user is not None + + def test_manual_trigger_with_no_marked_users(self): + """Test that manual trigger handles empty queue gracefully.""" + result = trigger_deletion_manually() + + assert result['triggered'] is True + assert result['queued_users'] == 0 + + def test_normal_scheduler_still_respects_threshold(self): + """Test that normal scheduler run (force=False) still respects time threshold.""" + user_id = 'recent_user' + + # Create user marked only 1 hour ago + marked_time = (datetime.now() - timedelta(hours=1)).isoformat() + user = User( + id=user_id, + email='recent@example.com', + first_name='Recent', + last_name='Test', + password='hash', + marked_for_deletion=True, + marked_for_deletion_at=marked_time, + deletion_in_progress=False, + deletion_attempted_at=None + ) + users_db.insert(user.to_dict()) + + # Run normal scheduler (not manual trigger) + process_deletion_queue(force=False) + + # User should still exist because not past threshold + Query_ = Query() + assert users_db.get(Query_.id == user_id) is not None + + # Now run with force=True + process_deletion_queue(force=True) + + # User should be deleted + assert users_db.get(Query_.id == user_id) is None diff --git a/backend/tests/test_user_api.py b/backend/tests/test_user_api.py index cf4e6c1..5a5f98e 100644 --- a/backend/tests/test_user_api.py +++ b/backend/tests/test_user_api.py @@ -138,11 +138,12 @@ def test_login_succeeds_for_unmarked_user(client): assert 'message' in data def test_password_reset_ignored_for_marked_user(client): - """Test that password reset requests are silently ignored for marked users.""" + """Test that password reset requests return 403 for marked users.""" response = client.post('/request-password-reset', json={"email": MARKED_EMAIL}) - assert response.status_code == 200 + assert response.status_code == 403 data = response.get_json() - assert 'message' in data + assert 'error' in data + assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION' def test_password_reset_works_for_unmarked_user(client): """Test that password reset works normally for unmarked users.""" @@ -167,6 +168,35 @@ def test_mark_for_deletion_updates_timestamp(authenticated_client): assert before_time <= marked_at <= after_time + +def test_mark_for_deletion_clears_tokens(authenticated_client): + """When an account is marked for deletion, verify/reset tokens must be cleared.""" + # Seed verify/reset tokens for the user + UserQuery = Query() + now_iso = datetime.utcnow().isoformat() + users_db.update({ + 'verify_token': 'verify-abc', + 'verify_token_created': now_iso, + 'reset_token': 'reset-xyz', + 'reset_token_created': now_iso + }, UserQuery.email == TEST_EMAIL) + + # Ensure tokens are present before marking + user_before = users_db.search(UserQuery.email == TEST_EMAIL)[0] + assert user_before['verify_token'] is not None + assert user_before['reset_token'] is not None + + # Mark account for deletion + response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL}) + assert response.status_code == 200 + + # Verify tokens were cleared in the DB + user_after = users_db.search(UserQuery.email == TEST_EMAIL)[0] + assert user_after.get('verify_token') is None + assert user_after.get('verify_token_created') is None + assert user_after.get('reset_token') is None + assert user_after.get('reset_token_created') is None + def test_mark_for_deletion_with_invalid_jwt(client): """Test marking for deletion with invalid JWT token.""" # Set invalid cookie manually diff --git a/backend/utils/account_deletion_scheduler.py b/backend/utils/account_deletion_scheduler.py index 87b0a60..66e1069 100644 --- a/backend/utils/account_deletion_scheduler.py +++ b/backend/utils/account_deletion_scheduler.py @@ -210,11 +210,18 @@ def delete_user_data(user: User) -> bool: pass return False -def process_deletion_queue(): +def process_deletion_queue(force=False): """ Process the deletion queue: find users due for deletion and delete them. + + Args: + force (bool): If True, delete all marked users immediately without checking threshold. + If False, only delete users past the threshold time. """ - logger.info("Starting deletion scheduler run") + if force: + logger.info("Starting FORCED deletion scheduler run (bypassing time threshold)") + else: + logger.info("Starting deletion scheduler run") processed = 0 deleted = 0 @@ -235,8 +242,8 @@ def process_deletion_queue(): user = User.from_dict(user_dict) processed += 1 - # Check if user is due for deletion - if not is_user_due_for_deletion(user): + # Check if user is due for deletion (skip check if force=True) + if not force and not is_user_due_for_deletion(user): continue # Check retry limit @@ -346,10 +353,11 @@ def stop_deletion_scheduler(): def trigger_deletion_manually(): """ Manually trigger the deletion process (for admin use). + Deletes all marked users immediately without waiting for threshold. Returns stats about the run. """ - logger.info("Manual deletion trigger requested") - process_deletion_queue() + logger.info("Manual deletion trigger requested - forcing immediate deletion") + process_deletion_queue(force=True) # Return stats (simplified version) Query_ = Query() diff --git a/frontend/vue-app/src/__tests__/UserProfile.spec.ts b/frontend/vue-app/src/__tests__/UserProfile.spec.ts index dc7c8ce..de78322 100644 --- a/frontend/vue-app/src/__tests__/UserProfile.spec.ts +++ b/frontend/vue-app/src/__tests__/UserProfile.spec.ts @@ -283,3 +283,207 @@ describe('UserProfile - Delete Account', () => { expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.') }) }) + +describe('UserProfile - Profile Update', () => { + let wrapper: VueWrapper + + beforeEach(() => { + vi.clearAllMocks() + ;(global.fetch as any).mockClear() + + // Mock fetch for profile loading in onMounted + ;(global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ + image_id: 'initial-image-id', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + }), + }) + + // Mount component with router + wrapper = mount(UserProfile, { + global: { + plugins: [mockRouter], + stubs: { + EntityEditForm: { + template: '
', + props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'], + emits: ['submit', 'cancel', 'add-image'], + }, + ModalDialog: { + template: '
', + }, + }, + }, + }) + }) + + it('updates initialData after successful profile save', async () => { + await flushPromises() + await nextTick() + + // Initial image_id should be set from mount + expect(wrapper.vm.initialData.image_id).toBe('initial-image-id') + + // Mock successful save response + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }) + + // Simulate form submission with new image_id + const newFormData = { + image_id: 'new-image-id', + first_name: 'Updated', + last_name: 'Name', + email: 'test@example.com', + } + + await wrapper.vm.handleSubmit(newFormData) + await flushPromises() + + // initialData should now be updated to match the saved form + expect(wrapper.vm.initialData.image_id).toBe('new-image-id') + expect(wrapper.vm.initialData.first_name).toBe('Updated') + expect(wrapper.vm.initialData.last_name).toBe('Name') + }) + + it('allows dirty detection after save when reverting to original value', async () => { + await flushPromises() + await nextTick() + + // Start with initial-image-id + expect(wrapper.vm.initialData.image_id).toBe('initial-image-id') + + // Mock successful save + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }) + + // Change and save to new-image-id + await wrapper.vm.handleSubmit({ + image_id: 'new-image-id', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + }) + await flushPromises() + + // initialData should now be new-image-id + expect(wrapper.vm.initialData.image_id).toBe('new-image-id') + + // Now if user changes back to initial-image-id, it should be detected as different + // (because initialData is now new-image-id) + const currentInitial = wrapper.vm.initialData.image_id + expect(currentInitial).toBe('new-image-id') + expect(currentInitial).not.toBe('initial-image-id') + }) + + it('handles image upload during profile save', async () => { + await flushPromises() + await nextTick() + + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }) + wrapper.vm.localImageFile = mockFile + + // Mock image upload response + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'uploaded-image-id' }), + }) + + // Mock profile update response + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }) + + await wrapper.vm.handleSubmit({ + image_id: 'local-upload', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + }) + await flushPromises() + + // Should have called image upload + expect(global.fetch).toHaveBeenCalledWith( + '/api/image/upload', + expect.objectContaining({ + method: 'POST', + }), + ) + + // initialData should be updated with uploaded image ID + expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id') + }) + + it('shows error message on failed image upload', async () => { + await flushPromises() + await nextTick() + + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }) + wrapper.vm.localImageFile = mockFile + + // Mock failed image upload + ;(global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 500, + }) + + await wrapper.vm.handleSubmit({ + image_id: 'local-upload', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + }) + await flushPromises() + + expect(wrapper.vm.errorMsg).toBe('Failed to upload image.') + expect(wrapper.vm.loading).toBe(false) + }) + + it('shows success modal after profile update', async () => { + await flushPromises() + await nextTick() + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }) + + await wrapper.vm.handleSubmit({ + image_id: 'some-image-id', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + }) + await flushPromises() + + expect(wrapper.vm.showModal).toBe(true) + expect(wrapper.vm.modalTitle).toBe('Profile Updated') + expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.') + }) + + it('shows error message on failed profile update', async () => { + await flushPromises() + await nextTick() + ;(global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 500, + }) + + await wrapper.vm.handleSubmit({ + image_id: 'some-image-id', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + }) + await flushPromises() + + expect(wrapper.vm.errorMsg).toBe('Failed to update profile.') + expect(wrapper.vm.loading).toBe(false) + }) +}) diff --git a/frontend/vue-app/src/components/child/ChildView.vue b/frontend/vue-app/src/components/child/ChildView.vue index 9abf9de..3a00326 100644 --- a/frontend/vue-app/src/components/child/ChildView.vue +++ b/frontend/vue-app/src/components/child/ChildView.vue @@ -165,20 +165,53 @@ function handleRewardModified(event: Event) { } } -const triggerTask = (task: Task) => { - if ('speechSynthesis' in window && task.name) { - const utter = new window.SpeechSynthesisUtterance(task.name) - window.speechSynthesis.speak(utter) +const triggerTask = async (task: Task) => { + // Cancel any pending speech to avoid conflicts + if ('speechSynthesis' in window) { + window.speechSynthesis.cancel() + + if (task.name) { + const utter = new window.SpeechSynthesisUtterance(task.name) + utter.rate = 1.0 + utter.pitch = 1.0 + utter.volume = 1.0 + window.speechSynthesis.speak(utter) + } + } + + // Trigger the task via API + if (child.value?.id && task.id) { + try { + const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: task.id }), + }) + if (!resp.ok) { + console.error('Failed to trigger task') + } + } catch (err) { + console.error('Error triggering task:', err) + } } } const triggerReward = (reward: RewardStatus) => { - if ('speechSynthesis' in window && reward.name) { - const utterString = - reward.name + - (reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`) - const utter = new window.SpeechSynthesisUtterance(utterString) - window.speechSynthesis.speak(utter) + // Cancel any pending speech to avoid conflicts + if ('speechSynthesis' in window) { + window.speechSynthesis.cancel() + + if (reward.name) { + const utterString = + reward.name + + (reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`) + const utter = new window.SpeechSynthesisUtterance(utterString) + utter.rate = 1.0 + utter.pitch = 1.0 + utter.volume = 1.0 + window.speechSynthesis.speak(utter) + } + if (reward.redeeming) { dialogReward.value = reward showCancelDialog.value = true @@ -271,6 +304,12 @@ function removeInactivityListeners() { if (inactivityTimer) clearTimeout(inactivityTimer) } +const readyItemId = ref(null) + +function handleItemReady(itemId: string) { + readyItemId.value = itemId +} + const hasPendingRewards = computed(() => childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming), ) @@ -333,6 +372,9 @@ onUnmounted(() => { :ids="tasks" itemKey="tasks" imageField="image_id" + :isParentAuthenticated="false" + :readyItemId="readyItemId" + @item-ready="handleItemReady" @trigger-item="triggerTask" :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :filter-fn=" @@ -364,6 +406,9 @@ onUnmounted(() => { :ids="tasks" itemKey="tasks" imageField="image_id" + :isParentAuthenticated="false" + :readyItemId="readyItemId" + @item-ready="handleItemReady" @trigger-item="triggerTask" :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :filter-fn=" @@ -394,6 +439,9 @@ onUnmounted(() => { :fetchBaseUrl="`/api/child/${child?.id}/reward-status`" itemKey="reward_status" imageField="image_id" + :isParentAuthenticated="false" + :readyItemId="readyItemId" + @item-ready="handleItemReady" @trigger-item="triggerReward" :getItemClass=" (item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming }) diff --git a/frontend/vue-app/src/components/child/__tests__/ChildView.spec.ts b/frontend/vue-app/src/components/child/__tests__/ChildView.spec.ts index 4d5c99b..7336c48 100644 --- a/frontend/vue-app/src/components/child/__tests__/ChildView.spec.ts +++ b/frontend/vue-app/src/components/child/__tests__/ChildView.spec.ts @@ -85,6 +85,7 @@ describe('ChildView', () => { // Mock speech synthesis global.window.speechSynthesis = { speak: vi.fn(), + cancel: vi.fn(), } as any global.window.SpeechSynthesisUtterance = vi.fn() as any }) @@ -186,13 +187,18 @@ describe('ChildView', () => { it('speaks task name when triggered', () => { wrapper.vm.triggerTask(mockChore) + expect(window.speechSynthesis.cancel).toHaveBeenCalled() expect(window.speechSynthesis.speak).toHaveBeenCalled() }) it('does not crash if speechSynthesis is not available', () => { + const originalSpeechSynthesis = global.window.speechSynthesis delete (global.window as any).speechSynthesis expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow() + + // Restore for other tests + global.window.speechSynthesis = originalSpeechSynthesis }) }) @@ -309,4 +315,95 @@ describe('ChildView', () => { expect(mockRefresh).not.toHaveBeenCalled() }) }) + + describe('Item Ready State Management', () => { + beforeEach(async () => { + wrapper = mount(ChildView) + await nextTick() + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + it('initializes readyItemId to null', () => { + expect(wrapper.vm.readyItemId).toBe(null) + }) + + it('updates readyItemId when handleItemReady is called with an item ID', () => { + wrapper.vm.handleItemReady('task-1') + expect(wrapper.vm.readyItemId).toBe('task-1') + + wrapper.vm.handleItemReady('reward-2') + expect(wrapper.vm.readyItemId).toBe('reward-2') + }) + + it('clears readyItemId when handleItemReady is called with empty string', () => { + wrapper.vm.readyItemId = 'task-1' + wrapper.vm.handleItemReady('') + expect(wrapper.vm.readyItemId).toBe('') + }) + + it('passes readyItemId prop to Chores ScrollingList', async () => { + wrapper.vm.readyItemId = 'task-1' + await nextTick() + + const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0] + expect(choresScrollingList.props('readyItemId')).toBe('task-1') + }) + + it('passes readyItemId prop to Penalties ScrollingList', async () => { + wrapper.vm.readyItemId = 'task-2' + await nextTick() + + const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1] + expect(penaltiesScrollingList.props('readyItemId')).toBe('task-2') + }) + + it('passes readyItemId prop to Rewards ScrollingList', async () => { + wrapper.vm.readyItemId = 'reward-1' + await nextTick() + + const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2] + expect(rewardsScrollingList.props('readyItemId')).toBe('reward-1') + }) + + it('handles item-ready event from Chores ScrollingList', async () => { + const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0] + + choresScrollingList.vm.$emit('item-ready', 'task-1') + await nextTick() + + expect(wrapper.vm.readyItemId).toBe('task-1') + }) + + it('handles item-ready event from Penalties ScrollingList', async () => { + const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1] + + penaltiesScrollingList.vm.$emit('item-ready', 'task-2') + await nextTick() + + expect(wrapper.vm.readyItemId).toBe('task-2') + }) + + it('handles item-ready event from Rewards ScrollingList', async () => { + const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2] + + rewardsScrollingList.vm.$emit('item-ready', 'reward-1') + await nextTick() + + expect(wrapper.vm.readyItemId).toBe('reward-1') + }) + + it('maintains 2-step click workflow: first click sets ready, second click triggers', async () => { + // Initial state + expect(wrapper.vm.readyItemId).toBe(null) + + // First click - item should become ready + wrapper.vm.handleItemReady('task-1') + expect(wrapper.vm.readyItemId).toBe('task-1') + + // Second click would trigger the item (tested via ScrollingList component) + // After trigger, ready state should be cleared + wrapper.vm.handleItemReady('') + expect(wrapper.vm.readyItemId).toBe('') + }) + }) }) diff --git a/frontend/vue-app/src/components/profile/UserProfile.vue b/frontend/vue-app/src/components/profile/UserProfile.vue index 514c0f1..144e821 100644 --- a/frontend/vue-app/src/components/profile/UserProfile.vue +++ b/frontend/vue-app/src/components/profile/UserProfile.vue @@ -231,6 +231,8 @@ async function updateProfile(form: { }) .then(async (res) => { if (!res.ok) throw new Error('Failed to update profile') + // Update initialData to reflect the saved state + initialData.value = { ...form } modalTitle.value = 'Profile Updated' modalSubtitle.value = '' modalMessage.value = 'Your profile was updated successfully.' @@ -245,7 +247,11 @@ async function updateProfile(form: { } async function handlePasswordModalClose() { + const wasProfileUpdate = modalTitle.value === 'Profile Updated' showModal.value = false + if (wasProfileUpdate) { + router.back() + } } async function resetPassword() { diff --git a/frontend/vue-app/src/components/reward/RewardEditView.vue b/frontend/vue-app/src/components/reward/RewardEditView.vue index 4e1e582..ecfeb79 100644 --- a/frontend/vue-app/src/components/reward/RewardEditView.vue +++ b/frontend/vue-app/src/components/reward/RewardEditView.vue @@ -36,7 +36,7 @@ const fields: { }[] = [ { name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 }, { name: 'description', label: 'Description', type: 'text', maxlength: 128 }, - { name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 1000 }, + { name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 10000 }, { name: 'image_id', label: 'Image', type: 'image', imageType: 2 }, ] // removed duplicate defineProps diff --git a/frontend/vue-app/src/components/shared/EntityEditForm.vue b/frontend/vue-app/src/components/shared/EntityEditForm.vue index 0241fa7..19bd2a2 100644 --- a/frontend/vue-app/src/components/shared/EntityEditForm.vue +++ b/frontend/vue-app/src/components/shared/EntityEditForm.vue @@ -39,7 +39,7 @@ - @@ -47,7 +47,7 @@