import pytest import os from werkzeug.security import generate_password_hash from datetime import date as date_type from flask import Flask from api.child_api import child_api from api.auth_api import auth_api from db.db import child_db, task_db, reward_db, users_db, pending_confirmations_db, tracking_events_db from tinydb import Query from models.child import Child from models.pending_confirmation import PendingConfirmation from models.tracking_event import TrackingEvent TEST_EMAIL = "testuser@example.com" TEST_PASSWORD = "testpass" TEST_USER_ID = "testuserid" def add_test_user(): users_db.remove(Query().email == TEST_EMAIL) users_db.insert({ "id": TEST_USER_ID, "first_name": "Test", "last_name": "User", "email": TEST_EMAIL, "password": generate_password_hash(TEST_PASSWORD), "verified": True, "image_id": "boy01" }) def login_and_set_cookie(client): resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) assert resp.status_code == 200 @pytest.fixture def client(): app = Flask(__name__) app.register_blueprint(child_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True app.config['SECRET_KEY'] = 'supersecretkey' with app.test_client() as client: add_test_user() login_and_set_cookie(client) yield client def setup_child_and_chore(child_name='TestChild', age=8, chore_points=10): """Helper to create a child with one assigned chore.""" child_db.truncate() task_db.truncate() pending_confirmations_db.truncate() tracking_events_db.truncate() task_db.insert({ 'id': 'chore1', 'name': 'Sweep Floor', 'points': chore_points, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom' }) child = Child(name=child_name, age=age, image_id='boy01').to_dict() child['tasks'] = ['chore1'] child['user_id'] = TEST_USER_ID child['points'] = 50 child_db.insert(child) return child['id'], 'chore1' # --------------------------------------------------------------------------- # Child Confirm Flow # --------------------------------------------------------------------------- def test_child_confirm_chore_success(client): child_id, task_id = setup_child_and_chore() resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) assert resp.status_code == 200 data = resp.get_json() assert 'confirmation_id' in data # Verify PendingConfirmation was created PQ = Query() pending = pending_confirmations_db.get( (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') ) assert pending is not None assert pending['status'] == 'pending' def test_child_confirm_chore_not_assigned(client): child_id, _ = setup_child_and_chore() resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': 'nonexistent'}) assert resp.status_code == 400 assert resp.get_json()['code'] == 'ENTITY_NOT_ASSIGNED' def test_child_confirm_chore_not_found(client): child_db.truncate() task_db.truncate() pending_confirmations_db.truncate() child = Child(name='Kid', age=7, image_id='boy01').to_dict() child['tasks'] = ['missing_task'] child['user_id'] = TEST_USER_ID child_db.insert(child) resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'missing_task'}) assert resp.status_code == 404 assert resp.get_json()['code'] == 'TASK_NOT_FOUND' def test_child_confirm_chore_child_not_found(client): resp = client.post('/child/fake_child/confirm-chore', json={'task_id': 'chore1'}) assert resp.status_code == 404 def test_child_confirm_chore_already_pending(client): child_id, task_id = setup_child_and_chore() client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) assert resp.status_code == 400 assert resp.get_json()['code'] == 'CHORE_ALREADY_PENDING' def test_child_confirm_chore_already_completed_today(client): child_id, task_id = setup_child_and_chore() # Simulate an approved confirmation for today from datetime import datetime, timezone now = datetime.now(timezone.utc).isoformat() pending_confirmations_db.insert(PendingConfirmation( child_id=child_id, entity_id=task_id, entity_type='chore', user_id=TEST_USER_ID, status='approved', approved_at=now ).to_dict()) resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) assert resp.status_code == 400 assert resp.get_json()['code'] == 'CHORE_ALREADY_COMPLETED' def test_child_confirm_chore_creates_tracking_event(client): child_id, task_id = setup_child_and_chore() client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) events = tracking_events_db.all() confirmed_events = [e for e in events if e.get('action') == 'confirmed' and e.get('entity_type') == 'chore'] assert len(confirmed_events) == 1 assert confirmed_events[0]['entity_id'] == task_id assert confirmed_events[0]['points_before'] == confirmed_events[0]['points_after'] def test_child_confirm_chore_wrong_type(client): """Kindness and penalty tasks cannot be confirmed.""" child_db.truncate() task_db.truncate() pending_confirmations_db.truncate() task_db.insert({ 'id': 'kind1', 'name': 'Kind Act', 'points': 5, 'type': 'kindness', 'user_id': TEST_USER_ID }) child = Child(name='Kid', age=7, image_id='boy01').to_dict() child['tasks'] = ['kind1'] child['user_id'] = TEST_USER_ID child_db.insert(child) resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'kind1'}) assert resp.status_code == 400 assert resp.get_json()['code'] == 'INVALID_TASK_TYPE' # --------------------------------------------------------------------------- # Child Cancel Flow # --------------------------------------------------------------------------- def test_child_cancel_confirm_success(client): child_id, task_id = setup_child_and_chore() client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id}) assert resp.status_code == 200 # Pending record should be deleted PQ = Query() assert pending_confirmations_db.get( (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') ) is None def test_child_cancel_confirm_not_pending(client): child_id, task_id = setup_child_and_chore() resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id}) assert resp.status_code == 400 assert resp.get_json()['code'] == 'PENDING_NOT_FOUND' def test_child_cancel_confirm_creates_tracking_event(client): child_id, task_id = setup_child_and_chore() client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) tracking_events_db.truncate() client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id}) events = tracking_events_db.all() cancelled = [e for e in events if e.get('action') == 'cancelled'] assert len(cancelled) == 1 # --------------------------------------------------------------------------- # Parent Approve Flow # --------------------------------------------------------------------------- def test_parent_approve_chore_success(client): child_id, task_id = setup_child_and_chore(chore_points=10) client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) child_before = child_db.get(Query().id == child_id) points_before = child_before['points'] resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) assert resp.status_code == 200 data = resp.get_json() assert data['points'] == points_before + 10 # Verify confirmation is now approved PQ = Query() conf = pending_confirmations_db.get( (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') ) assert conf['status'] == 'approved' assert conf['approved_at'] is not None def test_parent_approve_chore_not_pending(client): child_id, task_id = setup_child_and_chore() resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) assert resp.status_code == 400 assert resp.get_json()['code'] == 'PENDING_NOT_FOUND' def test_parent_approve_chore_creates_tracking_event(client): child_id, task_id = setup_child_and_chore(chore_points=15) client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) tracking_events_db.truncate() client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) events = tracking_events_db.all() approved = [e for e in events if e.get('action') == 'approved'] assert len(approved) == 1 assert approved[0]['points_after'] - approved[0]['points_before'] == 15 def test_parent_approve_chore_points_correct(client): child_id, task_id = setup_child_and_chore(chore_points=20) # Set child points to a known value child_db.update({'points': 100}, Query().id == child_id) client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) assert resp.status_code == 200 assert resp.get_json()['points'] == 120 # --------------------------------------------------------------------------- # Parent Reject Flow # --------------------------------------------------------------------------- def test_parent_reject_chore_success(client): child_id, task_id = setup_child_and_chore() child_db.update({'points': 50}, Query().id == child_id) client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id}) assert resp.status_code == 200 # Points unchanged child = child_db.get(Query().id == child_id) assert child['points'] == 50 # Pending record removed PQ = Query() assert pending_confirmations_db.get( (PQ.child_id == child_id) & (PQ.entity_id == task_id) ) is None def test_parent_reject_chore_not_pending(client): child_id, task_id = setup_child_and_chore() resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id}) assert resp.status_code == 400 assert resp.get_json()['code'] == 'PENDING_NOT_FOUND' def test_parent_reject_chore_creates_tracking_event(client): child_id, task_id = setup_child_and_chore() client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) tracking_events_db.truncate() client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id}) events = tracking_events_db.all() rejected = [e for e in events if e.get('action') == 'rejected'] assert len(rejected) == 1 # --------------------------------------------------------------------------- # Parent Reset Flow # --------------------------------------------------------------------------- def test_parent_reset_chore_success(client): child_id, task_id = setup_child_and_chore(chore_points=10) # Confirm and approve first client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) # Now reset child_before = child_db.get(Query().id == child_id) points_before = child_before['points'] resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) assert resp.status_code == 200 # Points unchanged after reset child_after = child_db.get(Query().id == child_id) assert child_after['points'] == points_before # Confirmation record removed PQ = Query() assert pending_confirmations_db.get( (PQ.child_id == child_id) & (PQ.entity_id == task_id) ) is None def test_parent_reset_chore_not_completed(client): child_id, task_id = setup_child_and_chore() resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) assert resp.status_code == 400 def test_parent_reset_chore_creates_tracking_event(client): child_id, task_id = setup_child_and_chore(chore_points=10) client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) tracking_events_db.truncate() client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) events = tracking_events_db.all() reset_events = [e for e in events if e.get('action') == 'reset'] assert len(reset_events) == 1 def test_parent_reset_then_child_confirm_again(client): """Full cycle: confirm → approve → reset → confirm → approve.""" child_id, task_id = setup_child_and_chore(chore_points=10) child_db.update({'points': 0}, Query().id == child_id) # First cycle client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) child = child_db.get(Query().id == child_id) assert child['points'] == 10 # Reset client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) # Second cycle client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) child = child_db.get(Query().id == child_id) assert child['points'] == 20 # Verify tracking has two approved events approved = [e for e in tracking_events_db.all() if e.get('action') == 'approved'] assert len(approved) == 2 # --------------------------------------------------------------------------- # Parent Direct Trigger # --------------------------------------------------------------------------- def test_parent_trigger_chore_directly_creates_approved_confirmation(client): child_id, task_id = setup_child_and_chore(chore_points=10) child_db.update({'points': 0}, Query().id == child_id) resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id}) assert resp.status_code == 200 assert resp.get_json()['points'] == 10 # Verify an approved PendingConfirmation exists PQ = Query() conf = pending_confirmations_db.get( (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') ) assert conf is not None assert conf['status'] == 'approved' assert conf['approved_at'] is not None # --------------------------------------------------------------------------- # Pending Confirmations List # --------------------------------------------------------------------------- def test_list_pending_confirmations_returns_chores_and_rewards(client): child_db.truncate() task_db.truncate() reward_db.truncate() pending_confirmations_db.truncate() child_db.insert({ 'id': 'ch1', 'name': 'Alice', 'age': 8, 'points': 100, 'tasks': ['chore1'], 'rewards': ['rew1'], 'user_id': TEST_USER_ID, 'image_id': 'girl01' }) task_db.insert({'id': 'chore1', 'name': 'Mop Floor', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'}) reward_db.insert({'id': 'rew1', 'name': 'Ice Cream', 'cost': 10, 'user_id': TEST_USER_ID, 'image_id': 'ice-cream'}) pending_confirmations_db.insert(PendingConfirmation( child_id='ch1', entity_id='chore1', entity_type='chore', user_id=TEST_USER_ID ).to_dict()) pending_confirmations_db.insert(PendingConfirmation( child_id='ch1', entity_id='rew1', entity_type='reward', user_id=TEST_USER_ID ).to_dict()) resp = client.get('/pending-confirmations') assert resp.status_code == 200 data = resp.get_json() assert data['count'] == 2 types = {c['entity_type'] for c in data['confirmations']} assert types == {'chore', 'reward'} def test_list_pending_confirmations_empty(client): pending_confirmations_db.truncate() resp = client.get('/pending-confirmations') assert resp.status_code == 200 data = resp.get_json() assert data['count'] == 0 assert data['confirmations'] == [] def test_list_pending_confirmations_hydrates_names_and_images(client): child_db.truncate() task_db.truncate() pending_confirmations_db.truncate() child_db.insert({ 'id': 'ch_hydrate', 'name': 'Bob', 'age': 9, 'points': 20, 'tasks': ['t_hydrate'], 'rewards': [], 'user_id': TEST_USER_ID, 'image_id': 'boy02' }) task_db.insert({'id': 't_hydrate', 'name': 'Clean Room', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'}) pending_confirmations_db.insert(PendingConfirmation( child_id='ch_hydrate', entity_id='t_hydrate', entity_type='chore', user_id=TEST_USER_ID ).to_dict()) resp = client.get('/pending-confirmations') assert resp.status_code == 200 data = resp.get_json() assert data['count'] == 1 conf = data['confirmations'][0] assert conf['child_name'] == 'Bob' assert conf['entity_name'] == 'Clean Room' assert conf['child_image_id'] == 'boy02' assert conf['entity_image_id'] == 'broom' def test_list_pending_confirmations_excludes_approved(client): child_db.truncate() task_db.truncate() pending_confirmations_db.truncate() child_db.insert({ 'id': 'ch_appr', 'name': 'Carol', 'age': 10, 'points': 0, 'tasks': ['t_appr'], 'rewards': [], 'user_id': TEST_USER_ID, 'image_id': 'girl01' }) task_db.insert({'id': 't_appr', 'name': 'Chore', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID}) from datetime import datetime, timezone pending_confirmations_db.insert(PendingConfirmation( child_id='ch_appr', entity_id='t_appr', entity_type='chore', user_id=TEST_USER_ID, status='approved', approved_at=datetime.now(timezone.utc).isoformat() ).to_dict()) resp = client.get('/pending-confirmations') assert resp.status_code == 200 assert resp.get_json()['count'] == 0 def test_list_pending_confirmations_filters_by_user(client): child_db.truncate() task_db.truncate() pending_confirmations_db.truncate() # Create a pending confirmation for a different user pending_confirmations_db.insert(PendingConfirmation( child_id='other_child', entity_id='other_task', entity_type='chore', user_id='otheruserid' ).to_dict()) resp = client.get('/pending-confirmations') assert resp.status_code == 200 assert resp.get_json()['count'] == 0