All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
- Implemented ChoreAssignView for assigning chores to children. - Created ChoreConfirmDialog for confirming chore completion. - Developed KindnessAssignView for assigning kindness acts. - Added PenaltyAssignView for assigning penalties. - Introduced ChoreEditView and ChoreView for editing and viewing chores. - Created KindnessEditView and KindnessView for managing kindness acts. - Developed PenaltyEditView and PenaltyView for managing penalties. - Added TaskSubNav for navigation between chores, kindness acts, and penalties.
480 lines
18 KiB
Python
480 lines
18 KiB
Python
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
|