300 lines
10 KiB
Python
300 lines
10 KiB
Python
#!/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")
|