feat: US-002 - Backend API - GET and POST habits

This commit is contained in:
Echo
2026-02-10 15:50:45 +00:00
parent 8f326b1846
commit f9de7a2c26
2 changed files with 425 additions and 0 deletions

View File

@@ -11,16 +11,22 @@ import sys
import re
import os
import signal
import uuid
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
from datetime import datetime
from pathlib import Path
# Import habits helpers
sys.path.insert(0, str(Path(__file__).parent))
import habits_helpers
BASE_DIR = Path(__file__).parent.parent
TOOLS_DIR = BASE_DIR / 'tools'
NOTES_DIR = BASE_DIR / 'kb' / 'youtube'
KANBAN_DIR = BASE_DIR / 'dashboard'
WORKSPACE_DIR = Path('/home/moltbot/workspace')
HABITS_FILE = KANBAN_DIR / 'habits.json'
# Load .env file if present
_env_file = Path(__file__).parent / '.env'
@@ -48,6 +54,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
self.handle_git_commit()
elif self.path == '/api/pdf':
self.handle_pdf_post()
elif self.path == '/api/habits':
self.handle_habits_post()
elif self.path == '/api/workspace/run':
self.handle_workspace_run()
elif self.path == '/api/workspace/stop':
@@ -251,6 +259,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'):
@@ -1381,6 +1391,122 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_get(self):
"""Get all habits with enriched stats."""
try:
# Read habits file
if not HABITS_FILE.exists():
self.send_json([])
return
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
habits = data.get('habits', [])
# Enrich each habit with calculated stats
enriched_habits = []
for habit in habits:
# Calculate stats using helpers
current_streak = habits_helpers.calculate_streak(habit)
best_streak = habit.get('streak', {}).get('best', 0)
completion_rate = habits_helpers.get_completion_rate(habit, days=30)
weekly_summary = habits_helpers.get_weekly_summary(habit)
# Add stats to habit
enriched = habit.copy()
enriched['current_streak'] = current_streak
enriched['best_streak'] = best_streak
enriched['completion_rate_30d'] = completion_rate
enriched['weekly_summary'] = weekly_summary
enriched_habits.append(enriched)
# Sort by priority ascending (lower number = higher priority)
enriched_habits.sort(key=lambda h: h.get('priority', 999))
self.send_json(enriched_habits)
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_habits_post(self):
"""Create a new habit."""
try:
# 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)
# Validate required fields
name = data.get('name', '').strip()
if not name:
self.send_json({'error': 'name is required'}, 400)
return
if len(name) > 100:
self.send_json({'error': 'name must be max 100 characters'}, 400)
return
# Validate color (hex format)
color = data.get('color', '#3b82f6')
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
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
# Create new habit
habit_id = str(uuid.uuid4())
now = datetime.now().isoformat()
new_habit = {
'id': habit_id,
'name': name,
'category': data.get('category', 'other'),
'color': color,
'icon': data.get('icon', 'check-circle'),
'priority': data.get('priority', 5),
'notes': data.get('notes', ''),
'reminderTime': data.get('reminderTime', ''),
'frequency': data.get('frequency', {'type': 'daily'}),
'streak': {
'current': 0,
'best': 0,
'lastCheckIn': None
},
'lives': 3,
'completions': [],
'createdAt': now,
'updatedAt': now
}
# Read existing habits
if HABITS_FILE.exists():
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
habits_data = json.load(f)
else:
habits_data = {'lastUpdated': '', 'habits': []}
# Add new habit
habits_data['habits'].append(new_habit)
habits_data['lastUpdated'] = now
# Save to file
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
json.dump(habits_data, f, indent=2)
# Return created habit with 201 status
self.send_json(new_habit, 201)
except json.JSONDecodeError:
self.send_json({'error': 'Invalid JSON'}, 400)
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')

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Tests for habits API endpoints (GET and POST)."""
import json
import sys
import tempfile
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from http.server import HTTPServer
import threading
import time
import urllib.request
import urllib.error
# Add parent directory to path so we can import api module
sys.path.insert(0, str(Path(__file__).parent.parent))
# Mock the habits file to a temp location for testing
import api
original_habits_file = api.HABITS_FILE
def setup_test_env():
"""Set up temporary test environment."""
temp_dir = Path(tempfile.mkdtemp())
api.HABITS_FILE = temp_dir / 'habits.json'
# Create empty habits file
api.HABITS_FILE.write_text(json.dumps({
'lastUpdated': '',
'habits': []
}))
return temp_dir
def cleanup_test_env(temp_dir):
"""Clean up temporary test environment."""
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)
thread = threading.Thread(target=server.serve_forever)
thread.daemon = True
thread.start()
time.sleep(0.5) # Give server time to start
return server
def http_get(path, port=8765):
"""Make HTTP GET request."""
url = f'http://localhost:{port}{path}'
try:
with urllib.request.urlopen(url) 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_post(path, data, port=8765):
"""Make HTTP POST request."""
url = f'http://localhost:{port}{path}'
req = urllib.request.Request(
url,
data=json.dumps(data).encode(),
headers={'Content-Type': 'application/json'}
)
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 {}
# 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()
try:
status, data = http_get('/api/habits')
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")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# 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()
try:
habit_data = {
'name': 'Morning Exercise',
'category': 'health',
'color': '#10b981',
'icon': 'dumbbell',
'priority': 1,
'notes': 'Start with 10 push-ups',
'reminderTime': '07:00',
'frequency': {
'type': 'daily'
}
}
status, data = http_post('/api/habits', habit_data)
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']}"
assert data['category'] == 'health', f"Category mismatch: {data['category']}"
assert data['streak']['current'] == 0, "Initial streak should be 0"
assert data['lives'] == 3, "Initial lives should be 3"
assert data['completions'] == [], "Initial completions should be empty"
print("✓ Test 2: POST /api/habits creates habit with 201")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# Test 3: POST validates name is required
def test_post_habit_missing_name():
temp_dir = setup_test_env()
server = start_test_server()
try:
status, data = http_post('/api/habits', {})
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']}"
print("✓ Test 3: POST validates name is required")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# Test 4: POST validates name max 100 chars
def test_post_habit_name_too_long():
temp_dir = setup_test_env()
server = start_test_server()
try:
status, data = http_post('/api/habits', {'name': 'x' * 101})
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']}"
print("✓ Test 4: POST validates name max 100 chars")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# Test 5: POST validates color hex format
def test_post_habit_invalid_color():
temp_dir = setup_test_env()
server = start_test_server()
try:
status, data = http_post('/api/habits', {
'name': 'Test',
'color': 'not-a-hex-color'
})
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']}"
print("✓ Test 5: POST validates color hex format")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# Test 6: POST validates frequency type
def test_post_habit_invalid_frequency():
temp_dir = setup_test_env()
server = start_test_server()
try:
status, data = http_post('/api/habits', {
'name': 'Test',
'frequency': {'type': 'invalid_type'}
})
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']}"
print("✓ Test 6: POST validates frequency type")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# 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()
try:
# Create a habit first
habit_data = {'name': 'Daily Reading', 'frequency': {'type': 'daily'}}
http_post('/api/habits', habit_data)
# Get habits
status, data = http_get('/api/habits')
assert status == 200, f"Expected 200, got {status}"
assert len(data) == 1, f"Expected 1 habit, got {len(data)}"
habit = data[0]
assert 'current_streak' in habit, "Should include current_streak"
assert 'best_streak' in habit, "Should include best_streak"
assert 'completion_rate_30d' in habit, "Should include completion_rate_30d"
assert 'weekly_summary' in habit, "Should include weekly_summary"
print("✓ Test 7: GET returns habits with stats enriched")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# 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()
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})
# Get habits
status, data = http_get('/api/habits')
assert status == 200, f"Expected 200, got {status}"
assert len(data) == 3, f"Expected 3 habits, got {len(data)}"
# Check sorting
assert data[0]['priority'] == 1, "First should be priority 1"
assert data[1]['priority'] == 5, "Second should be priority 5"
assert data[2]['priority'] == 10, "Third should be priority 10"
print("✓ Test 8: GET sorts habits by priority ascending")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# Test 9: POST returns 400 for invalid JSON
def test_post_habit_invalid_json():
temp_dir = setup_test_env()
server = start_test_server()
try:
url = f'http://localhost:8765/api/habits'
req = urllib.request.Request(
url,
data=b'invalid json{',
headers={'Content-Type': 'application/json'}
)
try:
urllib.request.urlopen(req)
assert False, "Should have raised HTTPError"
except urllib.error.HTTPError as e:
assert e.code == 400, f"Expected 400, got {e.code}"
print("✓ Test 9: POST returns 400 for invalid JSON")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# Test 10: POST initializes streak.current=0
def test_post_habit_initial_streak():
temp_dir = setup_test_env()
server = start_test_server()
try:
status, data = http_post('/api/habits', {'name': 'Test Habit'})
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"
assert data['streak']['lastCheckIn'] is None, "Initial lastCheckIn should be None"
print("✓ Test 10: POST initializes streak correctly")
finally:
server.shutdown()
cleanup_test_env(temp_dir)
# Test 11: 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")
if __name__ == '__main__':
import subprocess
print("\n=== Running Habits API Tests ===\n")
test_get_habits_empty()
test_post_habit_valid()
test_post_habit_missing_name()
test_post_habit_name_too_long()
test_post_habit_invalid_color()
test_post_habit_invalid_frequency()
test_get_habits_with_stats()
test_get_habits_sorted_by_priority()
test_post_habit_invalid_json()
test_post_habit_initial_streak()
test_typecheck()
print("\n✅ All 11 tests passed!\n")