#!/usr/bin/env python3 """ Tests for Story 15.0: Backend - Delete habit endpoint Tests the DELETE /api/habits/{id} endpoint functionality. """ 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 TestHabitsDelete(unittest.TestCase): """Test DELETE /api/habits/{id} 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 to use temp directory import dashboard.api as api_module cls.original_kanban_dir = api_module.KANBAN_DIR api_module.KANBAN_DIR = cls.temp_dir # Start server in background thread cls.port = 9022 cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler) cls.server_thread = Thread(target=cls.server.serve_forever, daemon=True) cls.server_thread.start() sleep(0.5) # Wait for server to start @classmethod def tearDownClass(cls): """Stop server and cleanup""" cls.server.shutdown() # Restore original KANBAN_DIR import dashboard.api as api_module api_module.KANBAN_DIR = cls.original_kanban_dir # Cleanup temp directory shutil.rmtree(cls.temp_dir) def setUp(self): """Reset habits file before each test""" self.habits_file.write_text(json.dumps({ 'lastUpdated': datetime.now().isoformat(), 'habits': [] })) def api_call(self, method, path, body=None): """Make API call and return (status, data)""" url = f'http://127.0.0.1:{self.port}{path}' headers = {'Content-Type': 'application/json'} if body: data = json.dumps(body).encode('utf-8') req = Request(url, data=data, headers=headers, method=method) else: req = Request(url, headers=headers, method=method) try: with urlopen(req) as response: return response.status, json.loads(response.read().decode('utf-8')) except HTTPError as e: return e.code, json.loads(e.read().decode('utf-8')) def test_01_delete_removes_habit_from_file(self): """AC1: DELETE /api/habits/{id} removes habit from habits.json""" # Create two habits _, habit1 = self.api_call('POST', '/api/habits', {'name': 'Habit 1', 'frequency': 'daily'}) _, habit2 = self.api_call('POST', '/api/habits', {'name': 'Habit 2', 'frequency': 'weekly'}) habit1_id = habit1['id'] habit2_id = habit2['id'] # Delete first habit status, _ = self.api_call('DELETE', f'/api/habits/{habit1_id}') self.assertEqual(status, 200) # Verify it's removed from file data = json.loads(self.habits_file.read_text()) remaining_ids = [h['id'] for h in data['habits']] self.assertNotIn(habit1_id, remaining_ids, "Deleted habit still in file") self.assertIn(habit2_id, remaining_ids, "Other habit was incorrectly deleted") self.assertEqual(len(data['habits']), 1, "Should have exactly 1 habit remaining") def test_02_returns_200_with_success_message(self): """AC2: Returns 200 with success message""" # Create a habit _, habit = self.api_call('POST', '/api/habits', {'name': 'Test Habit', 'frequency': 'daily'}) habit_id = habit['id'] # Delete it status, response = self.api_call('DELETE', f'/api/habits/{habit_id}') self.assertEqual(status, 200) self.assertTrue(response.get('success'), "Response should have success=true") self.assertIn('message', response, "Response should contain message field") self.assertEqual(response.get('id'), habit_id, "Response should contain habit ID") def test_03_returns_404_if_not_found(self): """AC3: Returns 404 if habit not found""" status, response = self.api_call('DELETE', '/api/habits/nonexistent-id') self.assertEqual(status, 404) self.assertIn('error', response, "Response should contain error message") def test_04_updates_lastUpdated_timestamp(self): """AC4: Updates lastUpdated timestamp""" # Get initial timestamp data_before = json.loads(self.habits_file.read_text()) timestamp_before = data_before['lastUpdated'] sleep(0.1) # Ensure timestamp difference # Create and delete a habit _, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'}) self.api_call('DELETE', f'/api/habits/{habit["id"]}') # Check timestamp was updated data_after = json.loads(self.habits_file.read_text()) timestamp_after = data_after['lastUpdated'] self.assertNotEqual(timestamp_after, timestamp_before, "Timestamp should be updated") # Verify it's a valid ISO timestamp try: datetime.fromisoformat(timestamp_after.replace('Z', '+00:00')) except ValueError: self.fail(f"Invalid ISO timestamp: {timestamp_after}") def test_05_edge_cases(self): """AC5: Tests for delete endpoint edge cases""" # Create a habit _, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'}) habit_id = habit['id'] # Delete it status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}') self.assertEqual(status, 200) # Try to delete again (should return 404) status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}') self.assertEqual(status, 404, "Should return 404 for already deleted habit") # Test invalid path (trailing slash) status, _ = self.api_call('DELETE', '/api/habits/') self.assertEqual(status, 404, "Should return 404 for invalid path") def test_06_missing_file_handling(self): """Test graceful handling when habits.json is missing""" # Remove file self.habits_file.unlink() # Try to delete status, response = self.api_call('DELETE', '/api/habits/some-id') self.assertEqual(status, 404) self.assertIn('error', response) # Restore file for cleanup self.habits_file.write_text(json.dumps({ 'lastUpdated': datetime.now().isoformat(), 'habits': [] })) if __name__ == '__main__': # Run tests suite = unittest.TestLoader().loadTestsFromTestCase(TestHabitsDelete) runner = unittest.TextTestRunner(verbosity=2) result = runner.run(suite) # Print summary if result.wasSuccessful(): print("\n✅ All Story 15.0 acceptance criteria verified:") print(" 1. DELETE /api/habits/{id} removes habit from habits.json ✓") print(" 2. Returns 200 with success message ✓") print(" 3. Returns 404 if habit not found ✓") print(" 4. Updates lastUpdated timestamp ✓") print(" 5. Tests for delete endpoint pass ✓") sys.exit(0 if result.wasSuccessful() else 1)