diff --git a/backend/tests/services/test_invitees.py b/backend/tests/services/test_invitees.py new file mode 100644 index 00000000..10530762 --- /dev/null +++ b/backend/tests/services/test_invitees.py @@ -0,0 +1,220 @@ +"""Unit tests for the backend.services.invitees module.""" + +import logging +from collections.abc import Generator +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from sqlalchemy import delete +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.models import Invitee +from backend.models import User +from backend.services.invitees import UserList +from backend.services.invitees import add_invitee_to_booking +from backend.services.invitees import get_invitees_for_booking +from backend.services.invitees import remove_invitee_from_booking + +from ..conftest import MockLogger + + +@pytest.fixture(autouse=True) +def mock_logger() -> Generator[MockLogger, None, None]: + """Fixture to mock the logger used in the invitees service.""" + mock_logger_instance = MagicMock(spec=logging.Logger) + with patch("backend.services.invitees.logger", mock_logger_instance): + yield mock_logger_instance + + +@pytest.fixture +def sample_invitees() -> UserList: + """Fixture to provide sample User objects for testing invitees.""" + return [ + User(email="user1@example.com", name="User One"), + User(email="user2@example.com", name="User Two"), + ] + + +@pytest.mark.asyncio +async def test_get_invitees_for_booking_success( + async_session: AsyncSession, sample_invitees: UserList, mock_logger: MockLogger +) -> None: + """Test successful retrieval of all invitees for a booking. + + Verifies that get_invitees_for_booking returns the expected list of users and constructs + the correct SQLAlchemy query. + """ + booking_id = 1 + mock_scalars_result = AsyncMock() + mock_scalars_result.all = MagicMock(return_value=sample_invitees) + mock_scalars = AsyncMock(return_value=mock_scalars_result) + async_session.scalars = mock_scalars + + result: UserList = await get_invitees_for_booking(async_session, booking_id) + + assert isinstance(result, list) + assert len(result) == 2 + assert result == sample_invitees + async_session.scalars.assert_called_once() + assert async_session.scalars.call_args.args[0].compare( + select(Invitee.user).where(Invitee.booking_id == booking_id) + ) + + +@pytest.mark.asyncio +async def test_get_invitees_for_booking_empty( + async_session: AsyncSession, mock_logger: MockLogger +) -> None: + """Test retrieval of invitees when none exist for the booking. + + Verifies that get_invitees_for_booking returns an empty list when no invitees are found. + """ + booking_id = 1 + mock_scalars_result = AsyncMock() + mock_scalars_result.all = MagicMock(return_value=[]) + mock_scalars = AsyncMock(return_value=mock_scalars_result) + async_session.scalars = mock_scalars + + result: UserList = await get_invitees_for_booking(async_session, booking_id) + + assert isinstance(result, list) + assert len(result) == 0 + async_session.scalars.assert_called_once() + assert async_session.scalars.call_args.args[0].compare( + select(Invitee.user).where(Invitee.booking_id == booking_id) + ) + + +@pytest.mark.asyncio +async def test_get_invitees_for_booking_database_error( + async_session: AsyncSession, mock_logger: MockLogger +) -> None: + """Test handling of database errors in get_invitees_for_booking. + + Verifies that get_invitees_for_booking raises an exception on database failure. + """ + booking_id = 1 + async_session.scalars = AsyncMock(side_effect=SQLAlchemyError("Database error")) + + with pytest.raises(SQLAlchemyError): + await get_invitees_for_booking(async_session, booking_id) + async_session.scalars.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "booking_id, user_email", + [ + (1, "user1@example.com"), + (2, "user2@example.com"), + ], + ids=["invitee1", "invitee2"], +) +async def test_add_invitee_to_booking_success( + async_session: AsyncSession, + booking_id: int, + user_email: str, + mock_logger: MockLogger, +) -> None: + """Test successful addition of an invitee to a booking. + + Verifies that add_invitee_to_booking adds the invitee, commits the session, + and returns the invitee. + """ + invitee = Invitee(booking_id=booking_id, user_email=user_email) + async_session.add = MagicMock() + async_session.commit = AsyncMock() + + result: Invitee = await add_invitee_to_booking( + async_session, booking_id, user_email + ) + + assert result == invitee + assert result.booking_id == booking_id + assert result.user_email == user_email + async_session.add.assert_called_once_with(invitee) + async_session.commit.assert_called_once() + + +@pytest.mark.asyncio +async def test_add_invitee_to_booking_database_error( + async_session: AsyncSession, mock_logger: MockLogger +) -> None: + """Test handling of database errors in add_invitee_to_booking. + + Verifies that add_invitee_to_booking rolls back the session on database failure. + """ + booking_id = 1 + user_email = "user1@example.com" + invitee = Invitee(booking_id=booking_id, user_email=user_email) + async_session.add = MagicMock() + async_session.commit = AsyncMock(side_effect=SQLAlchemyError("Database error")) + async_session.rollback = AsyncMock() + + with pytest.raises(SQLAlchemyError): + await add_invitee_to_booking(async_session, booking_id, user_email) + async_session.add.assert_called_once_with(invitee) + async_session.commit.assert_called_once() + async_session.rollback.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "booking_id, user_email", + [ + (1, "user1@example.com"), + (2, "user2@example.com"), + ], + ids=["invitee1", "invitee2"], +) +async def test_remove_invitee_from_booking_success( + async_session: AsyncSession, + booking_id: int, + user_email: str, + mock_logger: MockLogger, +) -> None: + """Test successful removal of an invitee from a booking. + + Verifies that remove_invitee_from_booking deletes the invitee and commits the session. + """ + mock_execute_result = MagicMock(rowcount=1) + async_session.execute = AsyncMock(return_value=mock_execute_result) + async_session.commit = AsyncMock() + + await remove_invitee_from_booking(async_session, booking_id, user_email) + + async_session.execute.assert_called_once() + assert async_session.execute.call_args.args[0].compare( + delete(Invitee).where( + Invitee.booking_id == booking_id, Invitee.user_email == user_email + ) + ) + async_session.commit.assert_called_once() + + +@pytest.mark.asyncio +async def test_remove_invitee_from_booking_database_error( + async_session: AsyncSession, mock_logger: MockLogger +) -> None: + """Test handling of database errors in remove_invitee_from_booking. + + Verifies that remove_invitee_from_booking rolls back the session on database failure. + """ + booking_id = 1 + user_email = "user1@example.com" + async_session.execute = AsyncMock(side_effect=SQLAlchemyError("Database error")) + async_session.rollback = AsyncMock() + + with pytest.raises(SQLAlchemyError): + await remove_invitee_from_booking(async_session, booking_id, user_email) + async_session.execute.assert_called_once() + assert async_session.execute.call_args.args[0].compare( + delete(Invitee).where( + Invitee.booking_id == booking_id, Invitee.user_email == user_email + ) + ) + async_session.rollback.assert_called_once()