From 3a09e6c51aa85130e21045f2307560d5f02be0b7 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 11:18:48 +0000 Subject: [PATCH] feat: 3.0 - Backend API - POST /api/habits (create habit) --- dashboard/api.py | 62 +++++++++++ dashboard/test_habits_post.py | 204 ++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 dashboard/test_habits_post.py diff --git a/dashboard/api.py b/dashboard/api.py index b4a4f76..97c3955 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -48,6 +48,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': @@ -718,6 +720,66 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): except Exception as e: self.send_json({'error': str(e)}, 500) + def handle_habits_post(self): + """Create a new habit in habits.json.""" + try: + # Read POST 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() + frequency = data.get('frequency', '').strip() + + # Validation: name is required + if not name: + self.send_json({'error': 'name is required'}, 400) + return + + # Validation: frequency must be daily or weekly + if frequency not in ('daily', 'weekly'): + self.send_json({'error': 'frequency must be daily or weekly'}, 400) + return + + # Generate habit ID with millisecond timestamp + from time import time + habit_id = f"habit-{int(time() * 1000)}" + + # Create habit object + new_habit = { + 'id': habit_id, + 'name': name, + 'frequency': frequency, + 'createdAt': datetime.now().isoformat(), + 'completions': [] + } + + # Read existing habits + habits_file = KANBAN_DIR / 'habits.json' + if habits_file.exists(): + try: + habits_data = json.loads(habits_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, IOError): + habits_data = {'habits': [], 'lastUpdated': datetime.now().isoformat()} + else: + habits_data = {'habits': [], 'lastUpdated': datetime.now().isoformat()} + + # Add new habit + habits_data['habits'].append(new_habit) + habits_data['lastUpdated'] = datetime.now().isoformat() + + # Write back to file + habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8') + + # Return 201 Created with the new habit + 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 handle_files_get(self): """List files or get file content.""" from urllib.parse import urlparse, parse_qs diff --git a/dashboard/test_habits_post.py b/dashboard/test_habits_post.py new file mode 100644 index 0000000..4c1ba65 --- /dev/null +++ b/dashboard/test_habits_post.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Tests for POST /api/habits endpoint""" + +import json +import shutil +import sys +import tempfile +import unittest +from datetime import datetime +from http.server import HTTPServer +from pathlib import Path +from threading import Thread +from time import sleep +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dashboard.api import TaskBoardHandler + +class TestHabitsPost(unittest.TestCase): + """Test POST /api/habits endpoint""" + + @classmethod + def setUpClass(cls): + """Start test server""" + # Create temp habits.json + cls.temp_dir = Path(tempfile.mkdtemp()) + cls.habits_file = cls.temp_dir / 'habits.json' + cls.habits_file.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + + # Monkey-patch KANBAN_DIR + import dashboard.api as api_module + cls.original_kanban_dir = api_module.KANBAN_DIR + api_module.KANBAN_DIR = cls.temp_dir + + # Start server + cls.port = 18088 + cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler) + cls.thread = Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + sleep(0.5) + + cls.base_url = f'http://127.0.0.1:{cls.port}' + + @classmethod + def tearDownClass(cls): + """Stop server and cleanup""" + cls.server.shutdown() + cls.thread.join(timeout=2) + + # Restore KANBAN_DIR + import dashboard.api as api_module + api_module.KANBAN_DIR = cls.original_kanban_dir + + # Cleanup temp dir + shutil.rmtree(cls.temp_dir) + + def setUp(self): + """Reset habits.json before each test""" + self.habits_file.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + + def post_habit(self, data): + """Helper to POST to /api/habits""" + url = f'{self.base_url}/api/habits' + req = Request(url, data=json.dumps(data).encode(), method='POST') + req.add_header('Content-Type', 'application/json') + return urlopen(req) + + def test_create_habit_success(self): + """Test creating a valid habit""" + data = {'name': 'Bazin', 'frequency': 'daily'} + resp = self.post_habit(data) + + self.assertEqual(resp.status, 201) + + result = json.loads(resp.read()) + self.assertIn('id', result) + self.assertTrue(result['id'].startswith('habit-')) + self.assertEqual(result['name'], 'Bazin') + self.assertEqual(result['frequency'], 'daily') + self.assertIn('createdAt', result) + self.assertEqual(result['completions'], []) + + # Verify ISO timestamp + datetime.fromisoformat(result['createdAt']) + + def test_habit_persisted_to_file(self): + """Test habit is written to habits.json""" + data = {'name': 'Sală', 'frequency': 'weekly'} + resp = self.post_habit(data) + habit = json.loads(resp.read()) + + # Read file + file_data = json.loads(self.habits_file.read_text()) + self.assertEqual(len(file_data['habits']), 1) + self.assertEqual(file_data['habits'][0]['id'], habit['id']) + self.assertEqual(file_data['habits'][0]['name'], 'Sală') + + def test_id_format_correct(self): + """Test generated id follows 'habit-{timestamp}' format""" + data = {'name': 'Test', 'frequency': 'daily'} + resp = self.post_habit(data) + habit = json.loads(resp.read()) + + habit_id = habit['id'] + self.assertTrue(habit_id.startswith('habit-')) + + # Extract timestamp and verify it's numeric + timestamp_part = habit_id.replace('habit-', '') + self.assertTrue(timestamp_part.isdigit()) + + # Verify timestamp is reasonable (milliseconds since epoch) + timestamp_ms = int(timestamp_part) + now_ms = int(datetime.now().timestamp() * 1000) + # Should be within 5 seconds + self.assertLess(abs(now_ms - timestamp_ms), 5000) + + def test_missing_name_returns_400(self): + """Test missing name returns 400""" + data = {'frequency': 'daily'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + error = json.loads(ctx.exception.read()) + self.assertIn('name', error['error'].lower()) + + def test_empty_name_returns_400(self): + """Test empty name (whitespace only) returns 400""" + data = {'name': ' ', 'frequency': 'daily'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + + def test_invalid_frequency_returns_400(self): + """Test invalid frequency returns 400""" + data = {'name': 'Test', 'frequency': 'monthly'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + error = json.loads(ctx.exception.read()) + self.assertIn('frequency', error['error'].lower()) + + def test_missing_frequency_returns_400(self): + """Test missing frequency returns 400""" + data = {'name': 'Test'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + + def test_multiple_habits_created(self): + """Test creating multiple habits""" + habit1 = {'name': 'Bazin', 'frequency': 'daily'} + habit2 = {'name': 'Sală', 'frequency': 'weekly'} + + resp1 = self.post_habit(habit1) + h1 = json.loads(resp1.read()) + + # Small delay to ensure different timestamp + sleep(0.01) + + resp2 = self.post_habit(habit2) + h2 = json.loads(resp2.read()) + + # IDs should be different + self.assertNotEqual(h1['id'], h2['id']) + + # Both should be in file + file_data = json.loads(self.habits_file.read_text()) + self.assertEqual(len(file_data['habits']), 2) + + def test_last_updated_timestamp(self): + """Test lastUpdated is updated when creating habit""" + before = datetime.now().isoformat() + + data = {'name': 'Test', 'frequency': 'daily'} + self.post_habit(data) + + file_data = json.loads(self.habits_file.read_text()) + last_updated = file_data['lastUpdated'] + + # Should be a valid ISO timestamp + datetime.fromisoformat(last_updated) + # Should be recent + self.assertGreaterEqual(last_updated, before) + + +if __name__ == '__main__': + unittest.main()