feat: 3.0 - Backend API - POST /api/habits (create habit)
This commit is contained in:
@@ -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
|
||||
|
||||
204
dashboard/test_habits_post.py
Normal file
204
dashboard/test_habits_post.py
Normal 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()
|
||||
Reference in New Issue
Block a user