Compare commits
9 Commits
c5a0114eaf
...
1829397195
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1829397195 | ||
|
|
033bd63329 | ||
|
|
e52d38793b | ||
|
|
9a899f94fd | ||
|
|
f3aa97c910 | ||
|
|
9d9f00e069 | ||
|
|
081121e48d | ||
|
|
4d50965bac | ||
|
|
6d40d7e24b |
@@ -80,7 +80,9 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
self.send_error(404)
|
||||
|
||||
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()
|
||||
else:
|
||||
self.send_error(404)
|
||||
@@ -1771,6 +1773,88 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
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):
|
||||
"""Skip a day using a life to preserve streak."""
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -38,14 +39,15 @@ def cleanup_test_env(temp_dir):
|
||||
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)
|
||||
def start_test_server():
|
||||
"""Start test server in background thread with random available port."""
|
||||
server = HTTPServer(('localhost', 0), api.TaskBoardHandler) # Port 0 = random
|
||||
port = server.server_address[1] # Get actual assigned port
|
||||
thread = threading.Thread(target=server.serve_forever)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
time.sleep(0.5) # Give server time to start
|
||||
return server
|
||||
time.sleep(0.3) # Give server time to start
|
||||
return server, port
|
||||
|
||||
def http_get(path, port=8765):
|
||||
"""Make HTTP GET request."""
|
||||
@@ -91,17 +93,21 @@ def http_delete(path, port=8765):
|
||||
req = urllib.request.Request(url, method='DELETE')
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return response.status, None
|
||||
# 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
|
||||
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()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, data = http_get('/api/habits')
|
||||
status, data = http_get('/api/habits', port)
|
||||
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")
|
||||
@@ -112,7 +118,7 @@ def test_get_habits_empty():
|
||||
# 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()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
habit_data = {
|
||||
@@ -128,7 +134,7 @@ def test_post_habit_valid():
|
||||
}
|
||||
}
|
||||
|
||||
status, data = http_post('/api/habits', habit_data)
|
||||
status, data = http_post('/api/habits', habit_data, port)
|
||||
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']}"
|
||||
@@ -144,10 +150,10 @@ def test_post_habit_valid():
|
||||
# Test 3: POST validates name is required
|
||||
def test_post_habit_missing_name():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, data = http_post('/api/habits', {})
|
||||
status, data = http_post('/api/habits', {}, port)
|
||||
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']}"
|
||||
@@ -159,10 +165,10 @@ def test_post_habit_missing_name():
|
||||
# Test 4: POST validates name max 100 chars
|
||||
def test_post_habit_name_too_long():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, data = http_post('/api/habits', {'name': 'x' * 101})
|
||||
status, data = http_post('/api/habits', {'name': 'x' * 101}, port)
|
||||
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']}"
|
||||
@@ -174,13 +180,13 @@ def test_post_habit_name_too_long():
|
||||
# Test 5: POST validates color hex format
|
||||
def test_post_habit_invalid_color():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, data = http_post('/api/habits', {
|
||||
'name': 'Test',
|
||||
'color': 'not-a-hex-color'
|
||||
})
|
||||
}, port)
|
||||
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']}"
|
||||
@@ -192,13 +198,13 @@ def test_post_habit_invalid_color():
|
||||
# Test 6: POST validates frequency type
|
||||
def test_post_habit_invalid_frequency():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, data = http_post('/api/habits', {
|
||||
'name': 'Test',
|
||||
'frequency': {'type': 'invalid_type'}
|
||||
})
|
||||
}, port)
|
||||
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']}"
|
||||
@@ -210,15 +216,15 @@ def test_post_habit_invalid_frequency():
|
||||
# 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()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit first
|
||||
habit_data = {'name': 'Daily Reading', 'frequency': {'type': 'daily'}}
|
||||
http_post('/api/habits', habit_data)
|
||||
http_post('/api/habits', habit_data, port)
|
||||
|
||||
# Get habits
|
||||
status, data = http_get('/api/habits')
|
||||
status, data = http_get('/api/habits', port)
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert len(data) == 1, f"Expected 1 habit, got {len(data)}"
|
||||
|
||||
@@ -235,16 +241,16 @@ def test_get_habits_with_stats():
|
||||
# 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()
|
||||
server, port = 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})
|
||||
http_post('/api/habits', {'name': 'Low Priority', 'priority': 10}, port)
|
||||
http_post('/api/habits', {'name': 'High Priority', 'priority': 1}, port)
|
||||
http_post('/api/habits', {'name': 'Medium Priority', 'priority': 5}, port)
|
||||
|
||||
# Get habits
|
||||
status, data = http_get('/api/habits')
|
||||
status, data = http_get('/api/habits', port)
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert len(data) == 3, f"Expected 3 habits, got {len(data)}"
|
||||
|
||||
@@ -260,10 +266,10 @@ def test_get_habits_sorted_by_priority():
|
||||
# Test 9: POST returns 400 for invalid JSON
|
||||
def test_post_habit_invalid_json():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
url = f'http://localhost:8765/api/habits'
|
||||
url = f'http://localhost:{port}/api/habits'
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=b'invalid json{',
|
||||
@@ -282,10 +288,10 @@ def test_post_habit_invalid_json():
|
||||
# Test 10: POST initializes streak.current=0
|
||||
def test_post_habit_initial_streak():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, data = http_post('/api/habits', {'name': 'Test Habit'})
|
||||
status, data = http_post('/api/habits', {'name': 'Test Habit'}, port)
|
||||
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"
|
||||
@@ -298,7 +304,7 @@ def test_post_habit_initial_streak():
|
||||
# Test 12: PUT /api/habits/{id} updates habit successfully
|
||||
def test_put_habit_valid():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit first
|
||||
@@ -308,7 +314,7 @@ def test_put_habit_valid():
|
||||
'color': '#10b981',
|
||||
'priority': 3
|
||||
}
|
||||
status, created_habit = http_post('/api/habits', habit_data)
|
||||
status, created_habit = http_post('/api/habits', habit_data, port)
|
||||
habit_id = created_habit['id']
|
||||
|
||||
# Update the habit
|
||||
@@ -319,7 +325,7 @@ def test_put_habit_valid():
|
||||
'priority': 1,
|
||||
'notes': 'New notes'
|
||||
}
|
||||
status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data)
|
||||
status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert updated_habit['name'] == 'Updated Name', "Name not updated"
|
||||
@@ -336,12 +342,12 @@ def test_put_habit_valid():
|
||||
# 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()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit first
|
||||
habit_data = {'name': 'Test Habit'}
|
||||
status, created_habit = http_post('/api/habits', habit_data)
|
||||
status, created_habit = http_post('/api/habits', habit_data, port)
|
||||
habit_id = created_habit['id']
|
||||
original_created_at = created_habit['createdAt']
|
||||
|
||||
@@ -354,7 +360,7 @@ def test_put_habit_protected_fields():
|
||||
'lives': 10,
|
||||
'completions': [{'date': '2025-01-01'}]
|
||||
}
|
||||
status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data)
|
||||
status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert updated_habit['name'] == 'Updated Name', "Name should be updated"
|
||||
@@ -371,11 +377,11 @@ def test_put_habit_protected_fields():
|
||||
# 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()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
update_data = {'name': 'Updated Name'}
|
||||
status, response = http_put('/api/habits/non-existent-id', update_data)
|
||||
status, response = http_put('/api/habits/non-existent-id', update_data, port)
|
||||
|
||||
assert status == 404, f"Expected 404, got {status}"
|
||||
assert 'error' in response, "Expected error message"
|
||||
@@ -387,27 +393,27 @@ def test_put_habit_not_found():
|
||||
# Test 15: PUT /api/habits/{id} validates input
|
||||
def test_put_habit_invalid_input():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit first
|
||||
habit_data = {'name': 'Test Habit'}
|
||||
status, created_habit = http_post('/api/habits', habit_data)
|
||||
status, created_habit = http_post('/api/habits', habit_data, port)
|
||||
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)
|
||||
status, response = http_put(f'/api/habits/{habit_id}', update_data, port)
|
||||
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)
|
||||
status, response = http_put(f'/api/habits/{habit_id}', update_data, port)
|
||||
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)
|
||||
status, response = http_put(f'/api/habits/{habit_id}', update_data, port)
|
||||
assert status == 400, f"Expected 400 for long name, got {status}"
|
||||
|
||||
print("✓ Test 15: PUT /api/habits/{id} validates input")
|
||||
@@ -418,24 +424,24 @@ def test_put_habit_invalid_input():
|
||||
# Test 16: DELETE /api/habits/{id} removes habit successfully
|
||||
def test_delete_habit_success():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit first
|
||||
habit_data = {'name': 'Habit to Delete'}
|
||||
status, created_habit = http_post('/api/habits', habit_data)
|
||||
status, created_habit = http_post('/api/habits', habit_data, port)
|
||||
habit_id = created_habit['id']
|
||||
|
||||
# Verify habit exists
|
||||
status, habits = http_get('/api/habits')
|
||||
status, habits = http_get('/api/habits', port)
|
||||
assert len(habits) == 1, "Should have 1 habit"
|
||||
|
||||
# Delete the habit
|
||||
status, _ = http_delete(f'/api/habits/{habit_id}')
|
||||
status, _ = http_delete(f'/api/habits/{habit_id}', port)
|
||||
assert status == 204, f"Expected 204, got {status}"
|
||||
|
||||
# Verify habit is deleted
|
||||
status, habits = http_get('/api/habits')
|
||||
status, habits = http_get('/api/habits', port)
|
||||
assert len(habits) == 0, "Should have 0 habits after deletion"
|
||||
print("✓ Test 16: DELETE /api/habits/{id} removes habit successfully")
|
||||
finally:
|
||||
@@ -445,10 +451,10 @@ def test_delete_habit_success():
|
||||
# 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()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, response = http_delete('/api/habits/non-existent-id')
|
||||
status, response = http_delete('/api/habits/non-existent-id', port)
|
||||
|
||||
assert status == 404, f"Expected 404, got {status}"
|
||||
assert 'error' in response, "Expected error message"
|
||||
@@ -460,11 +466,11 @@ def test_delete_habit_not_found():
|
||||
# Test 18: do_OPTIONS includes PUT and DELETE methods
|
||||
def test_options_includes_put_delete():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Make OPTIONS request
|
||||
url = 'http://localhost:8765/api/habits'
|
||||
url = f'http://localhost:{port}/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', '')
|
||||
@@ -478,19 +484,19 @@ def test_options_includes_put_delete():
|
||||
# Test 20: POST /api/habits/{id}/check adds completion entry
|
||||
def test_check_in_basic():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Morning Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
assert status == 201, f"Failed to create habit: {status}"
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in on the habit
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert len(updated_habit['completions']) == 1, "Expected 1 completion"
|
||||
@@ -504,14 +510,14 @@ def test_check_in_basic():
|
||||
# Test 21: Check-in accepts optional note, rating, mood
|
||||
def test_check_in_with_details():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Meditation',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in with details
|
||||
@@ -519,7 +525,7 @@ def test_check_in_with_details():
|
||||
'note': 'Felt very relaxed today',
|
||||
'rating': 5,
|
||||
'mood': 'happy'
|
||||
})
|
||||
}, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
completion = updated_habit['completions'][0]
|
||||
@@ -534,10 +540,10 @@ def test_check_in_with_details():
|
||||
# Test 22: Check-in returns 404 if habit not found
|
||||
def test_check_in_not_found():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, response = http_post('/api/habits/non-existent-id/check', {})
|
||||
status, response = http_post('/api/habits/non-existent-id/check', {}, port)
|
||||
|
||||
assert status == 404, f"Expected 404, got {status}"
|
||||
assert 'error' in response
|
||||
@@ -549,7 +555,7 @@ def test_check_in_not_found():
|
||||
# Test 23: Check-in returns 400 if habit not relevant for today
|
||||
def test_check_in_not_relevant():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit for specific days (e.g., Monday only)
|
||||
@@ -563,11 +569,11 @@ def test_check_in_not_relevant():
|
||||
'type': 'specific_days',
|
||||
'days': [different_day]
|
||||
}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Try to check in
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
assert status == 400, f"Expected 400, got {status}"
|
||||
assert 'not relevant' in response.get('error', '').lower()
|
||||
@@ -579,22 +585,22 @@ def test_check_in_not_relevant():
|
||||
# Test 24: Check-in returns 409 if already checked today
|
||||
def test_check_in_already_checked():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Water Plants',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in once
|
||||
status, _ = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
status, _ = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
assert status == 200, "First check-in should succeed"
|
||||
|
||||
# Try to check in again
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
assert status == 409, f"Expected 409, got {status}"
|
||||
assert 'already checked' in response.get('error', '').lower()
|
||||
@@ -606,18 +612,18 @@ def test_check_in_already_checked():
|
||||
# Test 25: Streak is recalculated after check-in
|
||||
def test_check_in_updates_streak():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Read',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert updated_habit['streak']['current'] == 1, f"Expected streak 1, got {updated_habit['streak']['current']}"
|
||||
@@ -630,21 +636,21 @@ def test_check_in_updates_streak():
|
||||
# Test 26: lastCheckIn is updated after check-in
|
||||
def test_check_in_updates_last_check_in():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Floss',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Initially lastCheckIn should be None
|
||||
assert habit['streak']['lastCheckIn'] is None
|
||||
|
||||
# Check in
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
assert updated_habit['streak']['lastCheckIn'] == today
|
||||
@@ -656,14 +662,14 @@ def test_check_in_updates_last_check_in():
|
||||
# Test 27: Lives are restored after 7 consecutive check-ins
|
||||
def test_check_in_life_restore():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit and manually set up 6 previous check-ins
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Yoga',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Manually add 6 previous check-ins and reduce lives to 2
|
||||
@@ -682,7 +688,7 @@ def test_check_in_life_restore():
|
||||
api.HABITS_FILE.write_text(json.dumps(habits_data, indent=2))
|
||||
|
||||
# Check in for today (7th consecutive)
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {})
|
||||
status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert updated_habit['lives'] == 3, f"Expected 3 lives restored, got {updated_habit['lives']}"
|
||||
@@ -694,20 +700,20 @@ def test_check_in_life_restore():
|
||||
# Test 28: Check-in validates rating range
|
||||
def test_check_in_invalid_rating():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Journal',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Try to check in with invalid rating
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {
|
||||
'rating': 10 # Invalid, should be 1-5
|
||||
})
|
||||
}, port)
|
||||
|
||||
assert status == 400, f"Expected 400, got {status}"
|
||||
assert 'rating' in response.get('error', '').lower()
|
||||
@@ -719,20 +725,20 @@ def test_check_in_invalid_rating():
|
||||
# Test 29: Check-in validates mood values
|
||||
def test_check_in_invalid_mood():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a daily habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Gratitude',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Try to check in with invalid mood
|
||||
status, response = http_post(f'/api/habits/{habit_id}/check', {
|
||||
'mood': 'excited' # Invalid, should be happy/neutral/sad
|
||||
})
|
||||
}, port)
|
||||
|
||||
assert status == 400, f"Expected 400, got {status}"
|
||||
assert 'mood' in response.get('error', '').lower()
|
||||
@@ -744,19 +750,19 @@ def test_check_in_invalid_mood():
|
||||
# Test 30: Skip basic - decrements lives
|
||||
def test_skip_basic():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
assert status == 201
|
||||
habit_id = habit['id']
|
||||
|
||||
# Skip a day
|
||||
status, response = http_post(f'/api/habits/{habit_id}/skip', {})
|
||||
status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
assert response['lives'] == 2, f"Expected 2 lives, got {response['lives']}"
|
||||
@@ -775,28 +781,28 @@ def test_skip_basic():
|
||||
# Test 31: Skip preserves streak
|
||||
def test_skip_preserves_streak():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
assert status == 201
|
||||
habit_id = habit['id']
|
||||
|
||||
# Check in to build a streak
|
||||
http_post(f'/api/habits/{habit_id}/check', {})
|
||||
http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
# Get current streak
|
||||
status, habits = http_get('/api/habits')
|
||||
status, habits = http_get('/api/habits', port)
|
||||
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', {})
|
||||
status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}"
|
||||
# Verify lives decremented
|
||||
@@ -816,10 +822,10 @@ def test_skip_preserves_streak():
|
||||
# Test 32: Skip returns 404 for non-existent habit
|
||||
def test_skip_not_found():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
status, response = http_post('/api/habits/nonexistent-id/skip', {})
|
||||
status, response = http_post('/api/habits/nonexistent-id/skip', {}, port)
|
||||
|
||||
assert status == 404, f"Expected 404, got {status}"
|
||||
assert 'not found' in response.get('error', '').lower()
|
||||
@@ -832,25 +838,25 @@ def test_skip_not_found():
|
||||
# Test 33: Skip returns 400 when no lives remaining
|
||||
def test_skip_no_lives():
|
||||
temp_dir = setup_test_env()
|
||||
server = start_test_server()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
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', {})
|
||||
status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
|
||||
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', {})
|
||||
status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
|
||||
|
||||
assert status == 400, f"Expected 400, got {status}"
|
||||
assert 'no lives remaining' in response.get('error', '').lower()
|
||||
@@ -863,20 +869,20 @@ def test_skip_no_lives():
|
||||
# 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()
|
||||
server, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
})
|
||||
}, port)
|
||||
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', {})
|
||||
status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
|
||||
|
||||
assert status == 200
|
||||
assert response['id'] == habit_id
|
||||
@@ -891,14 +897,189 @@ def test_skip_returns_updated_habit():
|
||||
server.shutdown()
|
||||
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, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
}, port)
|
||||
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', {}, port)
|
||||
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}', port)
|
||||
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, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit (but don't check in)
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
}, port)
|
||||
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}', port)
|
||||
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, port = start_test_server()
|
||||
|
||||
try:
|
||||
today = datetime.now().date().isoformat()
|
||||
status, response = http_delete(f'/api/habits/nonexistent-id/check?date={today}', port)
|
||||
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, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
}, port)
|
||||
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', port)
|
||||
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}', port)
|
||||
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, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create and check in
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
status, _ = http_post(f'/api/habits/{habit_id}/check', {}, port)
|
||||
|
||||
# Uncheck and verify response structure
|
||||
status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}', port)
|
||||
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, port = start_test_server()
|
||||
|
||||
try:
|
||||
# Create a habit
|
||||
status, habit = http_post('/api/habits', {
|
||||
'name': 'Daily Exercise',
|
||||
'frequency': {'type': 'daily'}
|
||||
}, port)
|
||||
habit_id = habit['id']
|
||||
|
||||
# Try to uncheck without date parameter
|
||||
status, response = http_delete(f'/api/habits/{habit_id}/check', port)
|
||||
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():
|
||||
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")
|
||||
print("✓ Test 41: Typecheck passes")
|
||||
|
||||
if __name__ == '__main__':
|
||||
import subprocess
|
||||
@@ -937,6 +1118,12 @@ if __name__ == '__main__':
|
||||
test_skip_not_found()
|
||||
test_skip_no_lives()
|
||||
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()
|
||||
|
||||
print("\n✅ All 35 tests passed!\n")
|
||||
print("\n✅ All 41 tests passed!\n")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user