mirror of
https://github.com/xlorepdarkhelm/numinar-coding-project.git
synced 2026-09-10 01:55:48 -04:00
551 lines
19 KiB
Python
551 lines
19 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.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.db import get_session
|
|
from backend.dependencies.rooms import get_room_service
|
|
from backend.main import app
|
|
from backend.models import Booking
|
|
from backend.models import BookingList
|
|
from backend.models import Room
|
|
from backend.routers.rooms import publish_room_availability_event
|
|
from backend.services.bookings import update_booking
|
|
from tests.types import BookingCreateData
|
|
from tests.types import BookingData
|
|
from tests.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,
|
|
),
|
|
],
|
|
)
|
|
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,
|
|
),
|
|
],
|
|
)
|
|
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: BookingList,
|
|
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)
|
|
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: BookingList,
|
|
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)
|
|
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: BookingList,
|
|
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
|
|
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()
|