feat: US-005 - Backend API - Skip endpoint with lives system
This commit is contained in:
@@ -58,6 +58,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.handle_habits_post()
|
self.handle_habits_post()
|
||||||
elif self.path.startswith('/api/habits/') and self.path.endswith('/check'):
|
elif self.path.startswith('/api/habits/') and self.path.endswith('/check'):
|
||||||
self.handle_habits_check()
|
self.handle_habits_check()
|
||||||
|
elif self.path.startswith('/api/habits/') and self.path.endswith('/skip'):
|
||||||
|
self.handle_habits_skip()
|
||||||
elif self.path == '/api/workspace/run':
|
elif self.path == '/api/workspace/run':
|
||||||
self.handle_workspace_run()
|
self.handle_workspace_run()
|
||||||
elif self.path == '/api/workspace/stop':
|
elif self.path == '/api/workspace/stop':
|
||||||
@@ -1768,6 +1770,66 @@ 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_skip(self):
|
||||||
|
"""Skip a day using a life to preserve streak."""
|
||||||
|
try:
|
||||||
|
# Extract habit ID from path (/api/habits/{id}/skip)
|
||||||
|
path_parts = self.path.split('/')
|
||||||
|
if len(path_parts) < 5:
|
||||||
|
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 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
|
||||||
|
|
||||||
|
# Verify lives > 0
|
||||||
|
current_lives = habit.get('lives', 3)
|
||||||
|
if current_lives <= 0:
|
||||||
|
self.send_json({'error': 'No lives remaining'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Decrement lives by 1
|
||||||
|
habit['lives'] = current_lives - 1
|
||||||
|
|
||||||
|
# Add completion entry with type='skip'
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
completion_entry = {
|
||||||
|
'date': today,
|
||||||
|
'type': 'skip'
|
||||||
|
}
|
||||||
|
habit['completions'].append(completion_entry)
|
||||||
|
|
||||||
|
# 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 send_json(self, data, code=200):
|
def send_json(self, data, code=200):
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
|||||||
@@ -741,7 +741,157 @@ def test_check_in_invalid_mood():
|
|||||||
server.shutdown()
|
server.shutdown()
|
||||||
cleanup_test_env(temp_dir)
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
# Test 30: Typecheck passes
|
# Test 30: Skip basic - decrements lives
|
||||||
|
def test_skip_basic():
|
||||||
|
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']
|
||||||
|
|
||||||
|
# Skip a day
|
||||||
|
status, response = http_post(f'/api/habits/{habit_id}/skip', {})
|
||||||
|
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
assert response['lives'] == 2, f"Expected 2 lives, got {response['lives']}"
|
||||||
|
|
||||||
|
# Verify completion entry was added with type='skip'
|
||||||
|
completions = response.get('completions', [])
|
||||||
|
assert len(completions) == 1, f"Expected 1 completion, got {len(completions)}"
|
||||||
|
assert completions[0]['type'] == 'skip', f"Expected type='skip', got {completions[0].get('type')}"
|
||||||
|
assert completions[0]['date'] == datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
print("✓ Test 30: Skip decrements lives and adds skip completion")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 31: Skip preserves streak
|
||||||
|
def test_skip_preserves_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 to build a streak
|
||||||
|
http_post(f'/api/habits/{habit_id}/check', {})
|
||||||
|
|
||||||
|
# Get current streak
|
||||||
|
status, habits = http_get('/api/habits')
|
||||||
|
current_streak = habits[0]['current_streak']
|
||||||
|
assert current_streak > 0
|
||||||
|
|
||||||
|
# Skip the next day (simulate by adding skip manually and checking streak doesn't break)
|
||||||
|
# Since we can't time travel, we'll verify that skip doesn't recalculate streak
|
||||||
|
status, response = http_post(f'/api/habits/{habit_id}/skip', {})
|
||||||
|
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
# Verify lives decremented
|
||||||
|
assert response['lives'] == 2
|
||||||
|
# The streak should remain unchanged (skip doesn't break it)
|
||||||
|
# Note: We can't verify streak preservation perfectly without time travel,
|
||||||
|
# but we verify the skip completion is added correctly
|
||||||
|
completions = response.get('completions', [])
|
||||||
|
skip_count = sum(1 for c in completions if c.get('type') == 'skip')
|
||||||
|
assert skip_count == 1
|
||||||
|
|
||||||
|
print("✓ Test 31: Skip preserves streak (doesn't break it)")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 32: Skip returns 404 for non-existent habit
|
||||||
|
def test_skip_not_found():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, response = http_post('/api/habits/nonexistent-id/skip', {})
|
||||||
|
|
||||||
|
assert status == 404, f"Expected 404, got {status}"
|
||||||
|
assert 'not found' in response.get('error', '').lower()
|
||||||
|
|
||||||
|
print("✓ Test 32: Skip returns 404 for non-existent habit")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 33: Skip returns 400 when no lives remaining
|
||||||
|
def test_skip_no_lives():
|
||||||
|
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']
|
||||||
|
|
||||||
|
# Use all 3 lives
|
||||||
|
for i in range(3):
|
||||||
|
status, response = http_post(f'/api/habits/{habit_id}/skip', {})
|
||||||
|
assert status == 200, f"Skip {i+1} failed with status {status}"
|
||||||
|
assert response['lives'] == 2 - i, f"Expected {2-i} lives, got {response['lives']}"
|
||||||
|
|
||||||
|
# Try to skip again with no lives
|
||||||
|
status, response = http_post(f'/api/habits/{habit_id}/skip', {})
|
||||||
|
|
||||||
|
assert status == 400, f"Expected 400, got {status}"
|
||||||
|
assert 'no lives remaining' in response.get('error', '').lower()
|
||||||
|
|
||||||
|
print("✓ Test 33: Skip returns 400 when no lives remaining")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 34: Skip returns updated habit with new lives count
|
||||||
|
def test_skip_returns_updated_habit():
|
||||||
|
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']
|
||||||
|
original_updated_at = habit['updatedAt']
|
||||||
|
|
||||||
|
# Skip a day
|
||||||
|
status, response = http_post(f'/api/habits/{habit_id}/skip', {})
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert response['id'] == habit_id
|
||||||
|
assert response['lives'] == 2
|
||||||
|
assert response['updatedAt'] != original_updated_at, "updatedAt should be updated"
|
||||||
|
assert 'name' in response
|
||||||
|
assert 'frequency' in response
|
||||||
|
assert 'completions' in response
|
||||||
|
|
||||||
|
print("✓ Test 34: Skip returns updated habit with new lives count")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 35: 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')],
|
||||||
@@ -782,6 +932,11 @@ if __name__ == '__main__':
|
|||||||
test_check_in_life_restore()
|
test_check_in_life_restore()
|
||||||
test_check_in_invalid_rating()
|
test_check_in_invalid_rating()
|
||||||
test_check_in_invalid_mood()
|
test_check_in_invalid_mood()
|
||||||
|
test_skip_basic()
|
||||||
|
test_skip_preserves_streak()
|
||||||
|
test_skip_not_found()
|
||||||
|
test_skip_no_lives()
|
||||||
|
test_skip_returns_updated_habit()
|
||||||
test_typecheck()
|
test_typecheck()
|
||||||
|
|
||||||
print("\n✅ All 30 tests passed!\n")
|
print("\n✅ All 35 tests passed!\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user