Files
clawd/dashboard/test_habits_post.py

205 lines
6.8 KiB
Python

#!/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()