Feature: Habit Tracker with Streak Calculation #1
@@ -251,6 +251,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
self.handle_cron_status()
|
||||
elif self.path == '/api/activity' or self.path.startswith('/api/activity?'):
|
||||
self.handle_activity()
|
||||
elif self.path == '/api/habits':
|
||||
self.handle_habits_get()
|
||||
elif self.path.startswith('/api/files'):
|
||||
self.handle_files_get()
|
||||
elif self.path.startswith('/api/diff'):
|
||||
@@ -681,6 +683,41 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_habits_get(self):
|
||||
"""Get all habits from habits.json."""
|
||||
try:
|
||||
habits_file = KANBAN_DIR / 'habits.json'
|
||||
|
||||
# Handle missing file or empty file gracefully
|
||||
if not habits_file.exists():
|
||||
self.send_json({
|
||||
'habits': [],
|
||||
'lastUpdated': datetime.now().isoformat()
|
||||
})
|
||||
return
|
||||
|
||||
# Read and parse habits data
|
||||
try:
|
||||
data = json.loads(habits_file.read_text(encoding='utf-8'))
|
||||
except (json.JSONDecodeError, IOError):
|
||||
# Return empty array on parse error instead of 500
|
||||
self.send_json({
|
||||
'habits': [],
|
||||
'lastUpdated': datetime.now().isoformat()
|
||||
})
|
||||
return
|
||||
|
||||
# Ensure required fields exist
|
||||
habits = data.get('habits', [])
|
||||
last_updated = data.get('lastUpdated', datetime.now().isoformat())
|
||||
|
||||
self.send_json({
|
||||
'habits': habits,
|
||||
'lastUpdated': last_updated
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_files_get(self):
|
||||
"""List files or get file content."""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
207
dashboard/test_habits_api.py
Normal file
207
dashboard/test_habits_api.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for GET /api/habits endpoint.
|
||||
Validates API response structure, status codes, and error handling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# API endpoint - assumes server is running on localhost:8088
|
||||
API_BASE = 'http://localhost:8088'
|
||||
HABITS_FILE = Path(__file__).parent / 'habits.json'
|
||||
|
||||
|
||||
def test_habits_endpoint_exists():
|
||||
"""Test that GET /api/habits endpoint exists and returns 200."""
|
||||
print("Testing endpoint exists and returns 200...")
|
||||
|
||||
try:
|
||||
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||
status_code = response.getcode()
|
||||
|
||||
assert status_code == 200, f"Expected status 200, got {status_code}"
|
||||
print("✓ Endpoint returns 200 status")
|
||||
except urllib.error.HTTPError as e:
|
||||
raise AssertionError(f"Endpoint returned HTTP {e.code}: {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
raise AssertionError(f"Could not connect to API server: {e.reason}")
|
||||
|
||||
|
||||
def test_habits_response_is_json():
|
||||
"""Test that response is valid JSON."""
|
||||
print("Testing response is valid JSON...")
|
||||
|
||||
try:
|
||||
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||
content = response.read().decode('utf-8')
|
||||
|
||||
try:
|
||||
data = json.loads(content)
|
||||
print("✓ Response is valid JSON")
|
||||
return data
|
||||
except json.JSONDecodeError as e:
|
||||
raise AssertionError(f"Response is not valid JSON: {e}")
|
||||
except urllib.error.URLError as e:
|
||||
raise AssertionError(f"Could not connect to API server: {e.reason}")
|
||||
|
||||
|
||||
def test_habits_response_structure():
|
||||
"""Test that response has correct structure: habits array and lastUpdated."""
|
||||
print("Testing response structure...")
|
||||
|
||||
data = test_habits_response_is_json()
|
||||
|
||||
# Check for habits array
|
||||
assert 'habits' in data, "Response missing 'habits' field"
|
||||
assert isinstance(data['habits'], list), "'habits' field must be an array"
|
||||
print("✓ Response contains 'habits' array")
|
||||
|
||||
# Check for lastUpdated timestamp
|
||||
assert 'lastUpdated' in data, "Response missing 'lastUpdated' field"
|
||||
print("✓ Response contains 'lastUpdated' field")
|
||||
|
||||
|
||||
def test_habits_lastupdated_is_iso():
|
||||
"""Test that lastUpdated is a valid ISO timestamp."""
|
||||
print("Testing lastUpdated is valid ISO timestamp...")
|
||||
|
||||
data = test_habits_response_is_json()
|
||||
last_updated = data.get('lastUpdated')
|
||||
|
||||
assert last_updated, "lastUpdated field is empty"
|
||||
|
||||
try:
|
||||
# Try to parse as ISO datetime
|
||||
dt = datetime.fromisoformat(last_updated.replace('Z', '+00:00'))
|
||||
print(f"✓ lastUpdated is valid ISO timestamp: {last_updated}")
|
||||
except (ValueError, AttributeError) as e:
|
||||
raise AssertionError(f"lastUpdated is not a valid ISO timestamp: {e}")
|
||||
|
||||
|
||||
def test_empty_habits_returns_empty_array():
|
||||
"""Test that empty habits.json returns empty array, not error."""
|
||||
print("Testing empty habits file returns empty array...")
|
||||
|
||||
# Backup original file
|
||||
backup = None
|
||||
if HABITS_FILE.exists():
|
||||
backup = HABITS_FILE.read_text()
|
||||
|
||||
try:
|
||||
# Write empty habits file
|
||||
HABITS_FILE.write_text(json.dumps({
|
||||
'lastUpdated': datetime.now().isoformat(),
|
||||
'habits': []
|
||||
}))
|
||||
|
||||
# Request habits
|
||||
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
assert data['habits'] == [], "Empty habits.json should return empty array"
|
||||
print("✓ Empty habits.json returns empty array (not error)")
|
||||
|
||||
finally:
|
||||
# Restore backup
|
||||
if backup:
|
||||
HABITS_FILE.write_text(backup)
|
||||
|
||||
|
||||
def test_habits_with_data():
|
||||
"""Test that habits with data are returned correctly."""
|
||||
print("Testing habits with data are returned...")
|
||||
|
||||
# Backup original file
|
||||
backup = None
|
||||
if HABITS_FILE.exists():
|
||||
backup = HABITS_FILE.read_text()
|
||||
|
||||
try:
|
||||
# Write test habits
|
||||
test_data = {
|
||||
'lastUpdated': '2026-02-10T10:00:00.000Z',
|
||||
'habits': [
|
||||
{
|
||||
'id': 'test-habit-1',
|
||||
'name': 'Bazin',
|
||||
'frequency': 'daily',
|
||||
'createdAt': '2026-02-10T10:00:00.000Z',
|
||||
'completions': ['2026-02-10T10:00:00.000Z']
|
||||
}
|
||||
]
|
||||
}
|
||||
HABITS_FILE.write_text(json.dumps(test_data, indent=2))
|
||||
|
||||
# Request habits
|
||||
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
assert len(data['habits']) == 1, "Should return 1 habit"
|
||||
habit = data['habits'][0]
|
||||
assert habit['name'] == 'Bazin', f"Expected habit name 'Bazin', got '{habit['name']}'"
|
||||
assert habit['frequency'] == 'daily', f"Expected frequency 'daily', got '{habit['frequency']}'"
|
||||
print("✓ Habits with data are returned correctly")
|
||||
|
||||
finally:
|
||||
# Restore backup
|
||||
if backup:
|
||||
HABITS_FILE.write_text(backup)
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all tests and report results."""
|
||||
print("=" * 60)
|
||||
print("Running GET /api/habits endpoint tests")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
tests = [
|
||||
test_habits_endpoint_exists,
|
||||
test_habits_response_is_json,
|
||||
test_habits_response_structure,
|
||||
test_habits_lastupdated_is_iso,
|
||||
test_empty_habits_returns_empty_array,
|
||||
test_habits_with_data,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
print()
|
||||
except AssertionError as e:
|
||||
print(f"✗ FAILED: {e}")
|
||||
print()
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"✗ ERROR: {e}")
|
||||
print()
|
||||
failed += 1
|
||||
|
||||
print("=" * 60)
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
print("=" * 60)
|
||||
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
# Check if API server is running
|
||||
try:
|
||||
urllib.request.urlopen(f'{API_BASE}/api/status', timeout=2)
|
||||
except urllib.error.URLError:
|
||||
print("ERROR: API server is not running on localhost:8088")
|
||||
print("Start the server with: python3 dashboard/api.py")
|
||||
sys.exit(1)
|
||||
|
||||
success = run_all_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
Reference in New Issue
Block a user