From 0f9c0de1a2d3531afeedf9de83dff63251720c86 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 13:19:36 +0000 Subject: [PATCH] feat: 15.0 - Backend - Delete habit endpoint --- dashboard/api.py | 59 ++++++++++ dashboard/test_habits_delete.py | 203 ++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 dashboard/test_habits_delete.py diff --git a/dashboard/api.py b/dashboard/api.py index ad60342..726a054 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -166,6 +166,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): else: self.send_error(404) + def do_DELETE(self): + if self.path.startswith('/api/habits/'): + self.handle_habits_delete() + else: + self.send_error(404) + def handle_git_commit(self): """Run git commit and push.""" try: @@ -988,6 +994,59 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): except Exception as e: self.send_json({'error': str(e)}, 500) + def handle_habits_delete(self): + """Delete a habit by ID.""" + try: + # Extract habit ID from path: /api/habits/{id} + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] # /api/habits/{id} -> index 3 is id + + # Read habits file + habits_file = KANBAN_DIR / 'habits.json' + if not habits_file.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + try: + habits_data = json.loads(habits_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, IOError): + self.send_json({'error': 'Habit not found'}, 404) + return + + # Find the habit by ID + habit_index = None + for i, h in enumerate(habits_data.get('habits', [])): + if h.get('id') == habit_id: + habit_index = i + break + + if habit_index is None: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Remove the habit + deleted_habit = habits_data['habits'].pop(habit_index) + + # Update lastUpdated timestamp + habits_data['lastUpdated'] = datetime.now().isoformat() + + # Write back to file + habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8') + + # Return 200 OK with success message + self.send_json({ + 'success': True, + 'message': 'Habit deleted successfully', + 'id': habit_id + }, 200) + + 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 diff --git a/dashboard/test_habits_delete.py b/dashboard/test_habits_delete.py new file mode 100644 index 0000000..72ecc63 --- /dev/null +++ b/dashboard/test_habits_delete.py @@ -0,0 +1,203 @@ +#!/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)