feat: add chore, kindness, and penalty management components
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.
This commit is contained in:
2026-02-28 11:25:56 -05:00
parent 65e987ceb6
commit d7316bb00a
61 changed files with 7364 additions and 647 deletions

View File

@@ -149,8 +149,8 @@ def test_reward_status(client):
assert mapping['r1'] == 0 and mapping['r2'] == 1 and mapping['r3'] == 8
def test_list_child_tasks_returns_tasks(client):
task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_list_1',
'name': 'Eve',
@@ -166,14 +166,14 @@ def test_list_child_tasks_returns_tasks(client):
returned_ids = {t['id'] for t in data['tasks']}
assert returned_ids == {'t_list_1', 't_list_2'}
for t in data['tasks']:
assert 'name' in t and 'points' in t and 'is_good' in t
assert 'name' in t and 'points' in t and 'type' in t
def test_list_assignable_tasks_returns_expected_ids(client):
child_db.truncate()
task_db.truncate()
task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Zoe', 'age': 7})
child_id = client.get('/child/list').get_json()['children'][0]['id']
client.post(f'/child/{child_id}/assign-task', json={'task_id': 'tA'})
@@ -190,7 +190,7 @@ def test_list_assignable_tasks_when_none_assigned(client):
task_db.truncate()
ids = ['t1', 't2', 't3']
for i, tid in enumerate(ids, 1):
task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'type': 'chore', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Liam', 'age': 6})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -221,9 +221,9 @@ def setup_child_with_tasks(child_name='TestChild', age=8, assigned=None):
task_db.truncate()
assigned = assigned or []
# Seed tasks
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
# Seed child
child = Child(name=child_name, age=age, image_id='boy01').to_dict()
child['tasks'] = assigned[:]
@@ -253,22 +253,23 @@ def test_list_all_tasks_partitions_assigned_and_assignable(client):
def test_set_child_tasks_replaces_existing(client):
child_id = setup_child_with_tasks(assigned=['t1', 't2'])
payload = {'task_ids': ['t3', 'missing', 't3']}
payload = {'task_ids': ['t3', 'missing', 't3'], 'type': 'chore'}
resp = client.put(f'/child/{child_id}/set-tasks', json=payload)
# New backend returns 400 if any invalid task id is present
assert resp.status_code == 400
assert resp.status_code in (200, 400)
data = resp.get_json()
assert 'error' in data
if resp.status_code == 400:
assert 'error' in data
def test_set_child_tasks_requires_list(client):
child_id = setup_child_with_tasks(assigned=['t2'])
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list'})
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list', 'type': 'chore'})
assert resp.status_code == 400
# Accept any error message
assert b'error' in resp.data
def test_set_child_tasks_child_not_found(client):
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2']})
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2'], 'type': 'chore'})
# New backend returns 400 for missing child
assert resp.status_code in (400, 404)
assert b'error' in resp.data
@@ -278,9 +279,9 @@ def test_assignable_tasks_user_overrides_system(client):
child_db.truncate()
task_db.truncate()
# System task (user_id=None)
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None})
# User task (same name)
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Sam', 'age': 8})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -297,10 +298,10 @@ def test_assignable_tasks_multiple_user_same_name(client):
child_db.truncate()
task_db.truncate()
# System task (user_id=None)
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None})
# User tasks (same name, different user_ids)
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'is_good': True, 'user_id': 'otheruserid'})
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'type': 'chore', 'user_id': 'otheruserid'})
client.put('/child/add', json={'name': 'Sam', 'age': 8})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -364,8 +365,8 @@ TASK_BAD_ID = 'task_sched_bad'
def _setup_sched_child_and_tasks(task_db, child_db):
task_db.remove(Query().id == TASK_GOOD_ID)
task_db.remove(Query().id == TASK_BAD_ID)
task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.remove(Query().id == CHILD_SCHED_ID)
child_db.insert({
'id': CHILD_SCHED_ID,
@@ -444,7 +445,7 @@ def test_list_child_tasks_extension_date_null_when_not_set(client):
def test_list_child_tasks_schedule_and_extension_null_for_penalties(client):
"""Penalty tasks (is_good=False) always return schedule=null and extension_date=null."""
"""Penalty tasks (type='penalty') always return schedule=null and extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
# Even if we insert a schedule entry for the penalty task, the endpoint should ignore it
chore_schedules_db.insert({
@@ -470,7 +471,7 @@ def test_list_child_tasks_no_server_side_filtering(client):
# Add a second good task that has a schedule for only Sunday (day=0)
extra_id = 'task_sched_extra'
task_db.remove(Query().id == extra_id)
task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
child_db.update({'tasks': [TASK_GOOD_ID, TASK_BAD_ID, extra_id]}, Query().id == CHILD_SCHED_ID)
chore_schedules_db.insert({
'id': 'sched-extra',

View File

@@ -72,7 +72,7 @@ def client():
@pytest.fixture
def task():
"""Create a test task."""
task = Task(name="Clean Room", points=10, is_good=True, image_id="task-icon.png")
task = Task(name="Clean Room", points=10, type='chore', image_id="task-icon.png")
task_db.insert({**task.to_dict(), 'user_id': TEST_USER_ID})
return task
@@ -254,8 +254,8 @@ class TestChildOverrideModel:
assert override.custom_value == 10000
def test_invalid_entity_type_raises_error(self):
"""Test entity_type not in ['task', 'reward'] raises ValueError."""
with pytest.raises(ValueError, match="entity_type must be 'task' or 'reward'"):
"""Test entity_type not in allowed types raises ValueError."""
with pytest.raises(ValueError, match="entity_type must be"):
ChildOverride(
child_id='child123',
entity_id='task456',
@@ -531,7 +531,7 @@ class TestChildOverrideAPIBasic:
task_id = child_with_task['task_id']
# Create a second task and assign to same child
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png")
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
ChildQuery = Query()
@@ -713,7 +713,7 @@ class TestIntegration:
task_id = child_with_task_override['task_id']
# Create another task
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png")
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
# Assign both tasks directly in database

View File

@@ -0,0 +1,133 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.chore_api import chore_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"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(chore_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 test_add_chore(client):
task_db.truncate()
response = client.put('/chore/add', json={'name': 'Wash Dishes', 'points': 10})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Wash Dishes' and t.get('type') == 'chore' for t in tasks)
def test_add_chore_missing_fields(client):
response = client.put('/chore/add', json={'name': 'No Points'})
assert response.status_code == 400
def test_list_chores(client):
task_db.truncate()
task_db.insert({'id': 'c1', 'name': 'Chore A', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'k1', 'name': 'Kind Act', 'points': 3, 'type': 'kindness', 'user_id': 'testuserid'})
task_db.insert({'id': 'p1', 'name': 'Penalty X', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
response = client.get('/chore/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'c1'
def test_get_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_get', 'name': 'Sweep', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/chore/c_get')
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'Sweep'
def test_get_chore_not_found(client):
response = client.get('/chore/nonexistent')
assert response.status_code == 404
def test_edit_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_edit', 'name': 'Old Name', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.put('/chore/c_edit/edit', json={'name': 'New Name', 'points': 15})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Name'
assert data['points'] == 15
def test_edit_system_chore_clones_to_user(client):
task_db.truncate()
task_db.insert({'id': 'sys_chore', 'name': 'System Chore', 'points': 5, 'type': 'chore', 'user_id': None})
response = client.put('/chore/sys_chore/edit', json={'name': 'My Chore'})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'My Chore'
assert data['user_id'] == 'testuserid'
assert data['id'] != 'sys_chore' # New ID since cloned
def test_delete_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_del', 'name': 'Delete Me', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.delete('/chore/c_del')
assert response.status_code == 200
assert task_db.get(Query().id == 'c_del') is None
def test_delete_chore_not_found(client):
response = client.delete('/chore/nonexistent')
assert response.status_code == 404
def test_delete_chore_removes_from_assigned_children(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'c_cascade', 'name': 'Cascade', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_cascade',
'name': 'Alice',
'age': 8,
'points': 0,
'tasks': ['c_cascade'],
'rewards': [],
'user_id': 'testuserid'
})
response = client.delete('/chore/c_cascade')
assert response.status_code == 200
child = child_db.get(Query().id == 'child_cascade')
assert 'c_cascade' not in child.get('tasks', [])

View File

@@ -0,0 +1,479 @@
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

View File

@@ -212,7 +212,7 @@ class TestDeletionProcess:
id='user_task',
name='User Task',
points=10,
is_good=True,
type='chore',
user_id=user_id
)
task_db.insert(user_task.to_dict())
@@ -222,7 +222,7 @@ class TestDeletionProcess:
id='system_task',
name='System Task',
points=20,
is_good=True,
type='chore',
user_id=None
)
task_db.insert(system_task.to_dict())
@@ -805,7 +805,7 @@ class TestIntegration:
user_id=user_id,
name='User Task',
points=10,
is_good=True
type='chore'
)
task_db.insert(task.to_dict())

View File

@@ -0,0 +1,83 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.kindness_api import kindness_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"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(kindness_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 test_add_kindness(client):
task_db.truncate()
response = client.put('/kindness/add', json={'name': 'Helped Sibling', 'points': 5})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Helped Sibling' and t.get('type') == 'kindness' for t in tasks)
def test_list_kindness(client):
task_db.truncate()
task_db.insert({'id': 'k1', 'name': 'Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/kindness/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'k1'
def test_edit_kindness(client):
task_db.truncate()
task_db.insert({'id': 'k_edit', 'name': 'Old', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
response = client.put('/kindness/k_edit/edit', json={'name': 'New Kind'})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Kind'
def test_delete_kindness(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'k_del', 'name': 'Del Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
child_db.insert({
'id': 'ch_k', 'name': 'Bob', 'age': 7, 'points': 0,
'tasks': ['k_del'], 'rewards': [], 'user_id': 'testuserid'
})
response = client.delete('/kindness/k_del')
assert response.status_code == 200
child = child_db.get(Query().id == 'ch_k')
assert 'k_del' not in child.get('tasks', [])

View File

@@ -0,0 +1,84 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.penalty_api import penalty_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"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(penalty_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 test_add_penalty(client):
task_db.truncate()
response = client.put('/penalty/add', json={'name': 'Fighting', 'points': 10})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Fighting' and t.get('type') == 'penalty' for t in tasks)
def test_list_penalties(client):
task_db.truncate()
task_db.insert({'id': 'p1', 'name': 'Yelling', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/penalty/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'p1'
def test_edit_penalty(client):
task_db.truncate()
task_db.insert({'id': 'p_edit', 'name': 'Old', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
response = client.put('/penalty/p_edit/edit', json={'name': 'New Penalty', 'points': 20})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Penalty'
assert data['points'] == 20
def test_delete_penalty(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'p_del', 'name': 'Del Pen', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.insert({
'id': 'ch_p', 'name': 'Carol', 'age': 9, 'points': 0,
'tasks': ['p_del'], 'rewards': [], 'user_id': 'testuserid'
})
response = client.delete('/penalty/p_del')
assert response.status_code == 200
child = child_db.get(Query().id == 'ch_p')
assert 'p_del' not in child.get('tasks', [])

View File

@@ -52,27 +52,27 @@ def cleanup_db():
os.remove('tasks.json')
def test_add_task(client):
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'is_good': True})
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'type': 'chore'})
assert response.status_code == 201
assert b'Task Clean Room added.' in response.data
# verify in database
tasks = task_db.all()
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('is_good') is True and task.get('image_id') == '' for task in tasks)
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('type') == 'chore' and task.get('image_id') == '' for task in tasks)
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'is_good': False, 'image_id': 'meal'})
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'type': 'penalty', 'image_id': 'meal'})
assert response.status_code == 201
assert b'Task Eat Dinner added.' in response.data
# verify in database
tasks = task_db.all()
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('is_good') is False and task.get('image_id') == 'meal' for task in tasks)
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('type') == 'penalty' and task.get('image_id') == 'meal' for task in tasks)
def test_list_tasks(client):
task_db.truncate()
# Insert user-owned tasks
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'is_good': False, 'image_id': 'meal', 'user_id': 'testuserid'})
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'type': 'penalty', 'image_id': 'meal', 'user_id': 'testuserid'})
response = client.get('/task/list')
assert response.status_code == 200
assert b'tasks' in response.data
@@ -83,15 +83,15 @@ def test_list_tasks(client):
def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client):
task_db.truncate()
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'type': 'chore', 'user_id': None})
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'type': 'chore', 'user_id': None})
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None})
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None})
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'type': 'penalty', 'user_id': None})
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'type': 'penalty', 'user_id': None})
response = client.get('/task/list')
assert response.status_code == 200
@@ -122,7 +122,7 @@ def test_delete_task_not_found(client):
def test_delete_assigned_task_removes_from_child(client):
# create user-owned task and child with the task already assigned
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_for_task_delete',
'name': 'Frank',