mirror of
https://github.com/xlorepdarkhelm/numinar-coding-project.git
synced 2026-09-09 10:59:50 -04:00
1423 lines
51 KiB
Python
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()
|