feat: 3.0 - Backend API - POST /api/habits (create habit)
This commit is contained in:
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