From fc5ebf2026a044638399a7b976cf0991b278dc4d Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 11:09:58 +0000 Subject: [PATCH] feat: 2.0 - Backend API - GET /api/habits --- dashboard/api.py | 37 +++++++ dashboard/test_habits_api.py | 207 +++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 dashboard/test_habits_api.py diff --git a/dashboard/api.py b/dashboard/api.py index cd93951..b4a4f76 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -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 diff --git a/dashboard/test_habits_api.py b/dashboard/test_habits_api.py new file mode 100644 index 0000000..09ebb83 --- /dev/null +++ b/dashboard/test_habits_api.py @@ -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)