#!/usr/bin/env python3 """ Integration tests for Habits feature - End-to-end flows Tests complete workflows involving multiple API calls and state transitions. """ import json import os import sys import tempfile import shutil from datetime import datetime, timedelta from http.server import HTTPServer from threading import Thread import urllib.request import urllib.error # Add parent directory to path to import api module sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from api import TaskBoardHandler import habits_helpers # Test helpers def setup_test_env(): """Create temporary environment for testing""" from pathlib import Path temp_dir = tempfile.mkdtemp() habits_file = Path(temp_dir) / 'habits.json' # Initialize empty habits file with open(habits_file, 'w') as f: json.dump({'lastUpdated': datetime.now().isoformat(), 'habits': []}, f) # Override HABITS_FILE constant import api api.HABITS_FILE = habits_file return temp_dir def teardown_test_env(temp_dir): """Clean up temporary environment""" shutil.rmtree(temp_dir) def start_test_server(): """Start HTTP server on random port for testing""" server = HTTPServer(('localhost', 0), TaskBoardHandler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() return server def http_request(url, method='GET', data=None): """Make HTTP request and return response data""" headers = {'Content-Type': 'application/json'} if data: data = json.dumps(data).encode('utf-8') req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req) as response: body = response.read().decode('utf-8') return json.loads(body) if body else None except urllib.error.HTTPError as e: error_body = e.read().decode('utf-8') try: return {'error': json.loads(error_body), 'status': e.code} except: return {'error': error_body, 'status': e.code} # Integration Tests def test_01_create_and_checkin_increments_streak(): """Integration test: create habit → check-in → verify streak is 1""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Create daily habit habit_data = { 'name': 'Morning meditation', 'category': 'health', 'color': '#10B981', 'icon': 'brain', 'priority': 50, 'frequency': {'type': 'daily'} } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) if 'error' in result: print(f"Error creating habit: {result}") assert 'id' in result, f"Should return created habit with ID, got: {result}" habit_id = result['id'] # Check in today checkin_result = http_request(f"{base_url}/api/habits/{habit_id}/check", method='POST') # Verify streak incremented to 1 assert checkin_result['streak']['current'] == 1, "Streak should be 1 after first check-in" assert checkin_result['streak']['best'] == 1, "Best streak should be 1 after first check-in" assert checkin_result['streak']['lastCheckIn'] == datetime.now().date().isoformat(), "Last check-in should be today" print("✓ Test 1: Create + check-in → streak is 1") finally: server.shutdown() teardown_test_env(temp_dir) def test_02_seven_consecutive_checkins_restore_life(): """Integration test: 7 consecutive check-ins → life restored (if below 3)""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Create daily habit habit_data = { 'name': 'Daily exercise', 'category': 'health', 'color': '#EF4444', 'icon': 'dumbbell', 'priority': 50, 'frequency': {'type': 'daily'} } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) habit_id = result['id'] # Manually set lives to 1 (instead of using skip API which would add completions) import api with open(api.HABITS_FILE, 'r') as f: data = json.load(f) habit_obj = next(h for h in data['habits'] if h['id'] == habit_id) habit_obj['lives'] = 1 # Directly set to 1 (simulating 2 skips used) # Add 7 consecutive check-in completions for the past 7 days for i in range(7): check_date = (datetime.now() - timedelta(days=6-i)).date().isoformat() habit_obj['completions'].append({ 'date': check_date, 'type': 'check' }) # Recalculate streak and check for life restore habit_obj['streak'] = { 'current': habits_helpers.calculate_streak(habit_obj), 'best': max(habit_obj['streak']['best'], habits_helpers.calculate_streak(habit_obj)), 'lastCheckIn': datetime.now().date().isoformat() } # Check life restore logic: last 7 completions all 'check' type last_7 = habit_obj['completions'][-7:] if len(last_7) == 7 and all(c.get('type') == 'check' for c in last_7): if habit_obj['lives'] < 3: habit_obj['lives'] += 1 data['lastUpdated'] = datetime.now().isoformat() with open(api.HABITS_FILE, 'w') as f: json.dump(data, f, indent=2) # Get updated habit habits = http_request(f"{base_url}/api/habits") habit = next(h for h in habits if h['id'] == habit_id) # Verify life restored assert habit['lives'] == 2, f"Should have 2 lives after 7 consecutive check-ins (was {habit['lives']})" assert habit['current_streak'] == 7, "Should have streak of 7" print("✓ Test 2: 7 consecutive check-ins → life restored") finally: server.shutdown() teardown_test_env(temp_dir) def test_03_skip_with_life_maintains_streak(): """Integration test: skip with life → lives decremented, streak unchanged""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Create daily habit habit_data = { 'name': 'Read book', 'category': 'growth', 'color': '#3B82F6', 'icon': 'book', 'priority': 50, 'frequency': {'type': 'daily'} } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) habit_id = result['id'] # Check in yesterday (to build a streak) import api with open(api.HABITS_FILE, 'r') as f: data = json.load(f) habit_obj = next(h for h in data['habits'] if h['id'] == habit_id) yesterday = (datetime.now() - timedelta(days=1)).date().isoformat() habit_obj['completions'].append({ 'date': yesterday, 'type': 'check' }) habit_obj['streak'] = { 'current': 1, 'best': 1, 'lastCheckIn': yesterday } data['lastUpdated'] = datetime.now().isoformat() with open(api.HABITS_FILE, 'w') as f: json.dump(data, f, indent=2) # Skip today skip_result = http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST') # Verify lives decremented and streak maintained assert skip_result['lives'] == 2, "Lives should be 2 after skip" # Get fresh habit data to check streak habits = http_request(f"{base_url}/api/habits") habit = next(h for h in habits if h['id'] == habit_id) # Streak should still be 1 (skip doesn't break it) assert habit['current_streak'] == 1, "Streak should be maintained after skip" print("✓ Test 3: Skip with life → lives decremented, streak unchanged") finally: server.shutdown() teardown_test_env(temp_dir) def test_04_skip_with_zero_lives_returns_400(): """Integration test: skip with 0 lives → returns 400 error""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Create daily habit habit_data = { 'name': 'Yoga practice', 'category': 'health', 'color': '#8B5CF6', 'icon': 'heart', 'priority': 50, 'frequency': {'type': 'daily'} } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) habit_id = result['id'] # Use all 3 lives http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST') http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST') http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST') # Attempt to skip with 0 lives result = http_request(f"{base_url}/api/habits/{habit_id}/skip", method='POST') # Verify 400 error assert result['status'] == 400, "Should return 400 status" assert 'error' in result, "Should return error message" print("✓ Test 4: Skip with 0 lives → returns 400 error") finally: server.shutdown() teardown_test_env(temp_dir) def test_05_edit_frequency_changes_should_check_today(): """Integration test: edit frequency → should_check_today logic changes""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Create daily habit habit_data = { 'name': 'Code review', 'category': 'work', 'color': '#F59E0B', 'icon': 'code', 'priority': 50, 'frequency': {'type': 'daily'} } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) habit_id = result['id'] # Verify should_check_today is True for daily habit habits = http_request(f"{base_url}/api/habits") habit = next(h for h in habits if h['id'] == habit_id) assert habit['should_check_today'] == True, "Daily habit should be checkable today" # Edit to specific_days (only Monday and Wednesday) update_data = { 'name': 'Code review', 'category': 'work', 'color': '#F59E0B', 'icon': 'code', 'priority': 50, 'frequency': { 'type': 'specific_days', 'days': ['monday', 'wednesday'] } } http_request(f"{base_url}/api/habits/{habit_id}", method='PUT', data=update_data) # Get updated habit habits = http_request(f"{base_url}/api/habits") habit = next(h for h in habits if h['id'] == habit_id) # Verify should_check_today reflects new frequency today_name = datetime.now().strftime('%A').lower() expected = today_name in ['monday', 'wednesday'] assert habit['should_check_today'] == expected, f"Should check today should be {expected} for {today_name}" print(f"✓ Test 5: Edit frequency → should_check_today is {expected} for {today_name}") finally: server.shutdown() teardown_test_env(temp_dir) def test_06_delete_removes_habit_from_storage(): """Integration test: delete → habit removed from storage""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Create habit habit_data = { 'name': 'Guitar practice', 'category': 'personal', 'color': '#EC4899', 'icon': 'music', 'priority': 50, 'frequency': {'type': 'daily'} } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) habit_id = result['id'] # Verify habit exists habits = http_request(f"{base_url}/api/habits") assert len(habits) == 1, "Should have 1 habit" assert habits[0]['id'] == habit_id, "Should be the created habit" # Delete habit http_request(f"{base_url}/api/habits/{habit_id}", method='DELETE') # Verify habit removed habits = http_request(f"{base_url}/api/habits") assert len(habits) == 0, "Should have 0 habits after delete" # Verify not in storage file import api with open(api.HABITS_FILE, 'r') as f: data = json.load(f) assert len(data['habits']) == 0, "Storage file should have 0 habits" print("✓ Test 6: Delete → habit removed from storage") finally: server.shutdown() teardown_test_env(temp_dir) def test_07_checkin_on_wrong_day_for_specific_days_returns_400(): """Integration test: check-in on wrong day for specific_days → returns 400""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Get today's day name today_name = datetime.now().strftime('%A').lower() # Create habit for different days (not today) if today_name == 'monday': allowed_days = ['tuesday', 'wednesday'] elif today_name == 'tuesday': allowed_days = ['monday', 'wednesday'] else: allowed_days = ['monday', 'tuesday'] habit_data = { 'name': 'Gym workout', 'category': 'health', 'color': '#EF4444', 'icon': 'dumbbell', 'priority': 50, 'frequency': { 'type': 'specific_days', 'days': allowed_days } } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) habit_id = result['id'] # Attempt to check in today (wrong day) result = http_request(f"{base_url}/api/habits/{habit_id}/check", method='POST') # Verify 400 error assert result['status'] == 400, "Should return 400 status" assert 'error' in result, "Should return error message" print(f"✓ Test 7: Check-in on {today_name} (not in {allowed_days}) → returns 400") finally: server.shutdown() teardown_test_env(temp_dir) def test_08_get_response_includes_all_stats(): """Integration test: GET response includes stats (streak, completion_rate, weekly_summary)""" temp_dir = setup_test_env() server = start_test_server() base_url = f"http://localhost:{server.server_port}" try: # Create habit with some completions habit_data = { 'name': 'Meditation', 'category': 'health', 'color': '#10B981', 'icon': 'brain', 'priority': 50, 'frequency': {'type': 'daily'} } result = http_request(f"{base_url}/api/habits", method='POST', data=habit_data) habit_id = result['id'] # Add some completions import api with open(api.HABITS_FILE, 'r') as f: data = json.load(f) habit_obj = next(h for h in data['habits'] if h['id'] == habit_id) # Add completions for last 3 days for i in range(3): check_date = (datetime.now() - timedelta(days=2-i)).date().isoformat() habit_obj['completions'].append({ 'date': check_date, 'type': 'check' }) habit_obj['streak'] = { 'current': 3, 'best': 3, 'lastCheckIn': datetime.now().date().isoformat() } data['lastUpdated'] = datetime.now().isoformat() with open(api.HABITS_FILE, 'w') as f: json.dump(data, f, indent=2) # Get habits habits = http_request(f"{base_url}/api/habits") habit = habits[0] # Verify all enriched stats are present assert 'current_streak' in habit, "Should include current_streak" assert 'best_streak' in habit, "Should include best_streak" assert 'completion_rate_30d' in habit, "Should include completion_rate_30d" assert 'weekly_summary' in habit, "Should include weekly_summary" assert 'should_check_today' in habit, "Should include should_check_today" # Verify streak values assert habit['current_streak'] == 3, "Current streak should be 3" assert habit['best_streak'] == 3, "Best streak should be 3" # Verify weekly_summary structure assert isinstance(habit['weekly_summary'], dict), "Weekly summary should be a dict" days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] for day in days: assert day in habit['weekly_summary'], f"Weekly summary should include {day}" print("✓ Test 8: GET response includes all stats (streak, completion_rate, weekly_summary)") finally: server.shutdown() teardown_test_env(temp_dir) def test_09_typecheck_passes(): """Integration test: Typecheck passes""" result = os.system('python3 -m py_compile /home/moltbot/clawd/dashboard/api.py') assert result == 0, "Typecheck should pass for api.py" result = os.system('python3 -m py_compile /home/moltbot/clawd/dashboard/habits_helpers.py') assert result == 0, "Typecheck should pass for habits_helpers.py" print("✓ Test 9: Typecheck passes") # Run all tests if __name__ == '__main__': tests = [ test_01_create_and_checkin_increments_streak, test_02_seven_consecutive_checkins_restore_life, test_03_skip_with_life_maintains_streak, test_04_skip_with_zero_lives_returns_400, test_05_edit_frequency_changes_should_check_today, test_06_delete_removes_habit_from_storage, test_07_checkin_on_wrong_day_for_specific_days_returns_400, test_08_get_response_includes_all_stats, test_09_typecheck_passes, ] passed = 0 failed = 0 print("Running integration tests...\n") for test in tests: try: test() passed += 1 except AssertionError as e: print(f"✗ {test.__name__}: {e}") failed += 1 except Exception as e: print(f"✗ {test.__name__}: Unexpected error: {e}") import traceback traceback.print_exc() failed += 1 print(f"\n{'='*50}") print(f"Integration Tests: {passed} passed, {failed} failed") print(f"{'='*50}") sys.exit(0 if failed == 0 else 1)