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