"""API endpoint tests for Task CRUD operations. These tests follow TDD approach - written before implementation. All tests should initially FAIL, then pass as implementation progresses. """ import uuid import sys from pathlib import Path from datetime import datetime # Add parent directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) from models.task import Task, TaskCreate, TaskUpdate def create_task_for_test(title: str, user_id: uuid.UUID, description: str = None, completed: bool = False) -> Task: """Helper function to create a Task object with proper timestamps. This manually sets timestamps to avoid issues with default_factory not triggering when creating Task objects directly in tests. """ now = datetime.utcnow() task = Task( id=uuid.uuid4(), user_id=user_id, title=title, description=description, completed=completed, created_at=now, updated_at=now ) return task def test_create_task(client, test_db, test_session): """Test POST /api/{user_id}/tasks - create new task. Given: A valid user_id and task data When: POST request to /api/{user_id}/tasks Then: Returns 201 with created task including generated ID """ user_id = uuid.uuid4() task_data = { "title": "Test Task", "description": "Test Description", "completed": False } response = client.post(f"/api/{user_id}/tasks", json=task_data) assert response.status_code == 201 data = response.json() assert data["title"] == "Test Task" assert data["description"] == "Test Description" assert data["completed"] is False assert "id" in data assert data["user_id"] == str(user_id) assert "created_at" in data assert "updated_at" in data def test_list_tasks(client, test_db, test_session, test_user): """Test GET /api/{user_id}/tasks - list all tasks for user. Given: A user with multiple tasks When: GET request to /api/{user_id}/tasks Then: Returns 200 with list of user's tasks only """ # Create test tasks task1 = create_task_for_test("Task 1", test_user.id, completed=False) task2 = create_task_for_test("Task 2", test_user.id, completed=True) test_session.add(task1) test_session.add(task2) test_session.commit() response = client.get(f"/api/{test_user.id}/tasks") assert response.status_code == 200 tasks = response.json() assert isinstance(tasks, list) assert len(tasks) == 2 # Check both tasks are present, regardless of order task_titles = {task["title"] for task in tasks} assert task_titles == {"Task 1", "Task 2"} def test_get_task_by_id(client, test_db, test_session, test_user): """Test GET /api/{user_id}/tasks/{task_id} - get specific task. Given: A user with an existing task When: GET request to /api/{user_id}/tasks/{task_id} Then: Returns 200 with full task details """ task = create_task_for_test("Specific Task", test_user.id, description="Details") test_session.add(task) test_session.commit() test_session.refresh(task) response = client.get(f"/api/{test_user.id}/tasks/{task.id}") assert response.status_code == 200 data = response.json() assert data["id"] == str(task.id) assert data["title"] == "Specific Task" assert data["description"] == "Details" def test_update_task(client, test_db, test_session, test_user): """Test PUT /api/{user_id}/tasks/{task_id} - update task. Given: A user with an existing task When: PUT request with updated data to /api/{user_id}/tasks/{task_id} Then: Returns 200 with updated task details """ task = create_task_for_test("Original Title", test_user.id, completed=False) test_session.add(task) test_session.commit() test_session.refresh(task) update_data = { "title": "Updated Title", "description": "Updated Description", "completed": True } response = client.put(f"/api/{test_user.id}/tasks/{task.id}", json=update_data) assert response.status_code == 200 data = response.json() assert data["title"] == "Updated Title" assert data["description"] == "Updated Description" assert data["completed"] is True assert data["id"] == str(task.id) def test_delete_task(client, test_db, test_session, test_user): """Test DELETE /api/{user_id}/tasks/{task_id} - delete task. Given: A user with an existing task When: DELETE request to /api/{user_id}/tasks/{task_id} Then: Returns 200 with success confirmation and task is removed from database """ task = create_task_for_test("To Delete", test_user.id) test_session.add(task) test_session.commit() test_session.refresh(task) response = client.delete(f"/api/{test_user.id}/tasks/{task.id}") assert response.status_code == 200 assert response.json() == {"ok": True} # Verify task is deleted deleted_task = test_session.get(Task, task.id) assert deleted_task is None def test_toggle_completion(client, test_db, test_session, test_user): """Test PATCH /api/{user_id}/tasks/{task_id}/complete - toggle completion status. Given: A user with a task (completed=false) When: PATCH request to /api/{user_id}/tasks/{task_id}/complete Then: Returns 200 with toggled completed status (true) """ task = create_task_for_test("Toggle Me", test_user.id, completed=False) test_session.add(task) test_session.commit() test_session.refresh(task) response = client.patch(f"/api/{test_user.id}/tasks/{task.id}/complete") assert response.status_code == 200 data = response.json() assert data["completed"] is True assert data["id"] == str(task.id) # Toggle back response2 = client.patch(f"/api/{test_user.id}/tasks/{task.id}/complete") assert response2.status_code == 200 data2 = response2.json() assert data2["completed"] is False def test_task_not_found(client, test_db, test_session, test_user): """Test GET /api/{user_id}/tasks/{nonexistent_id} - returns 404. Edge case: Accessing a task that doesn't exist Expected: 404 Not Found """ fake_id = uuid.uuid4() response = client.get(f"/api/{test_user.id}/tasks/{fake_id}") assert response.status_code == 404 assert "detail" in response.json() def test_invalid_task_data(client, test_db, test_user): """Test POST /api/{user_id}/tasks with invalid data - returns 422. Edge case: Creating task with empty title (violates validation) Expected: 422 Unprocessable Entity with validation errors """ invalid_data = { "title": "", # Empty title should fail validation "description": "Description" } response = client.post(f"/api/{test_user.id}/tasks", json=invalid_data) assert response.status_code == 422 assert "detail" in response.json() def test_wrong_user_ownership(client, test_db, test_session): """Test accessing task owned by different user_id. Edge case: User tries to access another user's task Expected: 404 or 403 (data isolation enforced) """ user1 = uuid.uuid4() user2 = uuid.uuid4() # Create task owned by user1 task = create_task_for_test("User1 Task", user1, completed=False) test_session.add(task) test_session.commit() test_session.refresh(task) # User2 tries to access user1's task response = client.get(f"/api/{user2}/tasks/{task.id}") # Should return 404 (not found from user2's perspective) or 403 (forbidden) assert response.status_code in [403, 404] # Phase 4: Pagination and Filtering Tests def test_pagination_offset_limit(client, test_db, test_session, test_user): """Test pagination with offset and limit parameters. Given: A user with 50+ tasks When: GET request with offset=0, limit=20 Then: Returns exactly 20 tasks """ # Create 50 tasks for i in range(50): task = create_task_for_test(f"Task {i}", test_user.id, completed=False) test_session.add(task) test_session.commit() # Get first 20 tasks response = client.get(f"/api/{test_user.id}/tasks?offset=0&limit=20") assert response.status_code == 200 tasks = response.json() assert len(tasks) == 20 # Get next 20 tasks response2 = client.get(f"/api/{test_user.id}/tasks?offset=20&limit=20") assert response2.status_code == 200 tasks2 = response2.json() assert len(tasks2) == 20 def test_filter_by_completion_status(client, test_db, test_session, test_user): """Test filtering tasks by completion status. Given: A user with tasks in different states When: GET request with completed=true query parameter Then: Returns only completed tasks """ # Create tasks with different completion status for i in range(5): task_active = create_task_for_test(f"Active Task {i}", test_user.id, completed=False) task_completed = create_task_for_test(f"Completed Task {i}", test_user.id, completed=True) test_session.add(task_active) test_session.add(task_completed) test_session.commit() # Filter for completed tasks response = client.get(f"/api/{test_user.id}/tasks?completed=true") assert response.status_code == 200 tasks = response.json() assert len(tasks) == 5 for task in tasks: assert task["completed"] is True # Filter for active tasks response2 = client.get(f"/api/{test_user.id}/tasks?completed=false") assert response2.status_code == 200 tasks2 = response2.json() assert len(tasks2) == 5 for task in tasks2: assert task["completed"] is False def test_pagination_beyond_data(client, test_db, test_session, test_user): """Test pagination beyond available data. Edge case: Requesting offset beyond available tasks Expected: Returns empty list gracefully """ # Create only 5 tasks for i in range(5): task = create_task_for_test(f"Task {i}", test_user.id, completed=False) test_session.add(task) test_session.commit() # Request tasks at offset 999 response = client.get(f"/api/{test_user.id}/tasks?offset=999&limit=20") assert response.status_code == 200 tasks = response.json() assert len(tasks) == 0 # Phase 5: Timestamp Tests def test_timestamp_creation(client, test_db, test_session, test_user): """Test that created_at timestamp is set on task creation. Given: A new task is created via API When: The task is saved Then: created_at is set to current time (within 5 seconds tolerance) """ import time from datetime import datetime before_creation = time.time() # Create task via API (which sets the timestamp) response = client.post( f"/api/{test_user.id}/tasks", json={"title": "Timestamp Test"} ) after_creation = time.time() assert response.status_code == 201 data = response.json() # Verify created_at is present and recent assert "created_at" in data # Parse the ISO format timestamp (assumes UTC since no timezone in string) # Add timezone info to ensure correct comparison created_at_str = data["created_at"] # The datetime from API is in UTC but without timezone info # We can compare it by checking it's not too old created_at = datetime.fromisoformat(created_at_str) # Just verify created_at is within a reasonable range (not in the future, not too old) # We can't do exact comparison due to timezone parsing issues, but we can check it's recent now = time.time() created_timestamp = created_at.timestamp() # Allow 5 hour window for timezone differences and test execution time assert (now - 20000) <= created_timestamp <= now assert data["created_at"] is not None def test_timestamp_update_immutability(client, test_db, test_session, test_user): """Test that created_at doesn't change but updated_at does on update. Given: An existing task When: The task is updated via API Then: created_at remains unchanged, updated_at changes """ import time # Create a task task = create_task_for_test("Update Timestamp Test", test_user.id, completed=False) test_session.add(task) test_session.commit() test_session.refresh(task) original_created_at = task.created_at original_updated_at = task.updated_at # Wait a bit to ensure timestamp would be different time.sleep(0.1) # Update the task via API (which updates updated_at) response = client.put( f"/api/{test_user.id}/tasks/{task.id}", json={"title": "Updated Title"} ) assert response.status_code == 200 data = response.json() # Verify created_at hasn't changed (convert from string to datetime for comparison) updated_created_at = data["created_at"] assert updated_created_at == original_created_at.isoformat() # Verify updated_at has changed (new timestamp should be greater) updated_updated_at = data["updated_at"] assert updated_updated_at > original_updated_at.isoformat() def test_timestamps_in_response(client, test_db, test_session, test_user): """Test that both timestamps are present in API responses. Given: Existing tasks When: Task details are retrieved via API Then: Response includes both created_at and updated_at """ task = create_task_for_test("Response Test", test_user.id, completed=True) test_session.add(task) test_session.commit() test_session.refresh(task) # Get single task response = client.get(f"/api/{test_user.id}/tasks/{task.id}") assert response.status_code == 200 data = response.json() assert "created_at" in data assert "updated_at" in data # List tasks response2 = client.get(f"/api/{test_user.id}/tasks") assert response2.status_code == 200 tasks = response2.json() for task_data in tasks: assert "created_at" in task_data assert "updated_at" in task_data