#!/usr/bin/env python3 """Tests for habits API endpoints (GET and POST).""" import json import sys import tempfile import shutil from pathlib import Path from datetime import datetime, timedelta from http.server import HTTPServer import threading import time import urllib.request import urllib.error # Add parent directory to path so we can import api module sys.path.insert(0, str(Path(__file__).parent.parent)) # Mock the habits file to a temp location for testing import api original_habits_file = api.HABITS_FILE def setup_test_env(): """Set up temporary test environment.""" temp_dir = Path(tempfile.mkdtemp()) api.HABITS_FILE = temp_dir / 'habits.json' # Create empty habits file api.HABITS_FILE.write_text(json.dumps({ 'lastUpdated': '', 'habits': [] })) return temp_dir def cleanup_test_env(temp_dir): """Clean up temporary test environment.""" api.HABITS_FILE = original_habits_file shutil.rmtree(temp_dir) def start_test_server(port=8765): """Start test server in background thread.""" server = HTTPServer(('localhost', port), api.TaskBoardHandler) thread = threading.Thread(target=server.serve_forever) thread.daemon = True thread.start() time.sleep(0.5) # Give server time to start return server def http_get(path, port=8765): """Make HTTP GET request.""" url = f'http://localhost:{port}{path}' try: with urllib.request.urlopen(url) as response: return response.status, json.loads(response.read().decode()) except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} def http_post(path, data, port=8765): """Make HTTP POST request.""" url = f'http://localhost:{port}{path}' req = urllib.request.Request( url, data=json.dumps(data).encode(), headers={'Content-Type': 'application/json'} ) try: with urllib.request.urlopen(req) as response: return response.status, json.loads(response.read().decode()) except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} def http_put(path, data, port=8765): """Make HTTP PUT request.""" url = f'http://localhost:{port}{path}' req = urllib.request.Request( url, data=json.dumps(data).encode(), headers={'Content-Type': 'application/json'}, method='PUT' ) try: with urllib.request.urlopen(req) as response: return response.status, json.loads(response.read().decode()) except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} def http_delete(path, port=8765): """Make HTTP DELETE request.""" url = f'http://localhost:{port}{path}' req = urllib.request.Request(url, method='DELETE') try: with urllib.request.urlopen(req) as response: return response.status, None except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} # Test 1: GET /api/habits returns empty array when no habits def test_get_habits_empty(): temp_dir = setup_test_env() server = start_test_server() try: status, data = http_get('/api/habits') assert status == 200, f"Expected 200, got {status}" assert data == [], f"Expected empty array, got {data}" print("✓ Test 1: GET /api/habits returns empty array") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 2: POST /api/habits creates new habit with valid input def test_post_habit_valid(): temp_dir = setup_test_env() server = start_test_server() try: habit_data = { 'name': 'Morning Exercise', 'category': 'health', 'color': '#10b981', 'icon': 'dumbbell', 'priority': 1, 'notes': 'Start with 10 push-ups', 'reminderTime': '07:00', 'frequency': { 'type': 'daily' } } status, data = http_post('/api/habits', habit_data) assert status == 201, f"Expected 201, got {status}" assert 'id' in data, "Response should include habit id" assert data['name'] == 'Morning Exercise', f"Name mismatch: {data['name']}" assert data['category'] == 'health', f"Category mismatch: {data['category']}" assert data['streak']['current'] == 0, "Initial streak should be 0" assert data['lives'] == 3, "Initial lives should be 3" assert data['completions'] == [], "Initial completions should be empty" print("✓ Test 2: POST /api/habits creates habit with 201") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 3: POST validates name is required def test_post_habit_missing_name(): temp_dir = setup_test_env() server = start_test_server() try: status, data = http_post('/api/habits', {}) assert status == 400, f"Expected 400, got {status}" assert 'error' in data, "Response should include error" assert 'name' in data['error'].lower(), f"Error should mention name: {data['error']}" print("✓ Test 3: POST validates name is required") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 4: POST validates name max 100 chars def test_post_habit_name_too_long(): temp_dir = setup_test_env() server = start_test_server() try: status, data = http_post('/api/habits', {'name': 'x' * 101}) assert status == 400, f"Expected 400, got {status}" assert 'error' in data, "Response should include error" assert '100' in data['error'], f"Error should mention max length: {data['error']}" print("✓ Test 4: POST validates name max 100 chars") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 5: POST validates color hex format def test_post_habit_invalid_color(): temp_dir = setup_test_env() server = start_test_server() try: status, data = http_post('/api/habits', { 'name': 'Test', 'color': 'not-a-hex-color' }) assert status == 400, f"Expected 400, got {status}" assert 'error' in data, "Response should include error" assert 'color' in data['error'].lower(), f"Error should mention color: {data['error']}" print("✓ Test 5: POST validates color hex format") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 6: POST validates frequency type def test_post_habit_invalid_frequency(): temp_dir = setup_test_env() server = start_test_server() try: status, data = http_post('/api/habits', { 'name': 'Test', 'frequency': {'type': 'invalid_type'} }) assert status == 400, f"Expected 400, got {status}" assert 'error' in data, "Response should include error" assert 'frequency' in data['error'].lower(), f"Error should mention frequency: {data['error']}" print("✓ Test 6: POST validates frequency type") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 7: GET /api/habits returns habits with stats enriched def test_get_habits_with_stats(): temp_dir = setup_test_env() server = start_test_server() try: # Create a habit first habit_data = {'name': 'Daily Reading', 'frequency': {'type': 'daily'}} http_post('/api/habits', habit_data) # Get habits status, data = http_get('/api/habits') assert status == 200, f"Expected 200, got {status}" assert len(data) == 1, f"Expected 1 habit, got {len(data)}" habit = data[0] 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" print("✓ Test 7: GET returns habits with stats enriched") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 8: GET /api/habits sorts by priority ascending def test_get_habits_sorted_by_priority(): temp_dir = setup_test_env() server = start_test_server() try: # Create habits with different priorities http_post('/api/habits', {'name': 'Low Priority', 'priority': 10}) http_post('/api/habits', {'name': 'High Priority', 'priority': 1}) http_post('/api/habits', {'name': 'Medium Priority', 'priority': 5}) # Get habits status, data = http_get('/api/habits') assert status == 200, f"Expected 200, got {status}" assert len(data) == 3, f"Expected 3 habits, got {len(data)}" # Check sorting assert data[0]['priority'] == 1, "First should be priority 1" assert data[1]['priority'] == 5, "Second should be priority 5" assert data[2]['priority'] == 10, "Third should be priority 10" print("✓ Test 8: GET sorts habits by priority ascending") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 9: POST returns 400 for invalid JSON def test_post_habit_invalid_json(): temp_dir = setup_test_env() server = start_test_server() try: url = f'http://localhost:8765/api/habits' req = urllib.request.Request( url, data=b'invalid json{', headers={'Content-Type': 'application/json'} ) try: urllib.request.urlopen(req) assert False, "Should have raised HTTPError" except urllib.error.HTTPError as e: assert e.code == 400, f"Expected 400, got {e.code}" print("✓ Test 9: POST returns 400 for invalid JSON") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 10: POST initializes streak.current=0 def test_post_habit_initial_streak(): temp_dir = setup_test_env() server = start_test_server() try: status, data = http_post('/api/habits', {'name': 'Test Habit'}) assert status == 201, f"Expected 201, got {status}" assert data['streak']['current'] == 0, "Initial streak.current should be 0" assert data['streak']['best'] == 0, "Initial streak.best should be 0" assert data['streak']['lastCheckIn'] is None, "Initial lastCheckIn should be None" print("✓ Test 10: POST initializes streak correctly") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 12: PUT /api/habits/{id} updates habit successfully def test_put_habit_valid(): temp_dir = setup_test_env() server = start_test_server() try: # Create a habit first habit_data = { 'name': 'Original Name', 'category': 'health', 'color': '#10b981', 'priority': 3 } status, created_habit = http_post('/api/habits', habit_data) habit_id = created_habit['id'] # Update the habit update_data = { 'name': 'Updated Name', 'category': 'productivity', 'color': '#ef4444', 'priority': 1, 'notes': 'New notes' } status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data) assert status == 200, f"Expected 200, got {status}" assert updated_habit['name'] == 'Updated Name', "Name not updated" assert updated_habit['category'] == 'productivity', "Category not updated" assert updated_habit['color'] == '#ef4444', "Color not updated" assert updated_habit['priority'] == 1, "Priority not updated" assert updated_habit['notes'] == 'New notes', "Notes not updated" assert updated_habit['id'] == habit_id, "ID should not change" print("✓ Test 12: PUT /api/habits/{id} updates habit successfully") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 13: PUT /api/habits/{id} does not allow editing protected fields def test_put_habit_protected_fields(): temp_dir = setup_test_env() server = start_test_server() try: # Create a habit first habit_data = {'name': 'Test Habit'} status, created_habit = http_post('/api/habits', habit_data) habit_id = created_habit['id'] original_created_at = created_habit['createdAt'] # Try to update protected fields update_data = { 'name': 'Updated Name', 'id': 'new-id', 'createdAt': '2020-01-01T00:00:00', 'streak': {'current': 100, 'best': 200}, 'lives': 10, 'completions': [{'date': '2025-01-01'}] } status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data) assert status == 200, f"Expected 200, got {status}" assert updated_habit['name'] == 'Updated Name', "Name should be updated" assert updated_habit['id'] == habit_id, "ID should not change" assert updated_habit['createdAt'] == original_created_at, "createdAt should not change" assert updated_habit['streak']['current'] == 0, "streak should not change" assert updated_habit['lives'] == 3, "lives should not change" assert updated_habit['completions'] == [], "completions should not change" print("✓ Test 13: PUT /api/habits/{id} does not allow editing protected fields") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 14: PUT /api/habits/{id} returns 404 for non-existent habit def test_put_habit_not_found(): temp_dir = setup_test_env() server = start_test_server() try: update_data = {'name': 'Updated Name'} status, response = http_put('/api/habits/non-existent-id', update_data) assert status == 404, f"Expected 404, got {status}" assert 'error' in response, "Expected error message" print("✓ Test 14: PUT /api/habits/{id} returns 404 for non-existent habit") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 15: PUT /api/habits/{id} validates input def test_put_habit_invalid_input(): temp_dir = setup_test_env() server = start_test_server() try: # Create a habit first habit_data = {'name': 'Test Habit'} status, created_habit = http_post('/api/habits', habit_data) habit_id = created_habit['id'] # Test invalid color update_data = {'color': 'not-a-hex-color'} status, response = http_put(f'/api/habits/{habit_id}', update_data) assert status == 400, f"Expected 400 for invalid color, got {status}" # Test empty name update_data = {'name': ''} status, response = http_put(f'/api/habits/{habit_id}', update_data) assert status == 400, f"Expected 400 for empty name, got {status}" # Test name too long update_data = {'name': 'x' * 101} status, response = http_put(f'/api/habits/{habit_id}', update_data) assert status == 400, f"Expected 400 for long name, got {status}" print("✓ Test 15: PUT /api/habits/{id} validates input") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 16: DELETE /api/habits/{id} removes habit successfully def test_delete_habit_success(): temp_dir = setup_test_env() server = start_test_server() try: # Create a habit first habit_data = {'name': 'Habit to Delete'} status, created_habit = http_post('/api/habits', habit_data) habit_id = created_habit['id'] # Verify habit exists status, habits = http_get('/api/habits') assert len(habits) == 1, "Should have 1 habit" # Delete the habit status, _ = http_delete(f'/api/habits/{habit_id}') assert status == 204, f"Expected 204, got {status}" # Verify habit is deleted status, habits = http_get('/api/habits') assert len(habits) == 0, "Should have 0 habits after deletion" print("✓ Test 16: DELETE /api/habits/{id} removes habit successfully") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 17: DELETE /api/habits/{id} returns 404 for non-existent habit def test_delete_habit_not_found(): temp_dir = setup_test_env() server = start_test_server() try: status, response = http_delete('/api/habits/non-existent-id') assert status == 404, f"Expected 404, got {status}" assert 'error' in response, "Expected error message" print("✓ Test 17: DELETE /api/habits/{id} returns 404 for non-existent habit") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 18: do_OPTIONS includes PUT and DELETE methods def test_options_includes_put_delete(): temp_dir = setup_test_env() server = start_test_server() try: # Make OPTIONS request url = 'http://localhost:8765/api/habits' req = urllib.request.Request(url, method='OPTIONS') with urllib.request.urlopen(req) as response: allowed_methods = response.headers.get('Access-Control-Allow-Methods', '') assert 'PUT' in allowed_methods, f"PUT not in allowed methods: {allowed_methods}" assert 'DELETE' in allowed_methods, f"DELETE not in allowed methods: {allowed_methods}" print("✓ Test 18: do_OPTIONS includes PUT and DELETE methods") finally: server.shutdown() cleanup_test_env(temp_dir) # Test 19: Typecheck passes def test_typecheck(): result = subprocess.run( ['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')], capture_output=True ) assert result.returncode == 0, f"Typecheck failed: {result.stderr.decode()}" print("✓ Test 11: Typecheck passes") if __name__ == '__main__': import subprocess print("\n=== Running Habits API Tests ===\n") test_get_habits_empty() test_post_habit_valid() test_post_habit_missing_name() test_post_habit_name_too_long() test_post_habit_invalid_color() test_post_habit_invalid_frequency() test_get_habits_with_stats() test_get_habits_sorted_by_priority() test_post_habit_invalid_json() test_post_habit_initial_streak() test_put_habit_valid() test_put_habit_protected_fields() test_put_habit_not_found() test_put_habit_invalid_input() test_delete_habit_success() test_delete_habit_not_found() test_options_includes_put_delete() test_typecheck() print("\n✅ All 19 tests passed!\n")