feat: US-001 - Backend: DELETE endpoint for uncheck (toggle support)
This commit is contained in:
@@ -80,7 +80,9 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
if self.path.startswith('/api/habits/'):
|
if self.path.startswith('/api/habits/') and '/check' in self.path:
|
||||||
|
self.handle_habits_uncheck()
|
||||||
|
elif self.path.startswith('/api/habits/'):
|
||||||
self.handle_habits_delete()
|
self.handle_habits_delete()
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
@@ -1771,6 +1773,88 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({'error': str(e)}, 500)
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def handle_habits_uncheck(self):
|
||||||
|
"""Uncheck a habit (remove completion for a specific date)."""
|
||||||
|
try:
|
||||||
|
# Extract habit ID from path (/api/habits/{id}/check)
|
||||||
|
path_parts = self.path.split('?')[0].split('/')
|
||||||
|
if len(path_parts) < 5:
|
||||||
|
self.send_json({'error': 'Invalid path'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
habit_id = path_parts[3]
|
||||||
|
|
||||||
|
# Parse query string for date parameter
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
query_params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
# Get date from query string (required)
|
||||||
|
if 'date' not in query_params:
|
||||||
|
self.send_json({'error': 'date parameter is required (format: YYYY-MM-DD)'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_date = query_params['date'][0]
|
||||||
|
|
||||||
|
# Validate date format
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(target_date)
|
||||||
|
except ValueError:
|
||||||
|
self.send_json({'error': 'Invalid date format. Use YYYY-MM-DD'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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
|
||||||
|
habit = None
|
||||||
|
for h in habits_data.get('habits', []):
|
||||||
|
if h['id'] == habit_id:
|
||||||
|
habit = h
|
||||||
|
break
|
||||||
|
|
||||||
|
if not habit:
|
||||||
|
self.send_json({'error': 'Habit not found'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find and remove the completion for the specified date
|
||||||
|
completions = habit.get('completions', [])
|
||||||
|
completion_found = False
|
||||||
|
for i, completion in enumerate(completions):
|
||||||
|
if completion.get('date') == target_date:
|
||||||
|
completions.pop(i)
|
||||||
|
completion_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not completion_found:
|
||||||
|
self.send_json({'error': 'No completion found for the specified date'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Recalculate streak after removing completion
|
||||||
|
current_streak = habits_helpers.calculate_streak(habit)
|
||||||
|
habit['streak']['current'] = current_streak
|
||||||
|
|
||||||
|
# Update best streak if needed (best never decreases, but we keep it for consistency)
|
||||||
|
if current_streak > habit['streak']['best']:
|
||||||
|
habit['streak']['best'] = current_streak
|
||||||
|
|
||||||
|
# Update timestamp
|
||||||
|
habit['updatedAt'] = datetime.now().isoformat()
|
||||||
|
habits_data['lastUpdated'] = habit['updatedAt']
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(habits_data, f, indent=2)
|
||||||
|
|
||||||
|
# Return updated habit
|
||||||
|
self.send_json(habit, 200)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
def handle_habits_skip(self):
|
def handle_habits_skip(self):
|
||||||
"""Skip a day using a life to preserve streak."""
|
"""Skip a day using a life to preserve streak."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ def http_delete(path, port=8765):
|
|||||||
req = urllib.request.Request(url, method='DELETE')
|
req = urllib.request.Request(url, method='DELETE')
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as response:
|
with urllib.request.urlopen(req) as response:
|
||||||
|
# Handle JSON response if present
|
||||||
|
if response.headers.get('Content-Type') == 'application/json':
|
||||||
|
return response.status, json.loads(response.read().decode())
|
||||||
|
else:
|
||||||
return response.status, None
|
return response.status, None
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
|
return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
|
||||||
@@ -891,14 +895,189 @@ def test_skip_returns_updated_habit():
|
|||||||
server.shutdown()
|
server.shutdown()
|
||||||
cleanup_test_env(temp_dir)
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
# Test 35: Typecheck passes
|
# Test 35: DELETE uncheck - removes completion for specified date
|
||||||
|
def test_uncheck_removes_completion():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a habit
|
||||||
|
status, habit = http_post('/api/habits', {
|
||||||
|
'name': 'Daily Exercise',
|
||||||
|
'frequency': {'type': 'daily'}
|
||||||
|
})
|
||||||
|
assert status == 201
|
||||||
|
habit_id = habit['id']
|
||||||
|
|
||||||
|
# Check in on a specific date
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
status, response = http_post(f'/api/habits/{habit_id}/check', {})
|
||||||
|
assert status == 200
|
||||||
|
assert len(response['completions']) == 1
|
||||||
|
assert response['completions'][0]['date'] == today
|
||||||
|
|
||||||
|
# Uncheck the habit for today
|
||||||
|
status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}')
|
||||||
|
assert status == 200
|
||||||
|
assert len(response['completions']) == 0, "Completion should be removed"
|
||||||
|
assert response['id'] == habit_id
|
||||||
|
|
||||||
|
print("✓ Test 35: DELETE uncheck removes completion for specified date")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 36: DELETE uncheck - returns 404 if no completion for date
|
||||||
|
def test_uncheck_no_completion_for_date():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a habit (but don't check in)
|
||||||
|
status, habit = http_post('/api/habits', {
|
||||||
|
'name': 'Daily Exercise',
|
||||||
|
'frequency': {'type': 'daily'}
|
||||||
|
})
|
||||||
|
assert status == 201
|
||||||
|
habit_id = habit['id']
|
||||||
|
|
||||||
|
# Try to uncheck a date with no completion
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}')
|
||||||
|
assert status == 404
|
||||||
|
assert 'error' in response
|
||||||
|
assert 'No completion found' in response['error']
|
||||||
|
|
||||||
|
print("✓ Test 36: DELETE uncheck returns 404 if no completion for date")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 37: DELETE uncheck - returns 404 if habit not found
|
||||||
|
def test_uncheck_habit_not_found():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
status, response = http_delete(f'/api/habits/nonexistent-id/check?date={today}')
|
||||||
|
assert status == 404
|
||||||
|
assert 'error' in response
|
||||||
|
assert 'Habit not found' in response['error']
|
||||||
|
|
||||||
|
print("✓ Test 37: DELETE uncheck returns 404 if habit not found")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 38: DELETE uncheck - recalculates streak correctly
|
||||||
|
def test_uncheck_recalculates_streak():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a habit
|
||||||
|
status, habit = http_post('/api/habits', {
|
||||||
|
'name': 'Daily Exercise',
|
||||||
|
'frequency': {'type': 'daily'}
|
||||||
|
})
|
||||||
|
assert status == 201
|
||||||
|
habit_id = habit['id']
|
||||||
|
|
||||||
|
# Check in for 3 consecutive days
|
||||||
|
today = datetime.now().date()
|
||||||
|
for i in range(3):
|
||||||
|
check_date = (today - timedelta(days=2-i)).isoformat()
|
||||||
|
# Manually add completion to the habit
|
||||||
|
with open(api.HABITS_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for h in data['habits']:
|
||||||
|
if h['id'] == habit_id:
|
||||||
|
h['completions'].append({'date': check_date, 'type': 'check'})
|
||||||
|
with open(api.HABITS_FILE, 'w') as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
# Get habit to verify streak is 3
|
||||||
|
status, habit = http_get('/api/habits')
|
||||||
|
assert status == 200
|
||||||
|
habit = [h for h in habit if h['id'] == habit_id][0]
|
||||||
|
assert habit['current_streak'] == 3
|
||||||
|
|
||||||
|
# Uncheck the middle day
|
||||||
|
middle_date = (today - timedelta(days=1)).isoformat()
|
||||||
|
status, response = http_delete(f'/api/habits/{habit_id}/check?date={middle_date}')
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
# Streak should now be 1 (only today counts)
|
||||||
|
assert response['streak']['current'] == 1, f"Expected streak 1, got {response['streak']['current']}"
|
||||||
|
|
||||||
|
print("✓ Test 38: DELETE uncheck recalculates streak correctly")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 39: DELETE uncheck - returns updated habit object
|
||||||
|
def test_uncheck_returns_updated_habit():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create and check in
|
||||||
|
status, habit = http_post('/api/habits', {
|
||||||
|
'name': 'Daily Exercise',
|
||||||
|
'frequency': {'type': 'daily'}
|
||||||
|
})
|
||||||
|
habit_id = habit['id']
|
||||||
|
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
status, _ = http_post(f'/api/habits/{habit_id}/check', {})
|
||||||
|
|
||||||
|
# Uncheck and verify response structure
|
||||||
|
status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}')
|
||||||
|
assert status == 200
|
||||||
|
assert 'id' in response
|
||||||
|
assert 'name' in response
|
||||||
|
assert 'completions' in response
|
||||||
|
assert 'streak' in response
|
||||||
|
assert 'updatedAt' in response
|
||||||
|
|
||||||
|
print("✓ Test 39: DELETE uncheck returns updated habit object")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 40: DELETE uncheck - requires date parameter
|
||||||
|
def test_uncheck_requires_date():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a habit
|
||||||
|
status, habit = http_post('/api/habits', {
|
||||||
|
'name': 'Daily Exercise',
|
||||||
|
'frequency': {'type': 'daily'}
|
||||||
|
})
|
||||||
|
habit_id = habit['id']
|
||||||
|
|
||||||
|
# Try to uncheck without date parameter
|
||||||
|
status, response = http_delete(f'/api/habits/{habit_id}/check')
|
||||||
|
assert status == 400
|
||||||
|
assert 'error' in response
|
||||||
|
assert 'date parameter is required' in response['error']
|
||||||
|
|
||||||
|
print("✓ Test 40: DELETE uncheck requires date parameter")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 41: Typecheck passes
|
||||||
def test_typecheck():
|
def test_typecheck():
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')],
|
['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')],
|
||||||
capture_output=True
|
capture_output=True
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, f"Typecheck failed: {result.stderr.decode()}"
|
assert result.returncode == 0, f"Typecheck failed: {result.stderr.decode()}"
|
||||||
print("✓ Test 11: Typecheck passes")
|
print("✓ Test 41: Typecheck passes")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -937,6 +1116,12 @@ if __name__ == '__main__':
|
|||||||
test_skip_not_found()
|
test_skip_not_found()
|
||||||
test_skip_no_lives()
|
test_skip_no_lives()
|
||||||
test_skip_returns_updated_habit()
|
test_skip_returns_updated_habit()
|
||||||
|
test_uncheck_removes_completion()
|
||||||
|
test_uncheck_no_completion_for_date()
|
||||||
|
test_uncheck_habit_not_found()
|
||||||
|
test_uncheck_recalculates_streak()
|
||||||
|
test_uncheck_returns_updated_habit()
|
||||||
|
test_uncheck_requires_date()
|
||||||
test_typecheck()
|
test_typecheck()
|
||||||
|
|
||||||
print("\n✅ All 35 tests passed!\n")
|
print("\n✅ All 41 tests passed!\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user