diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index bc785449..8985ad00 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -38,9 +38,3 @@ app.include_router(bookings.router) app.include_router(invitees.router) setup_logging(app) - - -@app.get("/") -async def root(): - """Root endpoint to verify the service is running.""" - return {"message": "Welcome to the Numinar Coding Project Backend!"} diff --git a/backend/src/backend/models.py b/backend/src/backend/models.py index 9a8ef84b..5b51f759 100644 --- a/backend/src/backend/models.py +++ b/backend/src/backend/models.py @@ -41,7 +41,7 @@ class Room(Base): __tablename__ = "rooms" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(index=True, unique=True) location: Mapped[str] equipment: Mapped[str] diff --git a/backend/src/backend/routers/rooms.py b/backend/src/backend/routers/rooms.py index f7fa0e4d..da19649b 100644 --- a/backend/src/backend/routers/rooms.py +++ b/backend/src/backend/routers/rooms.py @@ -56,7 +56,7 @@ async def read_room(room_id: int, session: DBSession): room = await get_room(session, room_id) logger.info(f"Successfully retrieved room with id: {room_id}") return room - except NoResultFound as e: + except (NoResultFound, ValueError) as e: logger.warning(f"Room with id {room_id} not found") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -113,7 +113,7 @@ async def update_existing_room( updated_room = await update_room(session, room_id, **room_params) logger.info(f"Successfully updated room with id: {room_id}") return updated_room - except NoResultFound as e: + except (NoResultFound, ValueError) as e: logger.warning(f"Room with id {room_id} not found") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -141,7 +141,7 @@ async def delete_existing_room(room_id: int, session: DBSession): try: await delete_room(session, room_id) logger.info(f"Successfully deleted room with id: {room_id}") - except NoResultFound as e: + except (NoResultFound, ValueError) as e: logger.warning(f"Room with id {room_id} not found") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/src/backend/schemas/rooms.py b/backend/src/backend/schemas/rooms.py index 111e46ff..b3354e26 100644 --- a/backend/src/backend/schemas/rooms.py +++ b/backend/src/backend/schemas/rooms.py @@ -7,6 +7,7 @@ from pydantic import ConfigDict class RoomBase(BaseModel): """Base schema for Room.""" + id: int name: str location: str equipment: str @@ -22,6 +23,7 @@ class RoomCreate(RoomBase): class RoomUpdate(BaseModel): """Schema for updating an existing Room.""" + id: int | None = None name: str | None = None location: str | None = None equipment: str | None = None @@ -31,8 +33,6 @@ class RoomUpdate(BaseModel): class RoomResponse(RoomBase): - """Response schema for Room, including ID.""" - - id: int + """Response schema for Room.""" model_config = ConfigDict(from_attributes=True) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ca431611..c240813c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -4,8 +4,11 @@ from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest +from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import AsyncSession +from backend.main import app + type MockLogger = MagicMock @@ -14,3 +17,9 @@ type MockLogger = MagicMock def async_session() -> AsyncSession: """Fixture to provide a mocked AsyncSession for database interactions.""" return AsyncMock(spec=AsyncSession) + + +@pytest.fixture +def client() -> TestClient: + """Fixture to provide a FastAPI TestClient for testing endpoints.""" + return TestClient(app) diff --git a/backend/tests/routers/test_rooms.py b/backend/tests/routers/test_rooms.py new file mode 100644 index 00000000..2eb01ed3 --- /dev/null +++ b/backend/tests/routers/test_rooms.py @@ -0,0 +1,264 @@ +"""Unit tests for the backend.routers.rooms module.""" + +import logging +from collections.abc import Generator +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 backend.models import Room +from backend.services.rooms import RoomList + +from ..conftest import MockLogger + + +@pytest.fixture(autouse=True) +def mock_logger() -> Generator[MockLogger, None, None]: + """Fixture to mock the logger used in the rooms service.""" + mock_logger_instance = MagicMock(spec=logging.Logger) + with patch("backend.routers.rooms.logger", mock_logger_instance): + yield mock_logger_instance + + +@pytest.fixture +def sample_rooms() -> RoomList: + """Fixture to provide sample Room objects for testing.""" + return [ + Room( + id=1, + name="Room 1", + location="Building A", + equipment="Projector", + capacity=10, + ), + Room( + id=2, + name="Room 2", + location="Building B", + equipment="Whiteboard", + capacity=15, + ), + ] + + +@pytest.mark.asyncio +async def test_get_rooms_success( + client: TestClient, sample_rooms: RoomList, mock_logger: MockLogger +) -> None: + """Test successful retrieval of all rooms via GET /rooms/. + + Verifies that the endpoint returns the expected list of rooms with a 200 status. + """ + with patch("backend.routers.rooms.get_rooms", new=AsyncMock()) as mock_get_rooms: + mock_get_rooms.return_value = sample_rooms + + response = client.get("/rooms/") + + assert response.status_code == 200 + assert response.json() == [ + { + "id": 1, + "name": "Room 1", + "location": "Building A", + "equipment": "Projector", + "capacity": 10, + }, + { + "id": 2, + "name": "Room 2", + "location": "Building B", + "equipment": "Whiteboard", + "capacity": 15, + }, + ] + mock_get_rooms.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_rooms_empty(client: TestClient, mock_logger: MockLogger) -> None: + """Test retrieval of rooms when none exist via GET /rooms/. + + Verifies that the endpoint returns an empty list with a 200 status. + """ + with patch("backend.routers.rooms.get_rooms", new=AsyncMock()) as mock_get_rooms: + mock_get_rooms.return_value = [] + + response = client.get("/rooms/") + + assert response.status_code == 200 + assert response.json() == [] + mock_get_rooms.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_room_success(client: TestClient, mock_logger: MockLogger) -> None: + """Test successful retrieval of a room by ID via GET /rooms/{room_id}. + + Verifies that the endpoint returns the correct room with a 200 status. + """ + room_id = 1 + room = Room( + id=room_id, + name="Room 1", + location="Building A", + equipment="Projector", + capacity=10, + ) + with patch("backend.routers.rooms.get_room", new=AsyncMock()) as mock_get_room: + mock_get_room.return_value = room + + response = client.get(f"/rooms/{room_id}") + + assert response.status_code == 200 + assert response.json() == { + "id": room_id, + "name": "Room 1", + "location": "Building A", + "equipment": "Projector", + "capacity": 10, + } + mock_get_room.assert_called_once_with(ANY, room_id) + + +@pytest.mark.asyncio +async def test_get_room_not_found(client: TestClient, mock_logger: MockLogger) -> None: + """Test handling of non-existent room in GET /rooms/{room_id}. + + Verifies that the endpoint returns a 404 status when the room is not found. + """ + room_id = 999 + with patch("backend.routers.rooms.get_room", new=AsyncMock()) as mock_get_room: + mock_get_room.side_effect = NoResultFound(f"Room with id {room_id} not found") + + response = client.get(f"/rooms/{room_id}") + + assert response.status_code == 404 + assert "detail" in response.json() + mock_get_room.assert_called_once_with(ANY, room_id) + + +@pytest.mark.asyncio +async def test_new_room_success(client: TestClient, mock_logger: MockLogger) -> None: + """Test successful creation of a new room via POST /rooms/. + + Verifies that the endpoint returns the created room with a 201 status. + """ + room_data: dict[str, Any] = { + "id": 3, + "name": "Room 3", + "location": "Building C", + "equipment": "TV", + "capacity": 20, + } + created_room = Room(**room_data) + with patch("backend.routers.rooms.new_room", new=AsyncMock()) as mock_new_room: + mock_new_room.return_value = created_room + + response = client.post("/rooms/", json=room_data) + + assert response.status_code == 201 + assert response.json() == room_data + mock_new_room.assert_called_once() + + +@pytest.mark.asyncio +async def test_update_room_success(client: TestClient, mock_logger: MockLogger) -> None: + """Test successful update of a room via PUT /rooms/{room_id}. + + Verifies that the endpoint returns the updated room with a 200 status. + """ + room_id = 1 + update_data: dict[str, Any] = { + "name": "Updated Room", + "location": "Building D", + "equipment": "Monitor", + "capacity": 25, + } + updated_room = Room(id=room_id, **update_data) + with patch( + "backend.routers.rooms.update_room", new=AsyncMock() + ) as mock_update_room: + mock_update_room.return_value = updated_room + + response = client.put(f"/rooms/{room_id}", json=update_data) + + assert response.status_code == 200 + assert response.json() == { + "id": room_id, + "name": "Updated Room", + "location": "Building D", + "equipment": "Monitor", + "capacity": 25, + } + mock_update_room.assert_called_once_with(ANY, room_id, **update_data) + + +@pytest.mark.asyncio +async def test_update_room_not_found( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of non-existent room in PUT /rooms/{room_id}. + + Verifies that the endpoint returns a 404 status when the room is not found. + """ + room_id = 999 + update_data: dict[str, Any] = { + "name": "Updated Room", + "location": "Building D", + "equipment": "Monitor", + "capacity": 25, + } + with patch( + "backend.routers.rooms.update_room", new=AsyncMock() + ) as mock_update_room: + mock_update_room.side_effect = ValueError() + + response = client.put(f"/rooms/{room_id}", json=update_data) + + assert response.status_code == 404 + assert "detail" in response.json() + mock_update_room.assert_called_once_with(ANY, room_id, **update_data) + + +@pytest.mark.asyncio +async def test_delete_room_success(client: TestClient, mock_logger: MockLogger) -> None: + """Test successful deletion of a room via DELETE /rooms/{room_id}. + + Verifies that the endpoint returns a 204 status on successful deletion. + """ + room_id = 1 + with patch( + "backend.routers.rooms.delete_room", new=AsyncMock() + ) as mock_delete_room: + response = client.delete(f"/rooms/{room_id}") + + assert response.status_code == 204 + assert response.text == "" + mock_delete_room.assert_called_once_with(ANY, room_id) + + +@pytest.mark.asyncio +async def test_delete_room_not_found( + client: TestClient, mock_logger: MockLogger +) -> None: + """Test handling of non-existent room in DELETE /rooms/{room_id}. + + Verifies that the endpoint returns a 404 status when the room is not found. + """ + room_id = 999 + with patch( + "backend.routers.rooms.delete_room", new=AsyncMock() + ) as mock_delete_room: + mock_delete_room.side_effect = ValueError() + + response = client.delete(f"/rooms/{room_id}") + + assert response.status_code == 404 + assert "detail" in response.json() + mock_delete_room.assert_called_once_with(ANY, room_id) diff --git a/backend/tests/routers/test_users.py b/backend/tests/routers/test_users.py index c905441c..62f28695 100644 --- a/backend/tests/routers/test_users.py +++ b/backend/tests/routers/test_users.py @@ -12,7 +12,6 @@ from fastapi.testclient import TestClient from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import SQLAlchemyError -from backend.main import app from backend.models import User from backend.services.users import UserList @@ -36,12 +35,6 @@ def sample_users() -> UserList: ] -@pytest.fixture -def client() -> TestClient: - """Fixture to provide a FastAPI TestClient for testing endpoints.""" - return TestClient(app) - - @pytest.mark.asyncio async def test_read_users_success( client: TestClient, sample_users: UserList, mock_logger: MockLogger @@ -88,12 +81,12 @@ async def test_read_users_database_error( Verifies that the endpoint returns a 500 status on database failure. """ with patch("backend.routers.users.get_users", new=AsyncMock()) as mock_get_users: - mock_get_users.side_effect = SQLAlchemyError("Database error") + mock_get_users.side_effect = SQLAlchemyError() response = client.get("/users/") assert response.status_code == 500 - assert response.json() == {"detail": "Database error occurred"} + assert "detail" in response.json() mock_get_users.assert_called_once() @@ -138,12 +131,12 @@ async def test_read_user_not_found( Verifies that the endpoint returns a 404 status when the user is not found. """ with patch("backend.routers.users.get_user", new=AsyncMock()) as mock_get_user: - mock_get_user.side_effect = NoResultFound(f"User with email {email} not found") + mock_get_user.side_effect = NoResultFound() response = client.get(f"/users/{email}") assert response.status_code == 404 - assert response.json() == {"detail": f"User with email {email} not found"} + assert "detail" in response.json() mock_get_user.assert_called_once_with(ANY, email) @@ -157,10 +150,10 @@ async def test_read_user_database_error( """ email = "user1@example.com" with patch("backend.routers.users.get_user", new=AsyncMock()) as mock_get_user: - mock_get_user.side_effect = SQLAlchemyError("Database error") + mock_get_user.side_effect = SQLAlchemyError() response = client.get(f"/users/{email}") assert response.status_code == 500 - assert response.json() == {"detail": "Database error occurred"} + assert "detail" in response.json() mock_get_user.assert_called_once_with(ANY, email)