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

770 lines
27 KiB
Python

"""Unit tests for the backend.routers.bookings module."""
from datetime import datetime
from typing import Any
from unittest.mock import AsyncMock
import pytest
from httpx import AsyncClient
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.db import get_session
from backend.dependencies.bookings import delete_booking_service
from backend.dependencies.bookings import get_booking_service
from backend.dependencies.bookings import get_bookings_for_month_service
from backend.dependencies.bookings import new_booking_service
from backend.dependencies.bookings import update_booking_service
from backend.dependencies.rooms import get_room_service
from backend.main import app
from backend.models import Booking
from backend.models import Room
from backend.routers.rooms import publish_room_availability_event
from backend.services.bookings import update_booking
from backend.types import BookingCreateData
from backend.types import BookingData
from backend.types import BookingUpdateData
# Useful constants for booking router tests
BOOKING_NOT_FOUND_ID = 999
BOOKING_DB_ERROR_ID = 1
BOOKING_NOT_FOUND_DETAIL = "Booking not found"
BOOKING_DB_ERROR_DETAIL = "Database error"
BOOKING_CONFLICT_DETAIL = (
"Booking times overlap with an existing booking for this room."
)
BOOKING_VALIDATION_MISSING_FIELD = "Invalid booking data: missing required field"
BOOKING_VALIDATION_WRONG_TYPE = "Invalid booking data: wrong type"
BOOKING_CONFLICT_STATUS = 409
BOOKING_SUCCESS_STATUS = 201
BOOKING_NOT_FOUND_STATUS = 404
BOOKING_DB_ERROR_STATUS = 500
BOOKING_GOOD_STATUS = 200
BOOKING_VALIDATION_ERROR_STATUS = 400
@pytest.mark.asyncio
async def test_update_booking_success(
client: AsyncClient,
sample_booking: Booking,
updated_booking: Booking,
booking_update_data: BookingUpdateData,
) -> None:
"""Test successful update of a booking via PATCH /bookings/{booking_id} using fixtures.
Args:
client: The FastAPI test client for making HTTP requests.
sample_booking: The original booking to be updated.
updated_booking: The expected updated booking.
booking_update_data: The data to update the booking with.
Asserts:
- Response status code is BOOKING_GOOD_STATUS.
- Response JSON matches updated booking fields (id, room_id, start_time,
end_time, title, invitees).
"""
booking_id = sample_booking.id
update_data = booking_update_data
async def mock_update_booking(
session: AsyncSession,
booking_id: int,
event_publisher: Any = None,
**kwargs: Any,
) -> Booking:
return updated_booking
app.dependency_overrides[update_booking] = mock_update_booking
response = await client.patch(f"/bookings/{booking_id}", json=update_data)
assert response.status_code == BOOKING_GOOD_STATUS
actual = response.json()
actual.pop("room", None)
expected_start = updated_booking.start_time.isoformat().replace("+00:00", "")
expected_end = updated_booking.end_time.isoformat().replace("+00:00", "")
actual_start = actual["start_time"].replace("Z", "")
actual_end = actual["end_time"].replace("Z", "")
assert actual["id"] == booking_id
assert actual["room_id"] == updated_booking.room_id
assert actual_start == expected_start
assert actual_end == expected_end
assert actual["title"] == updated_booking.title
assert (
actual["invitees"] == updated_booking.invitees or actual["invitees"] is not None
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception,status,detail,booking_id",
[
(
NoResultFound,
BOOKING_NOT_FOUND_STATUS,
BOOKING_NOT_FOUND_DETAIL,
BOOKING_NOT_FOUND_ID,
),
(
SQLAlchemyError,
BOOKING_DB_ERROR_STATUS,
BOOKING_DB_ERROR_DETAIL,
BOOKING_DB_ERROR_ID,
),
],
ids=["not_found", "db_error"],
)
async def test_update_booking_error_cases(
client: AsyncClient,
booking_update_data: BookingUpdateData,
exception: type[Exception],
status: int,
detail: str,
booking_id: int,
) -> None:
"""Test error cases for updating a booking via PATCH /bookings/{booking_id}.
Args:
client: The FastAPI test client for making HTTP requests.
booking_update_data: The data to update the booking with.
exception: The exception to raise.
status: The expected HTTP status code.
detail: The expected error detail message.
booking_id: The ID of the booking to update.
Asserts:
- Response status code matches expected error status.
- Response JSON 'detail' matches expected error detail.
"""
async def mock_update_booking(
session: AsyncSession,
booking_id: int,
event_publisher: Any = None,
**kwargs: Any,
) -> Booking:
raise exception
async def mock_get_booking(session: AsyncSession, booking_id: int) -> Booking:
raise exception
app.dependency_overrides[update_booking_service] = lambda: mock_update_booking
app.dependency_overrides[get_booking_service] = lambda: mock_get_booking
async def mock_get_room_none(*a: Any, **kw: Any) -> None:
return None
app.dependency_overrides[get_room_service] = lambda: mock_get_room_none
response = await client.patch(f"/bookings/{booking_id}", json=booking_update_data)
assert response.status_code == status
assert response.json()["detail"] == detail
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception,status,detail,booking_id",
[
(
NoResultFound,
BOOKING_NOT_FOUND_STATUS,
BOOKING_NOT_FOUND_DETAIL,
BOOKING_NOT_FOUND_ID,
),
(
SQLAlchemyError,
BOOKING_DB_ERROR_STATUS,
BOOKING_DB_ERROR_DETAIL,
BOOKING_DB_ERROR_ID,
),
],
ids=["not_found", "db_error"],
)
async def test_delete_booking_error_cases(
client: AsyncClient,
exception: type[Exception],
status: int,
detail: str,
booking_id: int,
) -> None:
"""Test error cases for deleting a booking via DELETE /bookings/{booking_id}.
Args:
client: The FastAPI test client for making HTTP requests.
exception: The exception to raise.
status: The expected HTTP status code.
detail: The expected error detail message.
booking_id: The ID of the booking to delete.
Asserts:
- Response status code matches expected error status.
- Response JSON 'detail' matches expected error detail.
"""
async def mock_delete_booking(
session: AsyncSession, booking_id: int, event_publisher: Any = None
) -> None:
raise exception
async def mock_get_booking(session: AsyncSession, booking_id: int) -> Booking:
raise exception
app.dependency_overrides[delete_booking_service] = lambda: mock_delete_booking
app.dependency_overrides[get_booking_service] = lambda: mock_get_booking
async def mock_get_room_none(*a: Any, **kw: Any) -> None:
return None
app.dependency_overrides[get_room_service] = lambda: mock_get_room_none
response = await client.delete(f"/bookings/{booking_id}")
assert response.status_code == status
assert response.json()["detail"] == detail
@pytest.mark.asyncio
@pytest.mark.parametrize(
"endpoint,payload,expected_status,expected_detail",
[
(
"/bookings/",
"conflict_booking_data",
BOOKING_CONFLICT_STATUS,
BOOKING_CONFLICT_DETAIL,
),
],
)
async def test_booking_creation_conflict(
client: AsyncClient,
sample_room: Room,
sample_booking: Booking,
sample_bookings: list[Booking],
sample_booking_data: BookingCreateData,
conflict_booking_data: BookingCreateData,
endpoint: str,
payload: str,
expected_status: int,
expected_detail: str | None,
) -> None:
"""Test booking creation conflict scenario (POST).
Args:
client: The FastAPI test client for making HTTP requests.
sample_room: The mocked Room object.
sample_booking: The mocked Booking object.
sample_bookings: The mocked list of Bookings.
sample_booking_data: The mocked booking data dictionary.
conflict_booking_data: The mocked conflicting booking data dictionary.
endpoint: The API endpoint to test.
payload: The request payload.
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message.
Asserts:
- Response status code matches expected_status.
- Response JSON 'detail' matches expected_detail if provided.
"""
async def mock_new_booking(
session: AsyncSession, booking: Any, event_publisher: Any = None
) -> dict[str, Any]:
booking_start = (
booking.start_time.isoformat()
if hasattr(booking.start_time, "isoformat")
else str(booking.start_time)
)
if booking_start == sample_booking_data["start_time"]:
return sample_booking_data | {"id": sample_booking.id}
raise ValueError(BOOKING_CONFLICT_DETAIL)
async def noop(*args: Any, **kwargs: Any) -> None:
return None
dummy_session = AsyncMock(spec=AsyncSession)
dummy_session.add_all = noop
dummy_session.commit = noop
app.dependency_overrides[new_booking_service] = lambda: mock_new_booking
app.dependency_overrides[get_session] = lambda: dummy_session
app.dependency_overrides[get_room_service] = lambda: (
lambda *a: sample_room # pyright: ignore
)
app.dependency_overrides[publish_room_availability_event] = lambda *a: None
payload_data = locals()[payload]
response = await client.post(endpoint, json=payload_data)
assert response.status_code == expected_status
if expected_detail:
assert response.json()["detail"] == expected_detail
@pytest.mark.asyncio
@pytest.mark.parametrize(
"payload,expected_status,expected_detail",
[
(
"conflict_booking_update_data",
BOOKING_CONFLICT_STATUS,
BOOKING_CONFLICT_DETAIL,
),
],
)
async def test_booking_update_conflict(
client: AsyncClient,
sample_room: Room,
sample_bookings: list[Booking],
conflict_booking_update_data: BookingUpdateData,
payload: str,
expected_status: int,
expected_detail: str | None,
) -> None:
"""Test booking update conflict scenario (PATCH).
Args:
client: The FastAPI test client for making HTTP requests.
sample_room: The mocked Room object.
sample_bookings: The mocked list of Bookings.
conflict_booking_update_data: The mocked conflicting booking update data dictionary.
payload: The request payload.
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message.
Asserts:
- Response status code matches expected_status.
- Response JSON 'detail' matches expected_detail if provided.
"""
async def mock_update_booking(
session: AsyncSession,
booking_id: int,
event_publisher: Any = None,
**kwargs: Any,
) -> Booking:
raise ValueError(BOOKING_CONFLICT_DETAIL)
async def mock_get_booking(session: AsyncSession, booking_id: int) -> Booking:
return sample_bookings[booking_id]
async def noop(*args: Any, **kwargs: Any) -> None:
return None
dummy_session = AsyncMock(spec=AsyncSession)
dummy_session.add_all = noop
dummy_session.commit = noop
app.dependency_overrides[update_booking_service] = lambda: mock_update_booking
app.dependency_overrides[get_booking_service] = lambda: mock_get_booking
app.dependency_overrides[get_session] = lambda: dummy_session
app.dependency_overrides[get_room_service] = lambda: (
lambda *a: sample_room # pyright: ignore
)
app.dependency_overrides[publish_room_availability_event] = lambda *a: None
booking_id = sample_bookings[0].id
payload_data = locals()[payload]
response = await client.patch(f"/bookings/{booking_id}", json=payload_data)
assert response.status_code == expected_status
if expected_detail:
assert response.json()["detail"] == expected_detail
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mock_get_booking, booking_id, expected_status, expected_detail",
[
("success", None, BOOKING_GOOD_STATUS, None),
("not_found", BOOKING_NOT_FOUND_ID, BOOKING_NOT_FOUND_STATUS, "not found"),
],
)
async def test_read_booking_parametrized(
client: AsyncClient,
sample_booking: Booking,
sample_room: Room,
mock_get_booking: str,
booking_id: int | None,
expected_status: int,
expected_detail: str | None,
) -> None:
"""Test reading a booking by ID using parametrization.
Args:
client: The FastAPI test client for making HTTP requests.
sample_booking: A sample booking to be returned on success.
sample_room: A sample room associated with the booking.
mock_get_booking: The scenario for mocking get_booking_service
("success" or "not_found").
booking_id: The ID of the booking to retrieve (used for not_found case).
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message, if any.
Asserts:
- Response status code matches expected_status.
- On success, response JSON matches sample booking fields (id, room_id, invitees, room).
- On error, response JSON 'detail' matches expected_detail.
"""
if mock_get_booking == "success":
async def mock_get_booking_success(session: AsyncSession, bid: int) -> Booking:
return sample_booking
async def mock_get_room(*a: Any, **kw: Any) -> Room:
return sample_room
app.dependency_overrides[get_booking_service] = lambda: mock_get_booking_success
app.dependency_overrides[get_room_service] = lambda: mock_get_room
bid = sample_booking.id
else:
async def mock_get_booking_not_found(
session: AsyncSession, bid: int
) -> Booking:
raise NoResultFound
async def mock_get_room_none(*a: Any, **kw: Any) -> None:
return None
app.dependency_overrides[get_booking_service] = (
lambda: mock_get_booking_not_found
)
app.dependency_overrides[get_room_service] = lambda: mock_get_room_none
bid = booking_id if booking_id is not None else 0
response = await client.get(f"/bookings/{bid}")
assert response.status_code == expected_status
if expected_status == BOOKING_GOOD_STATUS:
data = response.json()
assert data["id"] == sample_booking.id
assert data["room_id"] == sample_booking.room_id
assert "invitees" in data
assert "room" in data
if expected_detail:
assert expected_detail in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mock_case, expected_status, expected_detail",
[
("success", BOOKING_GOOD_STATUS, None),
("error", BOOKING_DB_ERROR_STATUS, "error"),
],
)
async def test_read_bookings_for_month_parametrized(
client: AsyncClient,
sample_bookings: list[Booking],
sample_room: Room,
mock_case: str,
expected_status: int,
expected_detail: str | None,
) -> None:
"""Test reading bookings for a specific month using parametrization.
Args:
client: The FastAPI test client for making HTTP requests.
sample_bookings: A list of sample bookings to be returned on success.
sample_room: A sample room associated with the bookings.
mock_case: The scenario for mocking get_bookings_for_month_service
("success" or "error").
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message, if any.
Asserts:
- Response status code matches expected_status.
- On success, response JSON matches sample bookings for the month.
- On error, response JSON 'detail' matches expected_detail.
"""
month = datetime.now().strftime("%Y-%m")
if mock_case == "success":
async def mock_get_bookings_for_month_success(
session: AsyncSession, m: str
) -> list[Booking]:
return list(sample_bookings)
async def mock_get_room(*a: Any, **kw: Any) -> Room:
return sample_room
app.dependency_overrides[get_bookings_for_month_service] = (
lambda: mock_get_bookings_for_month_success
)
app.dependency_overrides[get_room_service] = lambda: mock_get_room
else:
async def mock_get_bookings_for_month_error(
session: AsyncSession, m: str
) -> list[Booking]:
raise Exception("DB error")
async def mock_get_room_none(*a: Any, **kw: Any) -> None:
return None
app.dependency_overrides[get_bookings_for_month_service] = (
lambda: mock_get_bookings_for_month_error
)
app.dependency_overrides[get_room_service] = lambda: mock_get_room_none
response = await client.get(f"/bookings/month/{month}")
assert response.status_code == expected_status
if expected_status == BOOKING_GOOD_STATUS:
data: list[BookingData] = response.json()
assert isinstance(data, list)
assert len(data) == len(sample_bookings)
for booking_json, booking in zip(data, sample_bookings, strict=True):
assert booking_json["id"] == booking.id
assert booking_json["room_id"] == booking.room_id
assert "invitees" in booking_json
assert "room" in booking_json
if expected_detail:
assert expected_detail in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid_payload,expected_detail",
[
("sample_booking_data", BOOKING_VALIDATION_MISSING_FIELD),
("sample_booking_data", BOOKING_VALIDATION_WRONG_TYPE),
],
)
async def test_create_booking_validation_error_parametrized(
client: AsyncClient,
sample_room: Room,
sample_booking_data: BookingCreateData,
invalid_payload: str,
expected_detail: str,
) -> None:
"""Test booking creation with invalid payloads using parametrization.
Args:
client: The FastAPI test client for making HTTP requests.
sample_room: A sample room to be used for booking creation.
sample_booking_data: Valid booking creation data.
invalid_payload: The name of the variable containing the invalid payload data.
expected_detail: The expected error detail message.
Asserts:
- Response status code is BOOKING_VALIDATION_ERROR_STATUS.
- Response JSON 'detail' contains expected_detail (case-insensitive).
"""
async def mock_new_booking(
session: AsyncSession, booking: Any, event_publisher: Any = None
) -> dict[str, Any]:
raise ValueError(expected_detail)
app.dependency_overrides[new_booking_service] = lambda: mock_new_booking
app.dependency_overrides[get_room_service] = lambda: (
lambda *a: sample_room # pyright: ignore
)
app.dependency_overrides[publish_room_availability_event] = lambda *a: None
payload_data = locals()[invalid_payload]
response = await client.post("/bookings/", json=payload_data)
assert response.status_code == BOOKING_VALIDATION_ERROR_STATUS
assert expected_detail.lower() in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception,expected_status,expected_detail",
[
(NoResultFound(), BOOKING_NOT_FOUND_STATUS, "Booking with id 42 not found"),
(SQLAlchemyError(), BOOKING_DB_ERROR_STATUS, BOOKING_DB_ERROR_DETAIL),
(
Exception("fail"),
BOOKING_DB_ERROR_STATUS,
"Unexpected error while fetching booking",
),
],
)
async def test_read_booking_error_branches(
client: AsyncClient,
exception: Exception,
expected_status: int,
expected_detail: str,
) -> None:
"""Test GET /bookings/{booking_id} error branches for full coverage.
Args:
client: The FastAPI test client for making HTTP requests.
exception: The exception to raise in the dependency override.
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message.
Asserts:
- Response status code matches expected_status.
- Response detail matches expected_detail.
"""
booking_id = 42
async def mock_get_booking(*args: Any, **kwargs: Any) -> Booking:
raise exception
app.dependency_overrides[get_booking_service] = lambda: (
mock_get_booking # pyright: ignore
)
app.dependency_overrides[get_room_service] = lambda: (
lambda *a, **kw: None # pyright: ignore
)
response = await client.get(f"/bookings/{booking_id}")
assert response.status_code == expected_status
if "unexpected error" in expected_detail.lower():
assert "an unexpected error occurred" in response.text.lower()
else:
assert expected_detail.lower() in response.text.lower()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception,expected_status,expected_detail",
[
(ValueError("overlap error"), BOOKING_CONFLICT_STATUS, BOOKING_CONFLICT_DETAIL),
(ValueError("other error"), BOOKING_VALIDATION_ERROR_STATUS, "other error"),
(SQLAlchemyError(), BOOKING_DB_ERROR_STATUS, "Internal server error"),
(
Exception("fail"),
BOOKING_DB_ERROR_STATUS,
"Unexpected error while creating booking",
),
],
)
async def test_create_booking_error_branches(
client: AsyncClient,
booking_create: BookingCreateData,
exception: Exception,
expected_status: int,
expected_detail: str,
) -> None:
"""Test POST /bookings/ error branches for full coverage.
Args:
client: The FastAPI test client for making HTTP requests.
booking_create: The booking data to post.
exception: The exception to raise in the dependency override.
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message.
Asserts:
- Response status code matches expected_status.
- Response detail matches expected_detail.
"""
async def mock_new_booking(*args: Any, **kwargs: Any) -> Booking:
raise exception
app.dependency_overrides[new_booking_service] = lambda: mock_new_booking
def mock_get_room_none(*args: Any, **kwargs: Any) -> None:
return None
app.dependency_overrides[get_room_service] = lambda: mock_get_room_none
if hasattr(booking_create, "model_dump"):
payload = booking_create.model_dump(mode="json") # type: ignore [attr-defined]
elif hasattr(booking_create, "dict"):
payload = booking_create.dict() # type: ignore [attr-defined]
else:
payload = dict(booking_create)
response = await client.post("/bookings/", json=payload) # pyright: ignore
assert response.status_code == expected_status
if "unexpected error" in expected_detail.lower():
assert "an unexpected error occurred" in response.text.lower()
else:
assert expected_detail.lower() in response.text.lower()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception,expected_status,expected_detail",
[
(NoResultFound(), BOOKING_NOT_FOUND_STATUS, BOOKING_NOT_FOUND_DETAIL),
(SQLAlchemyError(), BOOKING_DB_ERROR_STATUS, "Database error"),
(ValueError("overlap error"), BOOKING_CONFLICT_STATUS, BOOKING_CONFLICT_DETAIL),
(ValueError("other error"), BOOKING_CONFLICT_STATUS, "other error"),
(
Exception("fail"),
BOOKING_DB_ERROR_STATUS,
"Unexpected error while updating booking",
),
],
)
async def test_update_booking_error_branches(
client: AsyncClient,
booking_update_data: BookingUpdateData,
exception: Exception,
expected_status: int,
expected_detail: str,
) -> None:
"""Test PATCH /bookings/{booking_id} error branches for full coverage.
Args:
client: The FastAPI test client for making HTTP requests.
booking_update_data: The booking update data to patch.
exception: The exception to raise in the dependency override.
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message.
Asserts:
- Response status code matches expected_status.
- Response detail matches expected_detail.
"""
booking_id = 42
async def mock_update_booking(*args: Any, **kwargs: Any) -> Booking:
raise exception
app.dependency_overrides[update_booking_service] = lambda: mock_update_booking
def mock_get_room_none(*args: Any, **kwargs: Any) -> None:
return None
app.dependency_overrides[get_room_service] = lambda: mock_get_room_none
response = await client.patch(f"/bookings/{booking_id}", json=booking_update_data)
assert response.status_code == expected_status
if "unexpected error" in expected_detail.lower():
assert "an unexpected error occurred" in response.text.lower()
else:
assert expected_detail.lower() in response.text.lower()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception,expected_status,expected_detail",
[
(NoResultFound(), BOOKING_NOT_FOUND_STATUS, BOOKING_NOT_FOUND_DETAIL),
(SQLAlchemyError(), BOOKING_DB_ERROR_STATUS, "Database error"),
(
ValueError("not found"),
BOOKING_NOT_FOUND_STATUS,
"Booking with id 42 not found",
),
(
Exception("fail"),
BOOKING_DB_ERROR_STATUS,
"Unexpected error while deleting booking",
),
],
)
async def test_delete_booking_error_branches(
client: AsyncClient,
exception: Exception,
expected_status: int,
expected_detail: str,
) -> None:
"""Test DELETE /bookings/{booking_id} error branches for full coverage.
Args:
client: The FastAPI test client for making HTTP requests.
exception: The exception to raise in the dependency override.
expected_status: The expected HTTP status code.
expected_detail: The expected error detail message.
Asserts:
- Response status code matches expected_status.
- Response detail matches expected_detail.
"""
booking_id = 42
async def mock_delete_booking(*args: Any, **kwargs: Any) -> None:
raise exception
app.dependency_overrides[delete_booking_service] = lambda: mock_delete_booking
response = await client.delete(f"/bookings/{booking_id}")
assert response.status_code == expected_status
if "unexpected error" in expected_detail.lower():
assert "an unexpected error occurred" in response.text.lower()
else:
assert expected_detail.lower() in response.text.lower()