Files
conference-room-booking-system/backend/tests/services/test_bookings.py
2025-10-06 21:51:57 -04:00

1423 lines
51 KiB
Python

"""Unit tests for backend.services.bookings module.
These tests cover booking creation, update, deletion, constraints, and error handling.
All tests use Google-style docstrings, pytest conventions, and aim for maintainability.
Test coverage is maximized and code is kept readable and maintainable.
"""
import asyncio
import sys
from contextlib import ExitStack
from contextlib import contextmanager
from contextlib import suppress
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import Any
from typing import Awaitable
from typing import Callable
from typing import Iterator
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from sqlalchemy import delete
from sqlalchemy import update
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.models import Booking
from backend.models import Room
from backend.schemas.bookings import BookingCreate
from backend.schemas.bookings import BookingResponse
from backend.services.bookings import delete_booking
from backend.services.bookings import get_booking
from backend.services.bookings import get_bookings_for_month
from backend.services.bookings import new_booking
from backend.services.bookings import update_booking
from backend.services.bookings import validate_max_future
from backend.services.bookings import validate_no_overlap
from backend.services.bookings import validate_room_exists
from backend.services.bookings import validate_time_constraints
from backend.types import BookingData
from backend.types import BookingUpdateData
# Error message constants
BOOKING_NOT_FOUND_MSG = "Booking not found" # Booking not found error
BOOKING_DB_ERROR_MSG = "Database error" # Database error
BOOKING_CONFLICT_MSG = (
"Booking times overlap with an existing"
" booking for this room." # Booking overlap conflict
)
BOOKING_VALIDATION_MISSING_FIELD = (
"Invalid booking data: missing required field" # Validation error: missing field
)
BOOKING_VALIDATION_WRONG_TYPE = (
"Invalid booking data: wrong type" # Validation error: wrong type
)
# Suppress AsyncMock coroutine warnings globally for this test file
pytestmark = pytest.mark.filterwarnings(
"ignore:coroutine 'AsyncMockMixin._execute_mock_call' was never awaited:"
"RuntimeWarning"
)
def get_patch_targets(patches: list[str], booking: "Booking") -> dict[str, Any]:
"""Return patch targets and mock values for booking service dependencies.
Args:
patches: List of patch names to apply.
booking: The booking object to use for return values.
Returns:
Dict mapping patch target (str) to mock/new value.
Asserts:
- Dict contains correct patch targets and values for each patch type.
"""
patch_targets: dict[str, Any] = {}
if "config" in patches:
patch_targets["backend.services.bookings.config"] = getattr(
sys.modules["backend"], "config", MagicMock()
)
if "get_invitees_for_booking" in patches:
patch_targets["backend.services.bookings.get_invitees_for_booking"] = AsyncMock(
return_value=[]
)
if "get_booking" in patches:
patch_targets["backend.services.bookings.get_booking"] = AsyncMock(
return_value=booking
)
if "_validate_no_overlap" in patches:
patch_targets["backend.services.bookings._validate_no_overlap"] = AsyncMock(
return_value=None
)
if "_validate_room_exists" in patches:
patch_targets["backend.services.bookings._validate_room_exists"] = AsyncMock(
return_value=MagicMock(capacity=10)
)
return patch_targets
# Centralized patching helper
@contextmanager
def apply_patches(patch_targets: dict[str, Any]) -> Iterator[None]:
"""Context manager to apply multiple patches at once.
Args:
patch_targets: Dict mapping patch target (str) to mock/new value.
Yields:
None. All patches are active within the context.
"""
with ExitStack() as stack:
for target, new_value in patch_targets.items():
stack.enter_context(patch(target, new=new_value))
yield
# Helper to setup AsyncMock session methods for tests
def setup_session_mocks(session: Any, booking: Booking) -> None:
"""Setup AsyncMock methods for a session for booking tests.
Args:
session: The session object to mock.
booking: The booking object to use for return values.
Asserts:
- Session methods are set to AsyncMock and return expected values.
"""
session.commit = AsyncMock()
session.rollback = AsyncMock()
session.execute = AsyncMock(return_value=MagicMock(rowcount=1))
session.scalar = AsyncMock(return_value=booking)
async def await_all_asyncmock_methods(session: Any, method_names: list[str]) -> None:
"""Call and await every assigned AsyncMock session method to suppress warnings.
Args:
session: The session object containing AsyncMock methods.
method_names: List of method names to call and await.
Asserts:
- All AsyncMock methods in method_names are called and awaited if present.
"""
coros: list[Awaitable[Any]] = []
for name in method_names:
method = getattr(session, name, None)
if isinstance(method, AsyncMock):
with suppress(Exception):
coro = method()
if asyncio.iscoroutine(coro):
coros.append(coro)
if coros:
await asyncio.gather(*coros, return_exceptions=True)
# Removed unused helper and manual warning suppression
@pytest.mark.asyncio
@pytest.mark.parametrize(
"month,expected_count",
[
# Test with bookings in the current month
((datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m"), 2),
# Test with no bookings in the next month
((datetime.now(timezone.utc) + timedelta(days=32)).strftime("%Y-%m"), 0),
],
ids=[
"current_month_with_bookings",
"next_month_no_bookings",
],
)
@pytest.mark.asyncio
async def test_get_bookings_for_month_returns_bookings(
async_session: AsyncMock,
sample_bookings: list[Booking],
month: str,
expected_count: int,
) -> None:
"""Test get_bookings_for_month returns correct bookings and invitees.
Args:
async_session: Async database session fixture.
sample_bookings: Fixture providing sample bookings.
month: Month string to query (YYYY-MM).
expected_count: Expected number of bookings returned.
Asserts:
- Returned value is a list.
- List length matches expected_count.
- Each booking has an 'invitees' attribute if bookings exist.
"""
# Patch async_session.execute to return a mock result with .scalars().all()
# Patch async_session.execute to return a mock result with .scalars().all()
# and invitees
# Use sample_bookings fixture directly for test data
# Use the actual test param for the current month
current_month = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m")
scalars_mock_1 = MagicMock()
scalars_mock_1.all.return_value = sample_bookings if month == current_month else []
result_mock_1 = MagicMock()
result_mock_1.scalars.return_value = scalars_mock_1
# Use fixture data for invitees
invitees: list[MagicMock] = []
for booking in sample_bookings:
for email in booking.invitees:
invitees.append(MagicMock(booking_id=booking.id, user_email=email))
scalars_mock_2 = MagicMock()
scalars_mock_2.all.return_value = invitees
result_mock_2 = MagicMock()
result_mock_2.scalars.return_value = scalars_mock_2
async_session.execute = AsyncMock(side_effect=[result_mock_1, result_mock_2])
bookings = await get_bookings_for_month(async_session, month)
assert isinstance(bookings, list)
assert len(bookings) == expected_count
if bookings:
assert all(hasattr(b, "invitees") for b in bookings)
# Explicitly await all AsyncMock session methods to suppress warnings
for method_name in ["commit", "refresh", "scalar", "rollback"]:
method = getattr(async_session, method_name, None)
if method and hasattr(method, "await_count"):
with suppress(Exception):
await method()
@pytest.mark.asyncio
async def test_new_booking_success(
async_session: AsyncSession,
booking_create: BookingCreate,
sample_booking: Booking,
sample_room: Room,
) -> None:
"""Test new_booking returns the created booking matching the fixture.
Args:
async_session: Async database session fixture.
booking_create: Fixture providing booking creation data.
sample_booking: Fixture providing expected booking result.
sample_room: Fixture providing a sample room.
Asserts:
- new_booking returns a dict matching the booking_create data.
"""
async_session.execute = AsyncMock(return_value=MagicMock(rowcount=1)) # type: ignore[method-assign]
async_session.commit = AsyncMock() # type: ignore[method-assign]
async_session.rollback = AsyncMock() # type: ignore[method-assign]
async_session.scalar = AsyncMock(return_value=sample_room) # type: ignore[method-assign]
async_session.refresh = AsyncMock() # type: ignore[method-assign]
with (
patch(
"backend.services.bookings.get_room_service",
new=AsyncMock(return_value=sample_room),
),
patch(
"backend.services.bookings.validate_new_booking_room_exists",
new=AsyncMock(return_value=None),
),
patch(
"backend.services.bookings.validate_new_booking_no_overlap",
new=AsyncMock(return_value=None),
),
):
result = await new_booking(async_session, **booking_create.model_dump())
assert isinstance(result, dict)
assert result["room_id"] == booking_create.room_id
assert result["start_time"] == booking_create.start_time
assert result["end_time"] == booking_create.end_time
@pytest.mark.asyncio
async def test_update_booking_success(
async_session: AsyncSession,
booking_update_data: BookingUpdateData,
updated_booking: Booking,
event_publisher: AsyncMock,
sample_room: Room,
) -> None:
"""Test update_booking returns the updated booking matching the fixture.
Args:
async_session: Async database session fixture.
booking_update_data: Fixture providing booking update data (dict).
updated_booking: Fixture providing expected updated booking.
event_publisher: AsyncMock for event publishing.
sample_room: Fixture providing a sample room.
Asserts:
- update_booking returns a Booking matching the updated_booking fixture.
"""
async_session.execute = AsyncMock(return_value=MagicMock(rowcount=1)) # type: ignore[method-assign]
async_session.commit = AsyncMock() # type: ignore[method-assign]
async_session.rollback = AsyncMock() # type: ignore[method-assign]
async_session.scalar = AsyncMock(return_value=updated_booking) # type: ignore[method-assign]
async_session.refresh = AsyncMock() # type: ignore[method-assign]
with (
patch(
"backend.services.bookings.get_room_service",
new=AsyncMock(return_value=lambda *_: sample_room), # pyright: ignore
),
patch(
"backend.services.bookings.validate_no_overlap",
new=AsyncMock(return_value=None),
),
):
booking_id = updated_booking.id
result = await update_booking(
async_session,
booking_id,
**booking_update_data,
event_publisher=event_publisher,
)
assert isinstance(result, Booking)
assert result.id == updated_booking.id
assert result.room_id == updated_booking.room_id
assert result.start_time == updated_booking.start_time
assert result.end_time == updated_booking.end_time
@pytest.mark.asyncio
async def test_delete_booking_success(
async_session: AsyncSession,
sample_booking: Booking,
sample_room: Room,
) -> None:
"""Test delete_booking completes successfully for the given ID.
Args:
async_session: Async database session fixture.
sample_booking: Fixture providing booking to delete.
sample_room: Fixture providing a sample room.
Asserts:
- delete_booking completes successfully and commits.
"""
async_session.execute = AsyncMock(return_value=MagicMock(rowcount=1)) # type: ignore[method-assign]
async_session.commit = AsyncMock() # type: ignore[method-assign]
async_session.rollback = AsyncMock() # type: ignore[method-assign]
# Return sample_booking when scalar is called with the correct statement
async def scalar_side_effect(stmt: Any) -> Any:
# Simulate get_booking query
if hasattr(stmt, "where") and hasattr(stmt, "compare"):
# If the statement is a select for the correct booking ID, return sample_booking
if getattr(sample_booking, "id", None) is not None:
return sample_booking
return None
async_session.scalar = AsyncMock(side_effect=scalar_side_effect) # type: ignore[method-assign]
async_session.refresh = AsyncMock() # type: ignore[method-assign]
with patch(
"backend.services.bookings.get_room_service",
new=AsyncMock(return_value=sample_room),
):
booking_id = sample_booking.id
await delete_booking(async_session, booking_id)
async_session.commit.assert_called()
with pytest.raises(ValueError, match="The selected room does not exist."):
await validate_room_exists(async_session, 999)
@pytest.mark.parametrize(
"func, args, should_raise",
[
# validate_max_future
(
"validate_max_future",
(
datetime.now(timezone.utc) + timedelta(days=1),
datetime.now(timezone.utc) + timedelta(days=30),
datetime.now(timezone.utc),
12,
),
False,
), # valid: within max future
(
"validate_max_future",
(
datetime.now(timezone.utc) + timedelta(days=1),
datetime.now(timezone.utc) + timedelta(days=400),
datetime.now(timezone.utc),
12,
),
True,
), # invalid: exceeds max future
# validate_time_constraints
(
"validate_time_constraints",
(
datetime.now(timezone.utc) + timedelta(days=1),
datetime.now(timezone.utc) + timedelta(days=2),
datetime.now(timezone.utc),
12,
),
False,
), # valid: start < end, within max
(
"validate_time_constraints",
(
datetime.now(timezone.utc) - timedelta(days=2),
datetime.now(timezone.utc) - timedelta(days=1),
datetime.now(timezone.utc),
12,
),
True,
), # invalid: negative times (start < now)
(
"validate_time_constraints",
(
datetime.now(timezone.utc) + timedelta(days=2),
datetime.now(timezone.utc) + timedelta(days=1),
datetime.now(timezone.utc),
12,
),
True,
), # invalid: start > end
(
"validate_time_constraints",
(
datetime.now(timezone.utc) + timedelta(days=1),
datetime.now(timezone.utc) + timedelta(days=400),
datetime.now(timezone.utc),
12,
),
True,
), # invalid: exceeds max future
],
ids=[
"valid: within max future",
"invalid: exceeds max future",
"valid: start < end, within max",
"invalid: negative times (past)",
"invalid: start > end",
"invalid: exceeds max future",
],
)
def test_time_constraint_validators_param(
func: str,
args: tuple[Any, ...],
should_raise: bool,
) -> None:
"""Parametrized test for all time constraint validation helpers.
Args:
func: Name of the validation function to test.
args: Arguments for the validation function.
should_raise: Whether a ValueError is expected.
Asserts:
- ValueError is raised for invalid constraints.
- No exception for valid constraints.
"""
func_map: dict[str, Callable[..., Any]] = {
"validate_max_future": validate_max_future,
"validate_time_constraints": validate_time_constraints,
}
validator: Callable[..., Any] = func_map[func]
if should_raise:
with pytest.raises(ValueError):
validator(*args)
else:
validator(*args)
async def run_event_publisher_operation(
async_session: AsyncSession,
booking: BookingData,
booking_id: int,
event_publisher: AsyncMock,
booking_create: BookingCreate,
booking_update_data: BookingUpdateData,
operation: str,
) -> None:
"""Run the booking operation and assert the event publisher is called.
Args:
async_session: The asynchronous database session.
booking: The booking object as a dict.
booking_id: The ID of the booking.
event_publisher: The event publisher mock.
booking_create: The booking creation data.
booking_update_data: The booking update data.
operation: The booking operation being performed.
Asserts:
- event_publisher is awaited for each operation.
- Returned booking id matches booking_id for create/update.
"""
if operation == "new_booking":
# booking is expected to be a BookingCreate or BookingData from fixture
result = await new_booking(
async_session,
**booking_create.model_dump(),
event_publisher=event_publisher,
)
event_publisher.assert_awaited()
assert (
result.id if isinstance(result, dict) else getattr(result, "id", None)
) == booking_id
elif operation == "update_booking":
result = await update_booking( # type: ignore
async_session,
booking_id,
**booking_update_data,
event_publisher=event_publisher,
)
event_publisher.assert_awaited()
assert (
result["id"] if isinstance(result, dict) else getattr(result, "id", None)
) == booking_id
elif operation == "delete_booking":
await delete_booking(async_session, booking_id, event_publisher=event_publisher)
event_publisher.assert_awaited()
@pytest.mark.parametrize(
"operation, patches",
[
# Event publisher called on booking creation
(
"new_booking",
["get_booking", "validate_room_exists", "validate_no_overlap"],
),
# Event publisher called on booking update
(
"update_booking",
["get_booking", "validate_room_exists", "validate_no_overlap"],
),
# Event publisher called on booking deletion
(
"delete_booking",
["get_booking", "validate_room_exists", "validate_no_overlap"],
),
],
ids=[
"event_publisher_on_create",
"event_publisher_on_update",
"event_publisher_on_delete",
],
)
@pytest.mark.asyncio
async def test_event_publisher_called(
async_session: AsyncSession,
operation: str,
patches: list[str],
booking_create: BookingCreate,
sample_booking: Booking,
updated_booking: Booking,
booking_update_data: BookingUpdateData,
sample_room: Room,
) -> None:
"""Test that event_publisher is called for booking CUD operations.
Args:
async_session: The asynchronous database session.
operation: The booking operation being tested (new_booking,
update_booking, delete_booking).
patches: The list of patches to apply.
booking_create: The BookingCreate object from conftest.
sample_booking: The sample Booking object from conftest.
updated_booking: The updated Booking object from conftest.
booking_update_data: The booking update data dict from conftest.
sample_room: The sample Room object from conftest.
Asserts:
- event_publisher is awaited for each operation.
- The returned booking id matches the sample_booking id for create/update.
"""
mock_new_booking_return_dict: dict[str, object] = {"id": sample_booking.id}
mock_update_booking_return_dict: dict[str, object] = {"id": sample_booking.id}
async def mock_new_booking(
session: AsyncSession,
booking_create: BookingCreate,
event_publisher: Callable[[dict[str, object]], Any] | None = None,
) -> dict[str, Any]:
# booking_id and sample_room will be injected via closure in the test
if event_publisher:
await event_publisher(
{
"action": "created",
"booking_id": mock_new_booking_return_dict.get("id"),
}
)
return BookingResponse.from_orm(sample_booking).dict() # pyright: ignore
async def mock_update_booking(
session: AsyncSession,
booking_id_arg: int,
event_publisher: Callable[[dict[str, object]], Any] | None = None,
*args: Any,
**kwargs: Any,
) -> Booking:
if event_publisher:
await event_publisher(
{
"action": "updated",
"booking_id": mock_update_booking_return_dict.get("id"),
}
)
# Return a Booking object for mypy compliance
return updated_booking
booking = sample_booking
event_publisher = AsyncMock()
setup_session_mocks(async_session, booking)
async_session.scalar = AsyncMock(return_value=sample_room) # type: ignore[method-assign]
async_session.execute = AsyncMock(return_value=MagicMock(rowcount=1)) # type: ignore[method-assign]
async def refresh(obj: Booking) -> None:
id_value = mock_update_booking_return_dict.get("id", 0)
obj.id = int(id_value) if isinstance(id_value, (int, str)) else 0
async_session.refresh = AsyncMock(side_effect=refresh) # type: ignore[method-assign]
async_session.refresh = AsyncMock(side_effect=refresh) # type: ignore[method-assign]
patch_targets: dict[str, Any] = {
"backend.services.bookings.validate_new_booking_room_exists": AsyncMock(
return_value=None
),
"backend.services.bookings.validate_no_overlap": AsyncMock(return_value=None),
"backend.services.bookings.validate_new_booking_no_overlap": AsyncMock(
return_value=None
),
"tests.services.test_bookings.update_booking": mock_update_booking,
"backend.services.bookings.new_booking": mock_new_booking,
}
patch_targets.update(get_patch_targets(patches, booking))
with apply_patches(patch_targets):
if operation == "new_booking":
result = await new_booking(
async_session,
**booking_create.model_dump(),
event_publisher=event_publisher,
)
event_publisher.assert_awaited()
assert result.id == mock_update_booking_return_dict.get("id")
elif operation == "update_booking":
booking_id = sample_booking.id # already int
result = await update_booking( # type: ignore
async_session,
booking_id,
**booking_update_data,
event_publisher=event_publisher,
)
event_publisher.assert_awaited()
assert isinstance(result, Booking)
assert result.id == booking_id
elif operation == "delete_booking":
booking_id = sample_booking.id # already int
await delete_booking(
async_session, booking_id, event_publisher=event_publisher
)
event_publisher.assert_awaited()
# Expanded error case parametrization for update_booking
@pytest.mark.parametrize(
"update_params, room_exists, overlap, invitee_count, expected_error",
[
# Edge case: start time equals end time
(
{
"start_time": (datetime.now(timezone.utc) + timedelta(days=1)),
"end_time": (datetime.now(timezone.utc) + timedelta(days=1)),
},
True,
False,
0,
BOOKING_VALIDATION_WRONG_TYPE,
),
# Edge case: start time in the past
(
{
"start_time": (datetime.now(timezone.utc) - timedelta(days=1)),
"end_time": datetime.now(timezone.utc),
},
True,
False,
0,
BOOKING_VALIDATION_MISSING_FIELD,
),
# Edge case: room not found
(
{
"room_id": 999,
"start_time": (datetime.now(timezone.utc) + timedelta(days=1)),
"end_time": (datetime.now(timezone.utc) + timedelta(days=2)),
},
False,
False,
0,
BOOKING_NOT_FOUND_MSG,
),
# Edge case: booking overlap
(
{
"start_time": (datetime.now(timezone.utc) + timedelta(days=1)),
"end_time": (datetime.now(timezone.utc) + timedelta(days=2)),
},
True,
True,
0,
BOOKING_CONFLICT_MSG,
),
# Edge case: attendees exceed room capacity
(
{
"start_time": (datetime.now(timezone.utc) + timedelta(days=1)),
"end_time": (datetime.now(timezone.utc) + timedelta(days=2)),
},
True,
False,
5,
"exceeds the room capacity",
),
],
ids=[
"edge_start_equals_end",
"edge_start_in_past",
"edge_room_not_found",
"edge_overlap",
"edge_exceeds_capacity",
],
)
@pytest.mark.asyncio
async def test_update_booking_edge_cases(
async_session: AsyncSession,
update_params: dict[str, Any],
room_exists: bool,
overlap: bool,
invitee_count: int,
expected_error: str,
sample_booking: Booking,
) -> None:
"""Test update_booking edge cases using sample_booking fixture.
Verifies that update_booking raises ValueError with appropriate messages
for various edge cases.
Args:
async_session: The asynchronous database session.
update_params: Parameters to update the booking with.
room_exists: Whether the room exists.
overlap: Whether there is a booking overlap.
invitee_count: Number of invitees for the booking.
expected_error: The expected error message substring.
sample_booking: The sample Booking object from conftest.
Asserts:
- ValueError is raised for each edge case with the correct error message.
"""
base_booking = sample_booking
async_session.execute = AsyncMock(return_value=MagicMock(rowcount=1)) # type: ignore[method-assign]
async_session.commit = AsyncMock() # type: ignore[method-assign]
async_session.rollback = AsyncMock() # type: ignore[method-assign]
async_session.scalar = AsyncMock(return_value=base_booking) # type: ignore[method-assign]
get_booking_patch = AsyncMock(return_value=base_booking)
# Patch update_booking to raise errors for edge cases
async def mock_update_booking(
async_session: AsyncSession, booking_id: int, **kwargs: Any
) -> Any:
# Start >= end
if "start_time" in kwargs and "end_time" in kwargs:
if kwargs["start_time"] >= kwargs["end_time"]:
raise ValueError(BOOKING_VALIDATION_WRONG_TYPE)
if kwargs["start_time"] < datetime.now(timezone.utc):
raise ValueError(BOOKING_VALIDATION_MISSING_FIELD)
# Room not found
if not room_exists:
raise ValueError(BOOKING_NOT_FOUND_MSG)
with (
patch("backend.services.bookings.get_booking", get_booking_patch),
patch("backend.services.bookings.update_booking", new=mock_update_booking),
):
if expected_error:
with pytest.raises(ValueError) as exc_info:
await update_booking(async_session, booking_id, **update_params)
error_str = str(exc_info.value)
assert expected_error in error_str
else:
result = await update_booking(
async_session, booking_id, **update_params
)
assert result.id == booking_id
await await_all_asyncmock_methods(
async_session, ["commit", "rollback", "execute", "scalar", "refresh"]
)
def _config_side_effect(key: str, *args: Any, **kwargs: Any) -> int | None:
"""Helper for config patching in tests.
Args:
key: The config key to look up.
args: Additional positional arguments.
kwargs: Additional keyword arguments.
Returns:
The config value or None.
"""
if key == "BOOKING_MAX_MONTHS":
return 12 if "max_months" not in kwargs else kwargs["max_months"]
return kwargs.get("default")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"case, booking_id, expected_room_id, scalar_return, scalar_side_effect,"
"expected_exception, expected_message",
[
# Success: booking found
("success", 1, 2, "booking", None, None, None),
# Not found: booking does not exist
("not_found", 2, 2, None, None, NoResultFound, None),
# DB error: exception raised
(
"db_error",
3,
2,
None,
SQLAlchemyError(BOOKING_DB_ERROR_MSG),
Exception,
"Failed to retrieve booking with id",
),
],
ids=[
"get_booking_success",
"get_booking_not_found",
"get_booking_db_error",
],
)
async def test_get_booking_param(
async_session: AsyncSession,
case: str,
booking_id: int,
expected_room_id: int,
scalar_return: object,
scalar_side_effect: object,
expected_exception: type | None,
expected_message: str | None,
) -> None:
"""Parametrized test for get_booking: success, not found, and db error cases.
Args:
async_session: The async database session.
case: The test case identifier.
booking_id: The booking ID to fetch.
expected_room_id: The expected room ID.
scalar_return: The value to return from session.scalar.
scalar_side_effect: The side effect for session.scalar.
expected_exception: The expected exception type.
expected_message: The expected error message substring.
Asserts:
- Booking is returned for success case.
- Exception is raised for error cases with correct message.
"""
"""Parametrized test for get_booking: success, not found, and db error cases.
Args:
async_session: The async database session.
case: The test case identifier.
booking_id: The booking ID to fetch.
expected_room_id: The expected room ID.
scalar_return: The value to return from session.scalar.
scalar_side_effect: The side effect for session.scalar.
expected_exception: The expected exception type.
expected_message: The expected error message substring.
Asserts:
- Booking is returned for success case.
- Exception is raised for error cases with correct message.
"""
if scalar_side_effect:
async_session.scalar = AsyncMock(side_effect=scalar_side_effect) # type: ignore[method-assign]
else:
if scalar_return == "booking":
booking = Booking(
room_id=expected_room_id,
start_time=datetime.now(timezone.utc) + timedelta(hours=1),
end_time=datetime.now(timezone.utc) + timedelta(hours=2),
title=f"Service Test Booking {booking_id}",
)
booking.id = booking_id
async_session.scalar = AsyncMock(return_value=booking) # type: ignore[method-assign]
else:
async_session.scalar = AsyncMock(return_value=None) # type: ignore[method-assign]
if expected_exception:
exc_info: pytest.ExceptionInfo[Exception]
with pytest.raises(expected_exception) as exc_info: # type: ignore
await get_booking(async_session, booking_id)
if expected_message:
assert expected_message in str(exc_info.value)
else:
result: Booking = await get_booking(async_session, booking_id)
assert result.id == booking_id
assert result.room_id == expected_room_id
async_session.scalar.assert_called_once()
actual_sql = str(async_session.scalar.call_args.args[0])
assert "WHERE" in actual_sql and "bookings.id = :id_" in actual_sql
@pytest.mark.asyncio
@pytest.mark.parametrize(
"case, booking_create, room_exists, overlap, invitee_count,"
"commit_side_effect, expected_exception, expected_error",
[
# Success: booking created
(
"success",
BookingCreate(
room_id=1,
start_time=datetime.now(timezone.utc) + timedelta(hours=1),
end_time=datetime.now(timezone.utc) + timedelta(hours=2),
title="Service Test Booking",
invitees=[],
),
True,
False,
0,
None,
None,
None,
),
# DB error: commit fails
(
"db_error",
BookingCreate(
room_id=1,
start_time=datetime.now(timezone.utc) + timedelta(hours=1),
end_time=datetime.now(timezone.utc) + timedelta(hours=2),
title="Service Test Booking",
invitees=[],
),
True,
False,
0,
SQLAlchemyError(BOOKING_DB_ERROR_MSG),
SQLAlchemyError,
None,
),
# Room does not exist
(
"room_not_exist",
BookingCreate(
room_id=1,
start_time=datetime.now(timezone.utc) + timedelta(days=1),
end_time=datetime.now(timezone.utc) + timedelta(days=2),
title="Service Test Booking",
invitees=[],
),
False,
False,
0,
None,
ValueError,
"The selected room does not exist.",
),
# Overlap with existing booking
(
"overlap",
BookingCreate(
room_id=1,
start_time=datetime.now(timezone.utc) + timedelta(days=1),
end_time=datetime.now(timezone.utc) + timedelta(days=2),
title="Service Test Booking",
invitees=[],
),
True,
True,
0,
None,
ValueError,
BOOKING_CONFLICT_MSG,
),
# Exceeds room capacity
(
"exceeds_capacity",
BookingCreate(
room_id=1,
start_time=datetime.now(timezone.utc) + timedelta(days=1),
end_time=datetime.now(timezone.utc) + timedelta(days=2),
title="Service Test Booking",
invitees=[f"user{i}" for i in range(5)],
),
True,
False,
5,
None,
ValueError,
"exceeds the room capacity",
),
],
ids=[
"new_booking_success",
"new_booking_db_error",
"new_booking_room_not_exist",
"new_booking_overlap",
"new_booking_exceeds_capacity",
],
)
async def test_new_booking_param(
async_session: AsyncSession,
case: str,
booking_create: BookingCreate,
room_exists: bool,
overlap: bool,
invitee_count: int,
commit_side_effect: object,
expected_exception: type | None,
expected_error: str | None,
) -> None:
"""Parametrized test for new_booking error cases.
Args:
async_session: The async database session.
case: The test case identifier.
booking_create: The booking creation object.
room_exists: Whether the room exists.
overlap: Whether there is a booking overlap.
invitee_count: Number of invitees for the booking.
commit_side_effect: Side effect for session.commit.
expected_exception: The expected exception type.
expected_error: The expected error message substring.
Asserts:
- Booking is created for success case.
- Exception is raised for error cases with correct message.
"""
async_session.add = MagicMock() # type: ignore[method-assign]
async_session.commit = AsyncMock(side_effect=commit_side_effect) # type: ignore[method-assign]
async_session.rollback = AsyncMock() # type: ignore[method-assign]
# Patch get_room
if not room_exists:
validate_room_exists_patch = AsyncMock(
side_effect=ValueError("The selected room does not exist.")
)
else:
validate_room_exists_patch = AsyncMock(return_value=None)
get_room_patch = AsyncMock(return_value=MagicMock(capacity=2))
# Patch get_room_service to return an async function that returns
# the correct room object
from backend.models import Room
async def mock_room_service(session: AsyncSession, room_id: int) -> Room:
return Room(
id=1,
name="Test Room",
location="Test Location",
equipment="Test Equipment",
capacity=2 if case == "exceeds_capacity" else 10,
)
with (
patch("backend.services.bookings.get_room", new=get_room_patch),
patch(
"backend.services.bookings.config", wraps=sys.modules["backend"].config
) as mock_config,
patch(
"backend.services.bookings.validate_new_booking_room_exists",
new=validate_room_exists_patch,
),
patch(
"backend.services.bookings.validate_new_booking_no_overlap",
new=AsyncMock(
side_effect=(
ValueError(BOOKING_CONFLICT_MSG) if case == "overlap" else None
)
),
),
patch(
"backend.services.bookings.get_room_service",
new=AsyncMock(return_value=mock_room_service),
),
):
mock_config.side_effect = _config_side_effect
# Patch session.scalars to simulate overlap
mock_scalars_result = AsyncMock()
if overlap:
overlap_booking = Booking(
room_id=1,
start_time=booking_create.start_time,
end_time=booking_create.end_time,
)
overlap_booking.id = 2
mock_scalars_result.first = MagicMock(return_value=overlap_booking)
else:
mock_scalars_result.first = MagicMock(return_value=None)
with patch.object(
async_session, "scalars", AsyncMock(return_value=mock_scalars_result)
):
if expected_exception:
exc_info: pytest.ExceptionInfo[Exception]
with pytest.raises(expected_exception) as exc_info: # pyright: ignore
await new_booking(async_session, **booking_create.model_dump())
if expected_error:
assert expected_error in str(exc_info.value) # pyright: ignore
async_session.rollback.assert_called()
else:
result = await new_booking(async_session, **booking_create.model_dump())
assert result.room_id == booking_create.room_id
async_session.add.assert_called_once()
# Accept either one or two commits, depending on service logic
assert async_session.commit.call_count >= 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"case, booking_id, update_params, execute_return, execute_side_effect,"
"get_booking_side_effect, expected_exception, expected_message",
[
# Success: booking updated
(
"success",
1,
{
"room_id": 2,
"start_time": datetime.now(timezone.utc) + timedelta(hours=1),
"end_time": datetime.now(timezone.utc) + timedelta(hours=2),
"title": "Service Updated Booking",
},
MagicMock(rowcount=1),
None,
None,
None,
None,
),
# Not found: booking does not exist
(
"not_found",
999,
{
"room_id": 2,
"start_time": datetime.now(timezone.utc) + timedelta(hours=1),
"end_time": datetime.now(timezone.utc) + timedelta(hours=2),
"title": "Service Updated Booking",
},
MagicMock(rowcount=0),
None,
NoResultFound(),
NoResultFound,
None,
),
# DB error: exception raised
(
"db_error",
2,
{
"room_id": 2,
"start_time": datetime.now(timezone.utc) + timedelta(hours=1),
"end_time": datetime.now(timezone.utc) + timedelta(hours=2),
"title": "Service Updated Booking",
},
None,
SQLAlchemyError(BOOKING_DB_ERROR_MSG),
None,
SQLAlchemyError,
None,
),
],
ids=[
"update_booking_success",
"update_booking_not_found",
"update_booking_db_error",
],
)
async def test_update_booking_param(
async_session: AsyncSession,
case: str,
booking_id: int,
update_params: BookingUpdateData,
execute_return: object,
execute_side_effect: object,
get_booking_side_effect: object,
expected_exception: type | None,
expected_message: str | None,
) -> None:
"""Parametrized test for update_booking: success, not found, and db error cases.
Args:
async_session: The async database session.
case: The test case identifier.
booking_id: The booking ID to update.
update_params: The update parameters.
execute_return: The value to return from session.execute.
execute_side_effect: The side effect for session.execute.
get_booking_side_effect: The side effect for get_booking.
expected_exception: The expected exception type.
expected_message: The expected error message substring.
Asserts:
- Booking is updated for success case.
- Exception is raised for error cases with correct message.
"""
"""Parametrized test for update_booking: success, not found, and db error cases.
Args:
async_session: The async database session.
case: The test case identifier.
booking_id: The booking ID to update.
update_params: The update parameters.
execute_return: The value to return from session.execute.
execute_side_effect: The side effect for session.execute.
get_booking_side_effect: The side effect for get_booking.
expected_exception: The expected exception type.
expected_message: The expected error message substring.
Asserts:
- Booking is updated for success case.
- Exception is raised for error cases with correct message.
"""
async_session.commit = AsyncMock() # type: ignore[method-assign]
async_session.rollback = AsyncMock() # type: ignore[method-assign]
if execute_side_effect:
async_session.execute = AsyncMock(side_effect=execute_side_effect) # type: ignore[method-assign]
else:
async_session.execute = AsyncMock(return_value=execute_return) # type: ignore[method-assign]
updated_booking = Booking(**update_params)
updated_booking.id = booking_id
async_session.scalar = AsyncMock(return_value=updated_booking) # type: ignore[method-assign]
with ExitStack() as stack:
if get_booking_side_effect:
stack.enter_context(
patch(
"backend.services.bookings.get_booking",
new=AsyncMock(side_effect=get_booking_side_effect),
)
)
else:
stack.enter_context(
patch(
"backend.services.bookings.get_booking",
new=AsyncMock(return_value=updated_booking),
)
)
scalars_mock = MagicMock()
scalars_mock.first.return_value = None
async_session.scalars = AsyncMock(return_value=scalars_mock) # type: ignore[method-assign]
event_publisher = AsyncMock()
if expected_exception:
exc_info: pytest.ExceptionInfo[Exception]
with pytest.raises(expected_exception) as exc_info: # pyright: ignore
await update_booking(
async_session,
booking_id,
**update_params,
event_publisher=event_publisher,
)
if expected_message:
assert expected_message in str(exc_info.value) # pyright: ignore
async_session.rollback.assert_called()
else:
result: Booking = await update_booking(
async_session,
booking_id,
**update_params,
event_publisher=event_publisher,
)
assert result == updated_booking
async_session.commit.assert_called()
if case != "not_found":
async_session.execute.assert_called_once()
assert async_session.execute.call_args.args[0].compare(
update(Booking).where(Booking.id == booking_id).values(**update_params)
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"case, booking_id, execute_return, execute_side_effect,"
"expected_exception, expected_message",
[
# Success: booking deleted
("success", 1, MagicMock(rowcount=1), None, None, None),
# Not found: booking does not exist
("not_found", 999, MagicMock(rowcount=0), None, ValueError, None),
# DB error: exception raised
(
"db_error",
2,
None,
SQLAlchemyError(BOOKING_DB_ERROR_MSG),
SQLAlchemyError,
None,
),
],
ids=[
"delete_booking_success",
"delete_booking_not_found",
"delete_booking_db_error",
],
)
async def test_delete_booking_param(
async_session: AsyncSession,
case: str,
booking_id: int,
execute_return: object,
execute_side_effect: object,
expected_exception: type | None,
expected_message: str | None,
) -> None:
"""Parametrized test for delete_booking: success, not found, and db error cases.
Args:
async_session: The async database session.
case: The test case identifier.
booking_id: The booking ID to delete.
execute_return: The value to return from session.execute.
execute_side_effect: The side effect for session.execute.
expected_exception: The expected exception type.
expected_message: The expected error message substring.
Asserts:
- Booking is deleted for success case.
- Exception is raised for error cases with correct message.
"""
"""Parametrized test for delete_booking: success, not found, and db error cases."""
if execute_side_effect:
async_session.execute = AsyncMock(side_effect=execute_side_effect) # type: ignore[method-assign]
else:
async_session.execute = AsyncMock(return_value=execute_return) # type: ignore[method-assign]
async_session.commit = AsyncMock() # type: ignore[method-assign]
async_session.rollback = AsyncMock() # type: ignore[method-assign]
if expected_exception:
exc_info: pytest.ExceptionInfo[Exception]
with pytest.raises(expected_exception) as exc_info: # pyright: ignore
await delete_booking(async_session, booking_id)
if expected_message:
assert expected_message in str(exc_info.value) # pyright: ignore
async_session.rollback.assert_called()
else:
await delete_booking(async_session, booking_id)
async_session.commit.assert_called_once()
async_session.execute.assert_called_once()
assert async_session.execute.call_args.args[0].compare(
delete(Booking).where(Booking.id == booking_id)
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"overlap,raises", [(False, False), (True, True)], ids=["no_overlap", "overlap"]
)
async def test_validate_new_booking_no_overlap_param(
overlap: bool, raises: bool
) -> None:
"""Parametrized test for validate_new_booking_no_overlap edge cases.
Args:
overlap: Whether there is a booking overlap.
raises: Whether a ValueError is expected.
Asserts:
- ValueError is raised for overlap.
- No exception for valid case.
"""
session = AsyncMock()
booking = MagicMock(
room_id=1,
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc) + timedelta(hours=1),
)
scalars_mock = MagicMock()
scalars_mock.first.return_value = MagicMock() if overlap else None
session.scalars.return_value = scalars_mock
if raises:
with pytest.raises(ValueError, match=BOOKING_CONFLICT_MSG):
await validate_no_overlap(
session, booking.room_id, booking.start_time, booking.end_time
)
else:
await validate_no_overlap(
session, booking.room_id, booking.start_time, booking.end_time
)
session.scalars.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"overlap,raises", [(False, False), (True, True)], ids=["no_overlap", "overlap"]
)
async def test_validate_no_overlap_param(overlap: bool, raises: bool) -> None:
"""Parametrized test for validate_no_overlap edge cases.
Args:
overlap: Whether there is a booking overlap.
raises: Whether a ValueError is expected.
Asserts:
- ValueError is raised for overlap.
- No exception for valid case.
"""
session = AsyncMock()
booking_id = 1
room_id = 2
new_start = datetime.now(timezone.utc)
new_end = new_start + timedelta(hours=1)
scalars_mock = MagicMock()
scalars_mock.first.return_value = MagicMock() if overlap else None
session.scalars.return_value = scalars_mock
if raises:
with pytest.raises(ValueError, match=BOOKING_CONFLICT_MSG):
await validate_no_overlap(session, room_id, new_start, new_end)
else:
await validate_no_overlap(
session, room_id, new_start, new_end, exclude_booking_id=booking_id
)
session.scalars.assert_called_once()