Files
conference-room-booking-system/backend/tests/services/test_rooms.py
2025-10-01 15:23:35 -04:00

584 lines
20 KiB
Python

"""Unit tests for backend.services.rooms.
This test module covers all CRUD service logic for Room objects, including:
- Retrieval of all rooms and single rooms
- Creation, update, and deletion of rooms
- Error handling for not found and database exceptions
Style conventions:
- Google-style docstrings with "Asserts:" sections
- All test data provided via fixtures from conftest.py
- Constants used for error messages and status codes
- Parameterized tests for error scenarios and data variations
- Consistent blank lines and organized imports
- Explicit type annotations for all function signatures
All test data is managed through fixtures in conftest.py for maintainability and reuse.
"""
# Standard library imports
from typing import Any
from typing import Callable
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
# Third-party imports
import pytest
from sqlalchemy import delete
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
# Local imports
from backend.models import Room
from backend.models import RoomList
from backend.services.rooms import delete_room
from backend.services.rooms import get_room
from backend.services.rooms import get_rooms
from backend.services.rooms import new_room
from backend.services.rooms import update_room
# Error message constants
ROOM_NOT_FOUND_MSG = "Room not found"
ROOM_DB_ERROR_MSG = "Database error"
# Not found ID constant for tests
ROOM_NOT_FOUND_ID = 999
@pytest.mark.asyncio
async def test_get_rooms_returns_all_rooms(
async_session: AsyncSession, sample_rooms: RoomList
) -> None:
"""Test successful retrieval of all rooms from the database.
Args:
async_session: The asynchronous database session.
sample_rooms: The sample list of Room objects.
Asserts:
- result is a list matching sample_rooms
- async_session.scalars called once with ``select(Room)``
"""
mock_scalars_result = AsyncMock()
mock_scalars_result.all = MagicMock(return_value=sample_rooms)
mock_scalars = AsyncMock(return_value=mock_scalars_result)
async_session.scalars = mock_scalars # type: ignore [method-assign]
result: RoomList = await get_rooms(async_session)
assert isinstance(result, list)
assert len(result) == 2
assert result == sample_rooms
async_session.scalars.assert_called_once()
assert async_session.scalars.call_args.args[0].compare(select(Room))
@pytest.mark.asyncio
async def test_get_rooms_returns_empty_list(async_session: AsyncSession) -> None:
"""Test retrieval of rooms when the database is empty.
Args:
async_session: The asynchronous database session.
Asserts:
- result is an empty list
- async_session.scalars called once with ``select(Room)``
"""
mock_scalars_result = AsyncMock()
mock_scalars_result.all = MagicMock(return_value=[])
mock_scalars = AsyncMock(return_value=mock_scalars_result)
async_session.scalars = mock_scalars # type: ignore [method-assign]
result: RoomList = await get_rooms(async_session)
assert isinstance(result, list)
assert len(result) == 0
async_session.scalars.assert_called_once()
assert async_session.scalars.call_args.args[0].compare(select(Room))
class DummyError(Exception):
"""Custom exception for generic error branch coverage in service tests."""
pass
# Parametrized test for get_room and delete_room error cases
@pytest.mark.asyncio
@pytest.mark.parametrize(
"service_func, session_attr, arg, exc_type",
[
(get_room, "scalar", "sample_room", SQLAlchemyError),
(
get_room,
"scalar",
"sample_room",
DummyError,
), # custom exception for coverage
(delete_room, "execute", "sample_room", SQLAlchemyError),
],
ids=["get_room_sqlalchemy", "get_room_dummy", "delete_room_sqlalchemy"],
)
async def test_get_room_and_delete_room_database_error(
async_session: AsyncSession,
service_func: Callable[[AsyncSession, int], Any],
session_attr: str,
arg: str,
exc_type: type[Exception],
request: pytest.FixtureRequest,
) -> None:
"""Test error handling and finally block coverage for get_room and delete_room.
Args:
async_session: The asynchronous database session fixture.
service_func: The service function to test (get_room or delete_room).
session_attr: The session method to mock ("scalar" or "execute").
arg: The fixture name for the room object.
exc_type: The exception type to raise.
request: The pytest request object for fixture access.
Asserts:
- Exception of exc_type is raised.
- Session method is called once.
- Rollback is called once for delete_room.
- Commit is not called for delete_room on error.
- Logger debug is called (finally block).
"""
obj = request.getfixturevalue(arg)
room_id = obj.id
setattr(
async_session,
session_attr,
AsyncMock(side_effect=exc_type("fail")),
)
if service_func is delete_room:
async_session.rollback = AsyncMock() # type: ignore [method-assign]
async_session.commit = AsyncMock() # type: ignore [method-assign]
with patch("backend.services.rooms.logger.debug") as mock_debug:
with pytest.raises(exc_type):
await service_func(async_session, room_id)
assert mock_debug.called
session_method = getattr(async_session, session_attr)
session_method.assert_called_once()
if service_func is delete_room:
assert isinstance(async_session.rollback, AsyncMock)
async_session.rollback.assert_called_once()
assert session_method.call_args.args[0].compare(
delete(Room).where(Room.id == room_id)
)
if isinstance(async_session.commit, AsyncMock):
assert async_session.commit.call_count == 0
# Parametrized test for new_room error case
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sample_room, exc_type",
[
("sample_room", SQLAlchemyError),
("sample_room", DummyError), # custom exception for coverage
],
indirect=["sample_room"],
ids=["sqlalchemy_error", "dummy_error"],
)
async def test_new_room_database_error(
async_session: AsyncSession, sample_room: Room, exc_type: type[Exception]
) -> None:
"""Test error handling and finally block coverage for new_room.
Args:
async_session: The asynchronous database session fixture.
sample_room: The sample Room object fixture.
exc_type: The exception type to raise.
Asserts:
- Exception of exc_type is raised.
- add called once with sample_room.
- commit called once.
- rollback called once.
- Logger debug is called (finally block).
"""
async_session.add = MagicMock() # type: ignore [method-assign]
async_session.commit = AsyncMock( # type: ignore [method-assign]
side_effect=exc_type("fail")
)
async_session.rollback = AsyncMock() # type: ignore [method-assign]
with patch("backend.services.rooms.logger.debug") as mock_debug:
with pytest.raises(exc_type):
await new_room(async_session, sample_room)
assert mock_debug.called
async_session.add.assert_called_once_with(sample_room)
async_session.commit.assert_called_once()
async_session.rollback.assert_called_once()
# Parametrized test for update_room error case
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sample_room, room_update_data",
[("sample_room", "room_update_data")],
indirect=True,
)
async def test_update_room_database_error_param(
async_session: AsyncSession, sample_room: Room, room_update_data: dict[str, Any]
) -> None:
"""Test handling of database errors for update_room using fixtures.
Args:
async_session: The asynchronous database session.
sample_room: The sample Room object fixture.
room_update_data: The update data for the room.
Asserts:
- SQLAlchemyError is raised
- async_session.execute called once
- async_session.rollback called once
"""
async_session.execute = AsyncMock( # type: ignore [method-assign]
side_effect=SQLAlchemyError(ROOM_DB_ERROR_MSG)
)
async_session.rollback = AsyncMock() # type: ignore [method-assign]
with pytest.raises(SQLAlchemyError):
await update_room(async_session, sample_room.id, **room_update_data)
async_session.execute.assert_called_once()
async_session.rollback.assert_called_once()
# Parametrized test for get_room_success (for all sample_rooms)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sample_room",
[
pytest.param(room, id=f"room_{room.id}")
for room in [
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,
),
]
],
indirect=True,
)
async def test_get_room_returns_room(
async_session: AsyncSession, sample_room: Room
) -> None:
"""Test successful retrieval of a room by ID using fixture data.
Args:
async_session: The asynchronous database session.
sample_room: The sample Room object fixture.
Asserts:
- result matches sample_room
- async_session.scalar called once with
``select(Room).where(Room.id == sample_room.id)``
"""
async_session.scalar = AsyncMock( # type: ignore [method-assign]
return_value=sample_room
)
result: Room = await get_room(async_session, sample_room.id)
assert result == sample_room
async_session.scalar.assert_called_once()
assert async_session.scalar.call_args.args[0].compare(
select(Room).where(Room.id == sample_room.id)
)
@pytest.mark.asyncio
async def test_get_room_returns_room_fixture(
async_session: AsyncSession, sample_room: Room
) -> None:
"""Test successful retrieval of a room by ID using fixture data.
Args:
async_session: The asynchronous database session.
sample_room: The sample Room object fixture.
Asserts:
- result matches sample_room
- async_session.scalar called once with
``select(Room).where(Room.id == sample_room.id)``
"""
async_session.scalar = AsyncMock(return_value=sample_room) # type: ignore [method-assign]
result: Room = await get_room(async_session, sample_room.id)
assert result == sample_room
async_session.scalar.assert_called_once()
assert async_session.scalar.call_args.args[0].compare(
select(Room).where(Room.id == sample_room.id)
)
"""Test handling of database errors in new_room.
Verifies that new_room rolls back the session on database failure.
Args:
async_session: The asynchronous database session.
Asserts:
- SQLAlchemyError is raised
- async_session.add called once with room
- async_session.commit called once
- async_session.rollback called once
"""
async_session.add = MagicMock() # type: ignore [method-assign]
async_session.commit = AsyncMock( # type: ignore [method-assign]
side_effect=SQLAlchemyError(ROOM_DB_ERROR_MSG)
)
async_session.rollback = AsyncMock() # type: ignore [method-assign]
with pytest.raises(SQLAlchemyError):
await new_room(async_session, sample_room)
async_session.add.assert_called_once_with(sample_room)
async_session.commit.assert_called_once()
async_session.rollback.assert_called_once()
@pytest.mark.asyncio
async def test_update_room_returns_updated_room(
async_session: AsyncSession,
sample_room: Room,
room_update_data: dict[str, Any],
updated_room: Room,
) -> None:
"""Test successful update of a room.
Args:
async_session: The asynchronous database session.
sample_room: The sample Room object fixture.
room_update_data: The update data for the room.
updated_room: The expected updated Room object.
Asserts:
- result matches updated_room
- async_session.execute called once with
``update(Room).where(Room.id == room_id).values(**update_params)``
- async_session.commit called once
- async_session.scalar called once with ``select(Room).where(Room.id == room_id)``
"""
room_id = sample_room.id
update_params = room_update_data
mock_execute_result = MagicMock(rowcount=1)
async_session.execute = AsyncMock( # type: ignore [method-assign]
return_value=mock_execute_result
)
async_session.commit = AsyncMock() # type: ignore [method-assign]
async_session.scalar = AsyncMock( # type: ignore [method-assign]
return_value=updated_room
)
result: Room = await update_room(async_session, room_id, **update_params)
assert result == updated_room
async_session.execute.assert_called_once()
assert async_session.execute.call_args.args[0].compare(
update(Room).where(Room.id == room_id).values(**update_params)
)
async_session.commit.assert_called_once()
async_session.scalar.assert_called_once()
assert async_session.scalar.call_args.args[0].compare(
select(Room).where(Room.id == room_id)
)
@pytest.mark.asyncio
async def test_update_room_raises_not_found(async_session: AsyncSession) -> None:
"""Test handling of non-existent room in update_room.
Verifies that update_room raises ValueError when the room is not found.
Args:
async_session: The asynchronous database session.
Asserts:
- ValueError is raised
- async_session.execute called once with
``update(Room).where(Room.id == room_id).values(**update_params)``
- async_session.rollback called once
"""
room_id = ROOM_NOT_FOUND_ID
update_params: dict[str, Any] = {
"name": "Updated Room",
"location": "Building 2",
"equipment": "Whiteboard",
"capacity": 15,
}
mock_execute_result = MagicMock(rowcount=0)
async_session.execute = AsyncMock( # type: ignore [method-assign]
return_value=mock_execute_result
)
async_session.rollback = AsyncMock() # type: ignore [method-assign]
with pytest.raises(ValueError):
await update_room(async_session, room_id, **update_params)
async_session.execute.assert_called_once()
assert async_session.execute.call_args.args[0].compare(
update(Room).where(Room.id == room_id).values(**update_params)
)
async_session.rollback.assert_called_once()
@pytest.mark.asyncio
async def test_update_room_database_error(
async_session: AsyncSession, sample_room: Room, room_update_data: dict[str, Any]
) -> None:
"""Test handling of database errors in update_room.
Args:
async_session: The asynchronous database session.
sample_room: The sample Room object fixture.
room_update_data: The update data for the room.
Asserts:
- SQLAlchemyError is raised
- async_session.execute called once
- async_session.rollback called once
"""
room_id = sample_room.id
update_params = room_update_data
async_session.execute = AsyncMock( # type: ignore [method-assign]
side_effect=SQLAlchemyError(ROOM_DB_ERROR_MSG)
)
async_session.rollback = AsyncMock() # type: ignore [method-assign]
with pytest.raises(SQLAlchemyError):
await update_room(async_session, room_id, **update_params)
async_session.execute.assert_called_once()
assert async_session.execute.call_args.args[0].compare(
update(Room).where(Room.id == room_id).values(**update_params)
)
async_session.rollback.assert_called_once()
@pytest.mark.asyncio
async def test_delete_room_commits_success(
async_session: AsyncSession, sample_room: Room
) -> None:
"""Test successful deletion of a room.
Args:
async_session: The asynchronous database session.
sample_room: The sample Room object fixture.
Asserts:
- async_session.execute called once with ``delete(Room).where(Room.id == room_id)``
- async_session.commit called once
"""
room_id = sample_room.id
mock_execute_result = MagicMock(rowcount=1)
async_session.execute = AsyncMock( # type: ignore [method-assign]
return_value=mock_execute_result
)
async_session.commit = AsyncMock() # type: ignore [method-assign]
await delete_room(async_session, room_id)
async_session.execute.assert_called_once()
assert async_session.execute.call_args.args[0].compare(
delete(Room).where(Room.id == room_id)
)
async_session.commit.assert_called_once()
@pytest.mark.asyncio
async def test_delete_room_raises_not_found(async_session: AsyncSession) -> None:
"""Test handling of non-existent room in delete_room.
Verifies that delete_room raises ValueError when the room is not found.
Args:
async_session: The asynchronous database session.
Asserts:
- ValueError is raised
- async_session.execute called once with ``delete(Room).where(Room.id == room_id)``
- async_session.rollback called once
"""
room_id = ROOM_NOT_FOUND_ID
mock_execute_result = MagicMock(rowcount=0)
async_session.execute = AsyncMock( # type: ignore [method-assign]
return_value=mock_execute_result
)
async_session.rollback = AsyncMock() # type: ignore [method-assign]
with pytest.raises(ValueError):
await delete_room(async_session, room_id)
async_session.execute.assert_called_once()
assert async_session.execute.call_args.args[0].compare(
delete(Room).where(Room.id == room_id)
)
async_session.rollback.assert_called_once()
# Patch logger.debug for finally block coverage using correct import path
LOGGER_PATH = "backend.services.rooms.logger.debug"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"service_func, args, session_attr",
[
(get_rooms, (), "scalars"),
(get_room, (ROOM_NOT_FOUND_ID,), "scalar"),
(
new_room,
(Room(id=999, name="fail", location="", equipment="", capacity=0),),
"commit",
),
],
)
async def test_services_rooms_finally_and_exception_logging(
async_session: AsyncSession,
service_func: Callable[..., Any],
args: tuple[Any, ...],
session_attr: str,
) -> None:
"""Test that finally blocks and generic exception logging are covered.
Args:
async_session: The asynchronous database session.
service_func: The service function to test.
args: The arguments to pass to the service function.
session_attr: The session method to mock.
Asserts:
- Exception is raised
- Logger error is called
- Logger debug is called
"""
with patch(LOGGER_PATH) as mock_debug, patch(
"backend.services.rooms.logger.error"
) as mock_error, patch("backend.services.rooms.logger.info"):
# Patch session methods to raise Exception
if service_func is get_rooms:
setattr(
async_session, session_attr, AsyncMock(side_effect=Exception("fail"))
)
elif service_func is get_room:
setattr(
async_session, session_attr, AsyncMock(side_effect=Exception("fail"))
)
elif service_func is new_room:
async_session.add = MagicMock()
async_session.commit = AsyncMock(side_effect=Exception("fail"))
async_session.rollback = AsyncMock()
with pytest.raises(Exception): # noqa: B017
await service_func(async_session, *args)
# Check that error and debug logging were called
assert mock_error.called
assert mock_debug.called