feat: US-003 - Backend API - PUT and DELETE habits

This commit is contained in:
Echo
2026-02-10 15:58:48 +00:00
parent f9de7a2c26
commit 648185abe6
2 changed files with 356 additions and 3 deletions

View File

@@ -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()

View File

@@ -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")