From 648185abe6cb80182d9f62bf06b3fdf1478c0b03 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 15:58:48 +0000 Subject: [PATCH] feat: US-003 - Backend API - PUT and DELETE habits --- dashboard/api.py | 143 ++++++++++++++++++- dashboard/tests/test_habits_api.py | 216 ++++++++++++++++++++++++++++- 2 files changed, 356 insertions(+), 3 deletions(-) diff --git a/dashboard/api.py b/dashboard/api.py index 2766afa..f531cb7 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -69,6 +69,18 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): else: self.send_error(404) + def do_PUT(self): + if self.path.startswith('/api/habits/'): + self.handle_habits_put() + else: + self.send_error(404) + + def do_DELETE(self): + if self.path.startswith('/api/habits/'): + self.handle_habits_delete() + else: + self.send_error(404) + def handle_git_commit(self): """Run git commit and push.""" try: @@ -1507,6 +1519,135 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): except Exception as e: self.send_json({'error': str(e)}, 500) + def handle_habits_put(self): + """Update an existing habit.""" + try: + # Extract habit ID from path + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] + + # Read request body + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length).decode('utf-8') + data = json.loads(post_data) + + # Read existing habits + if not HABITS_FILE.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + + # Find habit to update + habits = habits_data.get('habits', []) + habit_index = None + for i, habit in enumerate(habits): + if habit['id'] == habit_id: + habit_index = i + break + + if habit_index is None: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Validate allowed fields + allowed_fields = ['name', 'category', 'color', 'icon', 'priority', 'notes', 'frequency', 'reminderTime'] + + # Validate name if provided + if 'name' in data: + name = data['name'].strip() + if not name: + self.send_json({'error': 'name cannot be empty'}, 400) + return + if len(name) > 100: + self.send_json({'error': 'name must be max 100 characters'}, 400) + return + + # Validate color if provided + if 'color' in data: + color = data['color'] + if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color): + self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400) + return + + # Validate frequency type if provided + if 'frequency' in data: + frequency_type = data.get('frequency', {}).get('type', 'daily') + valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom'] + if frequency_type not in valid_types: + self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400) + return + + # Update only allowed fields + habit = habits[habit_index] + for field in allowed_fields: + if field in data: + habit[field] = data[field] + + # Update timestamp + habit['updatedAt'] = datetime.now().isoformat() + + # Save to file + habits_data['lastUpdated'] = datetime.now().isoformat() + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Return updated habit + self.send_json(habit) + except json.JSONDecodeError: + self.send_json({'error': 'Invalid JSON'}, 400) + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_delete(self): + """Delete a habit.""" + try: + # Extract habit ID from path + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] + + # Read existing habits + if not HABITS_FILE.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + + # Find and remove habit + habits = habits_data.get('habits', []) + habit_found = False + for i, habit in enumerate(habits): + if habit['id'] == habit_id: + habits.pop(i) + habit_found = True + break + + if not habit_found: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Save to file + habits_data['lastUpdated'] = datetime.now().isoformat() + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Return 204 No Content + self.send_response(204) + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + except Exception as e: + self.send_json({'error': str(e)}, 500) + def send_json(self, data, code=200): self.send_response(code) self.send_header('Content-Type', 'application/json') @@ -1520,7 +1661,7 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): def do_OPTIONS(self): self.send_response(200) self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type') self.end_headers() diff --git a/dashboard/tests/test_habits_api.py b/dashboard/tests/test_habits_api.py index f74e583..0e5758e 100644 --- a/dashboard/tests/test_habits_api.py +++ b/dashboard/tests/test_habits_api.py @@ -70,6 +70,31 @@ def http_post(path, data, port=8765): 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() @@ -270,7 +295,187 @@ def test_post_habit_initial_streak(): server.shutdown() cleanup_test_env(temp_dir) -# Test 11: Typecheck passes +# 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')], @@ -294,6 +499,13 @@ if __name__ == '__main__': 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 11 tests passed!\n") + print("\n✅ All 19 tests passed!\n")