From 519974a07f3aa72104cb25924196bc1f786bd3ab Mon Sep 17 00:00:00 2001 From: Cliff Hill Date: Mon, 25 Aug 2025 20:21:40 -0400 Subject: [PATCH] Added tests for bookings router. Signed-off-by: Cliff Hill --- backend/src/backend/routers/bookings.py | 12 +- backend/src/backend/routers/rooms.py | 4 +- backend/src/backend/schemas/bookings.py | 4 +- backend/tests/routers/test_bookings.py | 423 ++++++++++++++++++++++++ 4 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 backend/tests/routers/test_bookings.py diff --git a/backend/src/backend/routers/bookings.py b/backend/src/backend/routers/bookings.py index 297141fe..b32afdd1 100644 --- a/backend/src/backend/routers/bookings.py +++ b/backend/src/backend/routers/bookings.py @@ -98,13 +98,13 @@ async def create_booking(booking: BookingCreate, session: DBSession): except SQLAlchemyError as e: logger.error(f"Database error while creating booking: {str(e)}") raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error occurred", ) from e except Exception as e: logger.error(f"Unexpected error while creating booking: {str(e)}") raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred", ) from e @@ -116,9 +116,7 @@ async def update_existing_booking( """Endpoint to update an existing booking.""" logger.debug(f"Received request to update booking with id: {booking_id}") try: - booking_params = { - k: v for k, v in booking_update.model_dump(exclude_unset=True).items() - } + booking_params = booking_update.model_dump(exclude_unset=True) updated_booking = await update_booking(session, booking_id, **booking_params) logger.info(f"Successfully updated booking with id: {booking_id}") return updated_booking @@ -133,7 +131,7 @@ async def update_existing_booking( f"Database error while updating booking with id {booking_id}: {str(e)}" ) raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error occurred", ) from e except Exception as e: @@ -141,7 +139,7 @@ async def update_existing_booking( f"Unexpected error while updating booking with id {booking_id}: {str(e)}" ) raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred", ) from e diff --git a/backend/src/backend/routers/rooms.py b/backend/src/backend/routers/rooms.py index da19649b..afa034fa 100644 --- a/backend/src/backend/routers/rooms.py +++ b/backend/src/backend/routers/rooms.py @@ -107,9 +107,7 @@ async def update_existing_room( """Endpoint to update an existing room.""" logger.debug(f"Received request to update room with id: {room_id}") try: - room_params = { - k: v for k, v in room_update.model_dump(exclude_unset=True).items() - } + room_params = room_update.model_dump(exclude_unset=True) updated_room = await update_room(session, room_id, **room_params) logger.info(f"Successfully updated room with id: {room_id}") return updated_room diff --git a/backend/src/backend/schemas/bookings.py b/backend/src/backend/schemas/bookings.py index 3090d538..8ba30ad5 100644 --- a/backend/src/backend/schemas/bookings.py +++ b/backend/src/backend/schemas/bookings.py @@ -5,8 +5,6 @@ from datetime import datetime from pydantic import BaseModel from pydantic import ConfigDict -from backend.schemas.rooms import RoomResponse - class BookingBase(BaseModel): """Base schema for Booking.""" @@ -25,6 +23,7 @@ class BookingCreate(BookingBase): class BookingUpdate(BaseModel): """Schema for updating an existing Booking.""" + id: int | None = None room_id: int | None = None start_time: datetime | None = None end_time: datetime | None = None @@ -36,6 +35,5 @@ class BookingResponse(BookingBase): """Response schema for Booking, including ID and related Room.""" id: int - room: RoomResponse model_config = ConfigDict(from_attributes=True) diff --git a/backend/tests/routers/test_bookings.py b/backend/tests/routers/test_bookings.py new file mode 100644 index 00000000..692ff1c8 --- /dev/null +++ b/backend/tests/routers/test_bookings.py @@ -0,0 +1,423 @@ +"""Unit tests for the backend.routers.bookings module.""" + +import logging +from collections.abc import Generator +from datetime import datetime +from typing import Any +from unittest.mock import ANY +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import NoResultFound +from sqlalchemy.exc import SQLAlchemyError + +from backend.models import Booking +from backend.models import Room +from backend.services.bookings import BookingList + +from ..conftest import MockLogger + + +@pytest.fixture(autouse=True) +def mock_logger() -> Generator[MockLogger, None, None]: + """Fixture to mock the logger used in the bookings service.""" + mock_logger_instance = MagicMock(spec=logging.Logger) + with patch("backend.routers.bookings.logger", mock_logger_instance): + yield mock_logger_instance + + +@pytest.fixture +def sample_room() -> Room: + """Fixture to provide a sample Room object for testing.""" + return Room( + id=1, + name="Test Room", + location="Building A", + equipment="Projector, Whiteboard", + capacity=10, + ) + + +@pytest.fixture +def sample_bookings(sample_room: Room) -> BookingList: + """Fixture to provide sample Booking objects for testing.""" + booking1 = Booking( + room_id=1, + start_time=datetime.fromisoformat("2025-08-25T10:00:00"), + end_time=datetime.fromisoformat("2025-08-25T11:00:00"), + ) + booking1.id = 1 # Manually set ID for testing purposes + booking1.room = sample_room # Attach room relationship + booking2 = Booking( + room_id=1, + start_time=datetime.fromisoformat("2025-08-25T12:00:00"), + end_time=datetime.fromisoformat("2025-08-25T13:00:00"), + ) + booking2.id = 2 # Manually set ID for testing purposes + booking2.room = sample_room # Attach room relationship + return [booking1, booking2] + + +@pytest.mark.asyncio +async def test_get_bookings_for_room_success( + client: TestClient, sample_bookings: BookingList, mock_logger: MockLogger +) -> None: + """Test successful retrieval of all bookings for a room via GET /bookings/room/{room_id}. + + Verifies that the endpoint returns the expected list of bookings with a 200 status. + """ + room_id = 1 + with patch( + "backend.routers.bookings.get_bookings_for_room", new=AsyncMock() + ) as mock_get_bookings: + mock_get_bookings.return_value = sample_bookings + + response = client.get(f"/bookings/room/{room_id}") + + assert response.status_code == 200 + assert response.json() == [ + { + "id": 1, + "room_id": 1, + "start_time": "2025-08-25T10:00:00", + "end_time": "2025-08-25T11:00:00", + }, + { + "id": 2, + "room_id": 1, + "start_time": "2025-08-25T12:00:00", + "end_time": "2025-08-25T13:00:00", + }, + ] + mock_get_bookings.assert_called_once_with(ANY, room_id) + + +@pytest.mark.asyncio +async def test_get_bookings_for_room_empty( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test retrieval of bookings when none exist for a room via GET /bookings/room/{room_id}. + + Verifies that the endpoint returns an empty list with a 200 status. + """ + room_id = 1 + with patch( + "backend.routers.bookings.get_bookings_for_room", new=AsyncMock() + ) as mock_get_bookings: + mock_get_bookings.return_value = [] + + response = client.get(f"/bookings/room/{room_id}") + + assert response.status_code == 200 + assert response.json() == [] + mock_get_bookings.assert_called_once_with(ANY, room_id) + + +@pytest.mark.asyncio +async def test_get_bookings_for_room_database_error( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of database errors in GET /bookings/room/{room_id}. + + Verifies that the endpoint returns a 500 status on database failure. + """ + room_id = 1 + with patch( + "backend.routers.bookings.get_bookings_for_room", new=AsyncMock() + ) as mock_get_bookings: + mock_get_bookings.side_effect = SQLAlchemyError() + + response = client.get(f"/bookings/room/{room_id}") + + assert response.status_code == 500 + assert "detail" in response.json() + mock_get_bookings.assert_called_once_with(ANY, room_id) + + +@pytest.mark.asyncio +async def test_get_booking_success( + client: TestClient, sample_room: Room, mock_logger: MockLogger +) -> None: + """Test successful retrieval of a booking by ID via GET /bookings/{booking_id}. + + Verifies that the endpoint returns the correct booking with a 200 status. + """ + booking_id = 1 + booking = Booking( + room_id=1, + start_time=datetime.fromisoformat("2025-08-25T10:00:00"), + end_time=datetime.fromisoformat("2025-08-25T11:00:00"), + ) + booking.id = booking_id + booking.room = sample_room # Attach room relationship + with patch( + "backend.routers.bookings.get_booking", new=AsyncMock() + ) as mock_get_booking: + mock_get_booking.return_value = booking + + response = client.get(f"/bookings/{booking_id}") + + assert response.status_code == 200 + assert response.json() == { + "id": booking_id, + "room_id": 1, + "start_time": "2025-08-25T10:00:00", + "end_time": "2025-08-25T11:00:00", + } + mock_get_booking.assert_called_once_with(ANY, booking_id) + + +@pytest.mark.asyncio +async def test_get_booking_not_found( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of non-existent booking in GET /bookings/{booking_id}. + + Verifies that the endpoint returns a 404 status when the booking is not found. + """ + booking_id = 999 + with patch( + "backend.routers.bookings.get_booking", new=AsyncMock() + ) as mock_get_booking: + mock_get_booking.side_effect = NoResultFound() + + response = client.get(f"/bookings/{booking_id}") + + assert response.status_code == 404 + assert "detail" in response.json() + mock_get_booking.assert_called_once_with(ANY, booking_id) + + +@pytest.mark.asyncio +async def test_get_booking_database_error( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of database errors in GET /bookings/{booking_id}. + + Verifies that the endpoint returns a 500 status on database failure. + """ + booking_id = 1 + with patch( + "backend.routers.bookings.get_booking", new=AsyncMock() + ) as mock_get_booking: + mock_get_booking.side_effect = SQLAlchemyError() + + response = client.get(f"/bookings/{booking_id}") + + assert response.status_code == 500 + assert "detail" in response.json() + mock_get_booking.assert_called_once_with(ANY, booking_id) + + +@pytest.mark.asyncio +async def test_create_booking_success( + client: TestClient, sample_room: Room, mock_logger: MockLogger +) -> None: + """Test successful creation of a new booking via POST /bookings/. + + Verifies that the endpoint returns the created booking with a 201 status. + """ + booking_data: dict[str, Any] = { + "room_id": 1, + "start_time": "2025-08-25T10:00:00", + "end_time": "2025-08-25T11:00:00", + } + created_booking = Booking(**booking_data) + created_booking.id = 3 + created_booking.room = sample_room # Attach room relationship + with patch( + "backend.routers.bookings.new_booking", new=AsyncMock() + ) as mock_new_booking: + mock_new_booking.return_value = created_booking + + response = client.post("/bookings/", json=booking_data) + + assert response.status_code == 201 + assert response.json() == { + "id": 3, + "room_id": 1, + "start_time": "2025-08-25T10:00:00", + "end_time": "2025-08-25T11:00:00", + } + mock_new_booking.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_booking_database_error( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of database errors in POST /bookings/. + + Verifies that the endpoint returns a 400 status on database failure. + """ + booking_data: dict[str, Any] = { + "room_id": 1, + "start_time": "2025-08-25T10:00:00", + "end_time": "2025-08-25T11:00:00", + } + with patch( + "backend.routers.bookings.new_booking", new=AsyncMock() + ) as mock_new_booking: + mock_new_booking.side_effect = SQLAlchemyError() + + response = client.post("/bookings/", json=booking_data) + + assert response.status_code == 500 + assert "detail" in response.json() + mock_new_booking.assert_called_once() + + +@pytest.mark.asyncio +async def test_update_booking_success( + client: TestClient, sample_room: Room, mock_logger: MockLogger +) -> None: + """Test successful update of a booking via PUT /bookings/{booking_id}. + + Verifies that the endpoint returns the updated booking with a 200 status. + """ + booking_id = 1 + update_data: dict[str, Any] = { + "room_id": 2, + "start_time": "2025-08-25T14:00:00", + "end_time": "2025-08-25T15:00:00", + } + updated_booking = Booking(**update_data) + updated_booking.id = booking_id + updated_booking.room = sample_room # Attach room relationship + with patch( + "backend.routers.bookings.update_booking", new=AsyncMock() + ) as mock_update_booking: + mock_update_booking.return_value = updated_booking + + response = client.put(f"/bookings/{booking_id}", json=update_data) + + assert response.status_code == 200 + assert response.json() == { + "id": booking_id, + "room_id": 2, + "start_time": "2025-08-25T14:00:00", + "end_time": "2025-08-25T15:00:00", + } + update_data["start_time"] = datetime.fromisoformat(update_data["start_time"]) + update_data["end_time"] = datetime.fromisoformat(update_data["end_time"]) + mock_update_booking.assert_called_once_with(ANY, booking_id, **update_data) + + +@pytest.mark.asyncio +async def test_update_booking_not_found( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of non-existent booking in PUT /bookings/{booking_id}. + + Verifies that the endpoint returns a 404 status when the booking is not found. + """ + booking_id = 999 + update_data: dict[str, Any] = { + "room_id": 2, + "start_time": "2025-08-25T14:00:00", + "end_time": "2025-08-25T15:00:00", + } + with patch( + "backend.routers.bookings.update_booking", new=AsyncMock() + ) as mock_update_booking: + mock_update_booking.side_effect = NoResultFound() + + response = client.put(f"/bookings/{booking_id}", json=update_data) + + assert response.status_code == 404 + assert "detail" in response.json() + update_data["start_time"] = datetime.fromisoformat(update_data["start_time"]) + update_data["end_time"] = datetime.fromisoformat(update_data["end_time"]) + mock_update_booking.assert_called_once_with(ANY, booking_id, **update_data) + + +@pytest.mark.asyncio +async def test_update_booking_database_error( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of database errors in PUT /bookings/{booking_id}. + + Verifies that the endpoint returns a 500 status on database failure. + """ + booking_id = 1 + update_data: dict[str, Any] = { + "room_id": 2, + "start_time": "2025-08-25T14:00:00", + "end_time": "2025-08-25T15:00:00", + } + with patch( + "backend.routers.bookings.update_booking", new=AsyncMock() + ) as mock_update_booking: + mock_update_booking.side_effect = SQLAlchemyError() + + response = client.put(f"/bookings/{booking_id}", json=update_data) + + assert response.status_code == 500 + assert "detail" in response.json() + update_data["start_time"] = datetime.fromisoformat(update_data["start_time"]) + update_data["end_time"] = datetime.fromisoformat(update_data["end_time"]) + mock_update_booking.assert_called_once_with(ANY, booking_id, **update_data) + + +@pytest.mark.asyncio +async def test_delete_booking_success( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test successful deletion of a booking via DELETE /bookings/{booking_id}. + + Verifies that the endpoint returns a 204 status on successful deletion. + """ + booking_id = 1 + with patch( + "backend.routers.bookings.delete_booking", new=AsyncMock() + ) as mock_delete_booking: + response = client.delete(f"/bookings/{booking_id}") + + assert response.status_code == 204 + assert response.text == "" + mock_delete_booking.assert_called_once_with(ANY, booking_id) + + +@pytest.mark.asyncio +async def test_delete_booking_not_found( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of non-existent booking in DELETE /bookings/{booking_id}. + + Verifies that the endpoint returns a 404 status when the booking is not found. + """ + booking_id = 999 + with patch( + "backend.routers.bookings.delete_booking", new=AsyncMock() + ) as mock_delete_booking: + mock_delete_booking.side_effect = NoResultFound() + + response = client.delete(f"/bookings/{booking_id}") + + assert response.status_code == 404 + assert "detail" in response.json() + mock_delete_booking.assert_called_once_with(ANY, booking_id) + + +@pytest.mark.asyncio +async def test_delete_booking_database_error( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of database errors in DELETE /bookings/{booking_id}. + + Verifies that the endpoint returns a 500 status on database failure. + """ + booking_id = 1 + with patch( + "backend.routers.bookings.delete_booking", new=AsyncMock() + ) as mock_delete_booking: + mock_delete_booking.side_effect = SQLAlchemyError() + + response = client.delete(f"/bookings/{booking_id}") + + assert response.status_code == 500 + assert "detail" in response.json() + mock_delete_booking.assert_called_once_with(ANY, booking_id)