Feature: Habit Tracker with Streak Calculation #1
@@ -166,6 +166,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_error(404)
|
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):
|
def handle_git_commit(self):
|
||||||
"""Run git commit and push."""
|
"""Run git commit and push."""
|
||||||
try:
|
try:
|
||||||
@@ -988,6 +994,59 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({'error': str(e)}, 500)
|
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):
|
def handle_files_get(self):
|
||||||
"""List files or get file content."""
|
"""List files or get file content."""
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|||||||
203
dashboard/test_habits_delete.py
Normal file
203
dashboard/test_habits_delete.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user