feat: 3.0 - Backend API - POST /api/habits (create habit)

This commit is contained in:
Echo
2026-02-10 11:18:48 +00:00
parent fc5ebf2026
commit 3a09e6c51a
2 changed files with 266 additions and 0 deletions

View File

@@ -48,6 +48,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
self.handle_git_commit() self.handle_git_commit()
elif self.path == '/api/pdf': elif self.path == '/api/pdf':
self.handle_pdf_post() self.handle_pdf_post()
elif self.path == '/api/habits':
self.handle_habits_post()
elif self.path == '/api/workspace/run': elif self.path == '/api/workspace/run':
self.handle_workspace_run() self.handle_workspace_run()
elif self.path == '/api/workspace/stop': elif self.path == '/api/workspace/stop':
@@ -718,6 +720,66 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
except Exception as e: except Exception as e:
self.send_json({'error': str(e)}, 500) 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): def handle_files_get(self):
"""List files or get file content.""" """List files or get file content."""
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs

View File

@@ -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()