import pytest from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash from flask import Flask from api.chore_schedule_api import chore_schedule_api from api.auth_api import auth_api from db.db import users_db, child_db, chore_schedules_db, task_extensions_db from tinydb import Query TEST_EMAIL = "sched_test@example.com" TEST_PASSWORD = "testpass" TEST_CHILD_ID = "sched-child-1" TEST_TASK_ID = "sched-task-1" TEST_USER_ID = "sched-user-1" def add_test_user(): users_db.remove(Query().email == TEST_EMAIL) users_db.insert({ "id": TEST_USER_ID, "first_name": "Sched", "last_name": "Tester", "email": TEST_EMAIL, "password": generate_password_hash(TEST_PASSWORD), "verified": True, "image_id": "", }) def add_test_child(): child_db.remove(Query().id == TEST_CHILD_ID) child_db.insert({ "id": TEST_CHILD_ID, "user_id": TEST_USER_ID, "name": "Test Child", "points": 0, "image_id": "", "tasks": [TEST_TASK_ID], "rewards": [], }) 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_schedule_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True app.config['SECRET_KEY'] = TEST_SECRET_KEY app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() add_test_child() chore_schedules_db.truncate() task_extensions_db.truncate() login_and_set_cookie(client) yield client # --------------------------------------------------------------------------- # GET schedule # --------------------------------------------------------------------------- def test_get_schedule_not_found(client): resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') assert resp.status_code == 404 def test_get_schedule_returns_404_for_unknown_child(client): resp = client.get('/child/bad-child/task/bad-task/schedule') assert resp.status_code == 404 # --------------------------------------------------------------------------- # PUT (set) schedule – days mode # --------------------------------------------------------------------------- def test_set_schedule_days_mode(client): payload = { "mode": "days", "day_configs": [ {"day": 1, "hour": 8, "minute": 0}, {"day": 3, "hour": 9, "minute": 30}, ], } resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 200 data = resp.get_json() assert data["mode"] == "days" assert len(data["day_configs"]) == 2 assert data["child_id"] == TEST_CHILD_ID assert data["task_id"] == TEST_TASK_ID def test_get_schedule_after_set(client): payload = {"mode": "days", "day_configs": [{"day": 0, "hour": 7, "minute": 0}]} client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') assert resp.status_code == 200 data = resp.get_json() assert data["mode"] == "days" assert data["day_configs"][0]["day"] == 0 # --------------------------------------------------------------------------- # PUT (set) schedule – interval mode # --------------------------------------------------------------------------- def test_set_schedule_interval_mode(client): payload = { "mode": "interval", "interval_days": 3, "anchor_date": "2026-03-01", "interval_has_deadline": True, "interval_hour": 14, "interval_minute": 30, } resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 200 data = resp.get_json() assert data["mode"] == "interval" assert data["interval_days"] == 3 assert data["anchor_date"] == "2026-03-01" assert data["interval_has_deadline"] is True assert data["interval_hour"] == 14 assert data["interval_minute"] == 30 def test_set_schedule_interval_days_1_valid(client): """interval_days=1 is now valid (range changed to [1, 7]).""" payload = {"mode": "interval", "interval_days": 1, "anchor_date": ""} resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 200 assert resp.get_json()["interval_days"] == 1 def test_set_schedule_interval_days_0_invalid(client): """interval_days=0 is still out of range.""" payload = {"mode": "interval", "interval_days": 0, "anchor_date": ""} resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 400 def test_set_schedule_interval_days_8_invalid(client): """interval_days=8 is still out of range.""" payload = {"mode": "interval", "interval_days": 8, "anchor_date": ""} resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 400 def test_set_schedule_interval_has_deadline_false(client): """interval_has_deadline=False is accepted and persisted.""" payload = { "mode": "interval", "interval_days": 2, "anchor_date": "", "interval_has_deadline": False, "interval_hour": 0, "interval_minute": 0, } resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 200 assert resp.get_json()["interval_has_deadline"] is False def test_set_schedule_interval_anchor_date_empty_string(client): """anchor_date empty string is valid (means use today).""" payload = {"mode": "interval", "interval_days": 2, "anchor_date": ""} resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 200 assert resp.get_json()["anchor_date"] == "" def test_set_schedule_invalid_mode(client): payload = {"mode": "weekly", "day_configs": []} resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) assert resp.status_code == 400 def test_set_schedule_upserts_existing(client): # Set once with days mode client.put( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={"mode": "days", "day_configs": [{"day": 1, "hour": 8, "minute": 0}]}, ) # Overwrite with interval mode client.put( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={"mode": "interval", "interval_days": 2, "anchor_date": "", "interval_hour": 9, "interval_minute": 0}, ) resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') assert resp.status_code == 200 assert resp.get_json()["mode"] == "interval" def test_old_record_missing_new_fields_loads_with_defaults(client): """Old DB records without anchor_date/interval_has_deadline load with correct defaults.""" from db.chore_schedules import upsert_schedule from models.chore_schedule import ChoreSchedule # Insert a schedule as if it was created before phase 2 (missing new fields) old_style = ChoreSchedule( child_id=TEST_CHILD_ID, task_id=TEST_TASK_ID, mode='interval', interval_days=3, anchor_date='', # default value interval_has_deadline=True, # default value interval_hour=8, interval_minute=0, ) upsert_schedule(old_style) # Manually wipe the new fields from the raw stored record to simulate a pre-phase-2 record from db.db import chore_schedules_db from tinydb import Query ScheduleQ = Query() record = chore_schedules_db.search( (ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID) )[0] doc_id = chore_schedules_db.get( (ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID) ).doc_id chore_schedules_db.update( lambda rec: ( rec.pop('anchor_date', None), rec.pop('interval_has_deadline', None), ), doc_ids=[doc_id], ) resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') assert resp.status_code == 200 data = resp.get_json() assert data['anchor_date'] == '' assert data['interval_has_deadline'] is True # --------------------------------------------------------------------------- # DELETE schedule # --------------------------------------------------------------------------- def test_delete_schedule(client): client.put( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={"mode": "days", "day_configs": []}, ) resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') assert resp.status_code == 200 # Verify gone resp2 = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') assert resp2.status_code == 404 def test_delete_schedule_not_found(client): chore_schedules_db.truncate() resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') assert resp.status_code == 404 # --------------------------------------------------------------------------- # POST extend # --------------------------------------------------------------------------- def test_extend_chore_time(client): resp = client.post( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={"date": "2025-01-15"}, ) assert resp.status_code == 200 data = resp.get_json() assert data["child_id"] == TEST_CHILD_ID assert data["task_id"] == TEST_TASK_ID assert data["date"] == "2025-01-15" def test_extend_chore_time_duplicate_returns_409(client): task_extensions_db.truncate() client.post( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={"date": "2025-01-15"}, ) resp = client.post( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={"date": "2025-01-15"}, ) assert resp.status_code == 409 def test_extend_chore_time_different_dates_allowed(client): task_extensions_db.truncate() r1 = client.post( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={"date": "2025-01-15"}, ) r2 = client.post( f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={"date": "2025-01-16"}, ) assert r1.status_code == 200 assert r2.status_code == 200 def test_extend_chore_time_missing_date(client): resp = client.post(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={}) assert resp.status_code == 400 def test_extend_chore_time_bad_child(client): resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"}) assert resp.status_code == 404