Lots of fixes to lots of things.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-08-27 15:51:05 -04:00
parent d6f5b82029
commit 11e3b3c82b
11 changed files with 340 additions and 199 deletions

2
backend/poetry.lock generated
View File

@@ -3394,4 +3394,4 @@ tests-strict = ["pytest (==4.6.0) ; python_version < \"3.10.0\" and python_versi
[metadata]
lock-version = "2.1"
python-versions = ">=3.13,<4.0"
content-hash = "4fed8ee03daf2c52faa530fb0cc25cae226700e71bbabd83d0e40185c58e89de"
content-hash = "c8b1a52c4933e45697a4f71396247ff4c23e33621dfdfab3859f8472b62a27e3"

View File

@@ -51,6 +51,7 @@ xdoctest = {extras = ["colors"], version = ">=0.15.10"}
myst-parser = {version = ">=0.16.1"}
pytest-asyncio = "^1.1.0"
cryptography = ">=45.0.6" # For safety
httpx = "^0.28.1"
[tool.poetry.scripts]
backend = "backend.__main__:main"

View File

@@ -5,6 +5,7 @@ and manages the application lifecycle.
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI
@@ -27,6 +28,7 @@ if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENTS:
app_configs["openapi_url"] = None # Disable OpenAPI schema
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan event to create database tables on startup.
@@ -38,7 +40,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
after database initialization.
"""
await create_db_and_tables()
yield # This is where the lifespan ends
yield
app_configs["lifespan"] = lifespan

View File

@@ -12,6 +12,9 @@ from sqlalchemy.exc import SQLAlchemyError
from backend.dependencies.db import DBSession
from backend.models import Booking
from backend.models import BookingList
# Import the SSE publisher from rooms router
from backend.routers.rooms import publish_room_availability_event
from backend.schemas.bookings import BookingCreate
from backend.schemas.bookings import BookingResponse
from backend.schemas.bookings import BookingUpdate
@@ -115,11 +118,11 @@ async def create_booking(booking: BookingCreate, session: DBSession) -> Booking:
"""Create a new booking.
Args:
booking: Data for the new booking.
session: Database session.
booking (BookingCreate): Data for the new booking.
session (DBSession): Database session.
Returns:
Created booking.
Booking: Created booking.
Raises:
HTTPException: If a database error or unexpected error occurs.
@@ -127,7 +130,9 @@ async def create_booking(booking: BookingCreate, session: DBSession) -> Booking:
logger.debug("Received request to create a new booking")
try:
db_booking = Booking(**booking.model_dump())
created_booking = await new_booking(session, db_booking)
created_booking = await new_booking(
session, db_booking, publish_room_availability_event
)
logger.info(f"Successfully created booking with id: {created_booking.id}")
return created_booking
except SQLAlchemyError as e:
@@ -151,12 +156,12 @@ async def update_existing_booking(
"""Update an existing booking.
Args:
booking_id: ID of the booking to update.
booking_update: Updated data for the booking.
session: Database session.
booking_id (int): ID of the booking to update.
booking_update (BookingUpdate): Updated data for the booking.
session (DBSession): Database session.
Returns:
Updated booking.
Booking: Updated booking.
Raises:
HTTPException: If the booking is not found, a database error occurs,
@@ -165,7 +170,9 @@ async def update_existing_booking(
logger.debug(f"Received request to update booking with id: {booking_id}")
try:
booking_params = booking_update.model_dump(exclude_unset=True)
updated_booking = await update_booking(session, booking_id, **booking_params)
updated_booking = await update_booking(
session, booking_id, publish_room_availability_event, **booking_params
)
logger.info(f"Successfully updated booking with id: {booking_id}")
return updated_booking
except (NoResultFound, ValueError) as e:
@@ -197,8 +204,8 @@ async def delete_existing_booking(booking_id: int, session: DBSession) -> None:
"""Delete a booking.
Args:
booking_id: ID of the booking to delete.
session: Database session.
booking_id (int): ID of the booking to delete.
session (DBSession): Database session.
Raises:
HTTPException: If the booking is not found, a database error occurs,
@@ -206,7 +213,7 @@ async def delete_existing_booking(booking_id: int, session: DBSession) -> None:
"""
logger.debug(f"Received request to delete booking with id: {booking_id}")
try:
await delete_booking(session, booking_id)
await delete_booking(session, booking_id, publish_room_availability_event)
logger.info(f"Successfully deleted booking with id: {booking_id}")
except (NoResultFound, ValueError) as e:
logger.warning(f"Booking with id {booking_id} not found")

View File

@@ -1,11 +1,16 @@
"""Routes for room-related operations in the backend."""
import asyncio
import logging
from collections.abc import AsyncGenerator
from typing import Any
from typing import List
from fastapi import APIRouter
from fastapi import HTTPException
from fastapi import Request
from fastapi import status
from fastapi.responses import StreamingResponse
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
@@ -24,8 +29,70 @@ from backend.services.rooms import update_room
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/rooms", tags=["rooms"])
room_availability_subscribers: list[asyncio.Queue[dict[str, Any]]] = []
async def event_generator(
request: Any,
queue: asyncio.Queue[dict[str, Any]],
timeout: float = 15.0,
) -> AsyncGenerator[str, None]:
"""Yield SSE events or keep-alive messages from a queue.
Args:
request: The FastAPI request object.
queue: The asyncio queue to receive events from.
timeout: Timeout for keep-alive messages.
Yields:
SSE-formatted strings.
"""
import json
try:
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=timeout)
yield f"data: {json.dumps(event)}\n\n"
except asyncio.TimeoutError:
yield ": keep-alive\n\n"
finally:
if queue in room_availability_subscribers:
room_availability_subscribers.remove(queue)
async def publish_room_availability_event(event: dict[str, Any]) -> None:
"""Publish a room availability event to all subscribers.
Args:
event: The event data to publish.
"""
for queue in list(room_availability_subscribers):
await queue.put(event)
@router.get("/availability/stream")
async def stream_room_availability(request: Request) -> StreamingResponse:
"""Stream real-time room availability changes using SSE.
Args:
request: The incoming HTTP request.
Returns:
The streaming response for SSE.
"""
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
room_availability_subscribers.append(queue)
return StreamingResponse(
event_generator(request, queue), media_type="text/event-stream"
)
@router.get("/", response_model=List[RoomResponse])
async def read_rooms(session: DBSession) -> RoomList:

View File

@@ -32,6 +32,8 @@ from backend.services.rooms import get_room
logger = logging.getLogger(__name__)
EventPublisher = Callable[[dict[str, Any]], Awaitable[None]] | None
class BookingParams(TypedDict):
"""Parameters for updating a booking.
@@ -98,85 +100,120 @@ async def get_booking(session: AsyncSession, booking_id: int) -> Booking:
logger.debug("Exiting get_booking")
async def new_booking(session: AsyncSession, booking: Booking) -> Booking:
async def _validate_new_booking_room_exists(session: AsyncSession, room_id: int) -> Any:
try:
return await get_room(session, room_id)
except Exception as e:
logger.warning(f"Attempted to book non-existent room_id {room_id}")
raise ValueError("The selected room does not exist.") from e
def _validate_new_booking_time_constraints(
start: datetime, end: datetime, now: datetime
) -> None:
if start < now or end < now:
logger.warning("Attempted to create booking in the past.")
raise ValueError("Bookings cannot be made in the past.")
if start >= end:
logger.warning("Attempted to create booking with start_time >= end_time.")
raise ValueError("The booking start time must be before the end time.")
def _validate_new_booking_max_future(
start: datetime, end: datetime, now: datetime, max_months: int
) -> None:
max_future = now + timedelta(days=30 * max_months)
if start > max_future or end > max_future:
logger.warning("Attempted to create booking too far in the future.")
raise ValueError(
f"Bookings can only be made up to {max_months} months in advance."
)
async def _validate_new_booking_no_overlap(
session: AsyncSession, booking: Booking
) -> None:
overlap_stmt = select(Booking).where(
Booking.room_id == booking.room_id,
func.tstzrange(Booking.start_time, Booking.end_time, "[]").op("&&")(
func.tstzrange(booking.start_time, booking.end_time, "[]")
),
)
result = await session.scalars(overlap_stmt)
overlapping = result.first()
if overlapping:
logger.warning(
"Attempted to create overlapping booking for room_id %s",
booking.room_id,
)
raise ValueError(
"Booking times overlap with an existing booking for this room."
)
async def _validate_new_booking_capacity(
session: AsyncSession, booking: Booking, room: Any
) -> None:
invitees = await get_invitees_for_booking(
session, getattr(booking, "id", None) or -1
)
num_attendees = len(invitees) if invitees else 0
if num_attendees > room.capacity:
logger.warning(
f"Attempted to create booking with {num_attendees} attendees"
f" exceeding room capacity {room.capacity}."
)
raise ValueError(
f"Number of attendees ({num_attendees}) exceeds the room"
f" capacity ({room.capacity})."
)
async def new_booking(
session: AsyncSession,
booking: Booking,
event_publisher: EventPublisher = None,
) -> Booking:
"""Create a new booking in the database.
Args:
session: Database session.
booking: Booking object to create.
session (AsyncSession): Database session.
booking (Booking): Booking object to create.
event_publisher (EventPublisher): Optional async callback to publish booking events.
Returns:
Created booking.
Booking: Created booking.
Raises:
ValueError: If any booking constraint is violated.
Exception: If any unexpected error occurs.
"""
logger.debug("Entering new_booking")
try:
# Constraint: Room must exist
try:
room = await get_room(session, booking.room_id)
except Exception as e:
logger.warning(f"Attempted to book non-existent room_id {booking.room_id}")
raise ValueError("The selected room does not exist.") from e
now = datetime.now(timezone.utc)
# Constraint: No bookings in the past
if booking.start_time < now or booking.end_time < now:
logger.warning("Attempted to create booking in the past.")
raise ValueError("Bookings cannot be made in the past.")
# Constraint: Start date < end date
if booking.start_time >= booking.end_time:
logger.warning("Attempted to create booking with start_time >= end_time.")
raise ValueError("The booking start time must be before the end time.")
# Constraint: Max months in future
room = await _validate_new_booking_room_exists(session, booking.room_id)
_validate_new_booking_time_constraints(
booking.start_time, booking.end_time, now
)
max_months = config("BOOKING_MAX_MONTHS", default=12, cast=int)
max_future = now + timedelta(days=30 * max_months)
if booking.start_time > max_future or booking.end_time > max_future:
logger.warning("Attempted to create booking too far in the future.")
raise ValueError(
f"Bookings can only be made up to {max_months} months in advance."
)
# Check for overlapping bookings for the same room
overlap_stmt = select(Booking).where(
Booking.room_id == booking.room_id,
func.tstzrange(Booking.start_time, Booking.end_time, "[]").op("&&")(
func.tstzrange(booking.start_time, booking.end_time, "[]")
),
_validate_new_booking_max_future(
booking.start_time, booking.end_time, now, max_months
)
result = await session.scalars(overlap_stmt)
overlapping = result.first()
if overlapping:
logger.warning(
"Attempted to create overlapping booking for room_id %s",
booking.room_id,
)
raise ValueError(
"Booking times overlap with an existing booking for this room."
)
# Constraint: Attendees <= room capacity
invitees = await get_invitees_for_booking(
session, getattr(booking, "id", None) or -1
)
num_attendees = len(invitees) if invitees else 0
if num_attendees > room.capacity:
logger.warning(
f"Attempted to create booking with {num_attendees} attendees"
f" exceeding room capacity {room.capacity}."
)
raise ValueError(
f"Number of attendees ({num_attendees}) exceeds the room"
f" capacity ({room.capacity})."
)
await _validate_new_booking_no_overlap(session, booking)
await _validate_new_booking_capacity(session, booking, room)
session.add(booking)
await session.commit()
logger.info(f"Successfully created new booking with id: {booking.id}")
if event_publisher:
await event_publisher(
{
"action": "created",
"room_id": booking.room_id,
"booking_id": booking.id,
"start_time": str(booking.start_time),
"end_time": str(booking.end_time),
}
)
return booking
except Exception as e:
logger.error(f"Failed to create new booking: {str(e)}")
@@ -255,17 +292,21 @@ def _validate_attendee_count(invitees: Sequence[Any], room: Any) -> None:
async def update_booking(
session: AsyncSession,
booking_id: int,
event_publisher: EventPublisher = None,
**kwargs: Unpack[BookingParams],
) -> Booking:
"""Update an existing booking.
Args:
session: Database session.
booking_id: ID of the booking to update.
kwargs: Updated booking parameters (room ID, start time, end time).
session (AsyncSession): Database session.
booking_id (int): ID of the booking to update.
event_publisher (EventPublisher): Optional async callback to publish
booking events.
kwargs (Unpack[BookingParams]): Updated booking parameters
(room ID, start time, end time).
Returns:
Updated booking.
Booking: Updated booking.
Raises:
ValueError: If no booking with the specified ID is found.
@@ -295,6 +336,16 @@ async def update_booking(
await session.commit()
booking = await get_booking(session, booking_id)
logger.info(f"Successfully updated booking with id: {booking_id}")
if event_publisher:
await event_publisher(
{
"action": "updated",
"room_id": booking.room_id,
"booking_id": booking.id,
"start_time": str(booking.start_time),
"end_time": str(booking.end_time),
}
)
return booking
except Exception as e:
logger.error(f"Failed to update booking with id {booking_id}: {str(e)}")
@@ -304,18 +355,25 @@ async def update_booking(
logger.debug("Exiting update_booking")
async def delete_booking(session: AsyncSession, booking_id: int) -> None:
async def delete_booking(
session: AsyncSession,
booking_id: int,
event_publisher: EventPublisher = None,
) -> None:
"""Delete a booking from the database.
Args:
session: Database session.
booking_id: ID of the booking to delete.
session (AsyncSession): Database session.
booking_id (int): ID of the booking to delete.
event_publisher (EventPublisher): Optional async callback to publish booking events.
Raises:
ValueError: If no booking with the specified ID is found.
"""
logger.debug(f"Entering delete_booking with booking_id: {booking_id}")
try:
# Get booking before delete to get room_id and times
booking = await get_booking(session, booking_id)
stmt = delete(Booking).where(Booking.id == booking_id)
result = await session.execute(stmt)
if result.rowcount == 0:
@@ -323,6 +381,16 @@ async def delete_booking(session: AsyncSession, booking_id: int) -> None:
raise ValueError(f"No booking found with id {booking_id}")
await session.commit()
logger.info(f"Successfully deleted booking with id: {booking_id}")
if event_publisher:
await event_publisher(
{
"action": "deleted",
"room_id": booking.room_id,
"booking_id": booking.id,
"start_time": str(booking.start_time),
"end_time": str(booking.end_time),
}
)
except Exception as e:
logger.error(f"Failed to delete booking with id {booking_id}: {str(e)}")
await session.rollback()

View File

@@ -1,6 +1,7 @@
"""Test configuration and fixtures for the backend tests."""
import logging
from collections.abc import AsyncGenerator
from collections.abc import Generator
from datetime import datetime
from datetime import timedelta
@@ -10,7 +11,9 @@ from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
import pytest_asyncio
from httpx import ASGITransport
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from backend.main import app
@@ -33,14 +36,16 @@ def async_session() -> AsyncSession:
return AsyncMock(spec=AsyncSession)
@pytest.fixture
def client() -> TestClient:
"""Fixture to provide a FastAPI TestClient for testing endpoints.
@pytest_asyncio.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
"""Fixture to provide an AsyncClient for async endpoint testing.
Returns:
A FastAPI TestClient instance for making HTTP requests.
Yields:
An HTTPX AsyncClient instance for making async HTTP requests.
"""
return TestClient(app)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
@@ -55,19 +60,7 @@ def mock_logger(request: pytest.FixtureRequest) -> Generator[MagicMock, None, No
Usage:
To use this fixture in a test module, parameterize it with the module path
where the logger is defined. For example, to mock the logger in
`backend.services.users`, use the `@pytest.mark.parametrize` decorator:
.. code-block:: python
@pytest.mark.parametrize("mock_logger", ["backend.services.users"], indirect=True)
async def test_example(mock_logger):
# Test code here, with mock_logger patched for backend.services.users
The `indirect=True` argument ensures the fixture uses the provided module path.
The fixture patches the logger at the specified path
(e.g., `backend.services.users.logger`) and yields a `MagicMock` instance that can be
used to verify logging behavior.
where the logger is defined.
"""
# Default path if none provided, or use the parameterized path
module_path = getattr(request, "param", "backend") + ".logger"
@@ -77,6 +70,16 @@ def mock_logger(request: pytest.FixtureRequest) -> Generator[MagicMock, None, No
yield mock_logger_instance
@pytest.fixture
def event_publisher() -> AsyncMock:
"""Fixture for an AsyncMock event publisher for event publishing tests.
Returns:
An async mock event publisher callable.
"""
return AsyncMock()
@pytest.fixture
def sample_users() -> UserList:
"""Fixture to provide sample User objects for testing.

View File

@@ -8,7 +8,7 @@ from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
@@ -20,7 +20,7 @@ from backend.models import Room
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_get_bookings_for_room_success(
client: TestClient, sample_bookings: BookingList, mock_logger: MagicMock
client: AsyncClient, sample_bookings: BookingList, mock_logger: MagicMock
) -> None:
"""Test successful retrieval of all bookings for a room via GET /bookings/room/{room_id}.
@@ -37,7 +37,7 @@ async def test_get_bookings_for_room_success(
) as mock_get_bookings:
mock_get_bookings.return_value = sample_bookings
response = client.get(f"/bookings/room/{room_id}")
response = await client.get(f"/bookings/room/{room_id}")
assert response.status_code == 200
assert response.json() == [
@@ -55,7 +55,7 @@ async def test_get_bookings_for_room_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_get_bookings_for_room_empty(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test retrieval of bookings when none exist for a room via GET /bookings/room/{room_id}.
@@ -71,7 +71,7 @@ async def test_get_bookings_for_room_empty(
) as mock_get_bookings:
mock_get_bookings.return_value = []
response = client.get(f"/bookings/room/{room_id}")
response = await client.get(f"/bookings/room/{room_id}")
assert response.status_code == 200
assert response.json() == []
@@ -81,7 +81,7 @@ async def test_get_bookings_for_room_empty(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_get_bookings_for_room_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in GET /bookings/room/{room_id}.
@@ -97,7 +97,7 @@ async def test_get_bookings_for_room_database_error(
) as mock_get_bookings:
mock_get_bookings.side_effect = SQLAlchemyError()
response = client.get(f"/bookings/room/{room_id}")
response = await client.get(f"/bookings/room/{room_id}")
assert response.status_code == 500
assert "detail" in response.json()
@@ -107,7 +107,7 @@ async def test_get_bookings_for_room_database_error(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_get_booking_success(
client: TestClient, sample_room: Room, mock_logger: MagicMock
client: AsyncClient, sample_room: Room, mock_logger: MagicMock
) -> None:
"""Test successful retrieval of a booking by ID via GET /bookings/{booking_id}.
@@ -131,7 +131,7 @@ async def test_get_booking_success(
) as mock_get_booking:
mock_get_booking.return_value = booking
response = client.get(f"/bookings/{booking_id}")
response = await client.get(f"/bookings/{booking_id}")
assert response.status_code == 200
assert response.json() == {
@@ -146,7 +146,7 @@ async def test_get_booking_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_get_booking_not_found(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of non-existent booking in GET /bookings/{booking_id}.
@@ -162,7 +162,7 @@ async def test_get_booking_not_found(
) as mock_get_booking:
mock_get_booking.side_effect = NoResultFound()
response = client.get(f"/bookings/{booking_id}")
response = await client.get(f"/bookings/{booking_id}")
assert response.status_code == 404
assert "detail" in response.json()
@@ -172,7 +172,7 @@ async def test_get_booking_not_found(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_get_booking_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in GET /bookings/{booking_id}.
@@ -188,7 +188,7 @@ async def test_get_booking_database_error(
) as mock_get_booking:
mock_get_booking.side_effect = SQLAlchemyError()
response = client.get(f"/bookings/{booking_id}")
response = await client.get(f"/bookings/{booking_id}")
assert response.status_code == 500
assert "detail" in response.json()
@@ -198,7 +198,7 @@ async def test_get_booking_database_error(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_create_booking_success(
client: TestClient, sample_room: Room, mock_logger: MagicMock
client: AsyncClient, sample_room: Room, mock_logger: MagicMock
) -> None:
"""Test successful creation of a new booking via POST /bookings/.
@@ -222,7 +222,7 @@ async def test_create_booking_success(
) as mock_new_booking:
mock_new_booking.return_value = created_booking
response = client.post("/bookings/", json=booking_data)
response = await client.post("/bookings/", json=booking_data)
assert response.status_code == 201
assert response.json() == {
@@ -237,7 +237,7 @@ async def test_create_booking_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_create_booking_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in POST /bookings/.
@@ -257,7 +257,7 @@ async def test_create_booking_database_error(
) as mock_new_booking:
mock_new_booking.side_effect = SQLAlchemyError()
response = client.post("/bookings/", json=booking_data)
response = await client.post("/bookings/", json=booking_data)
assert response.status_code == 500
assert "detail" in response.json()
@@ -267,7 +267,7 @@ async def test_create_booking_database_error(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_update_booking_success(
client: TestClient, sample_room: Room, mock_logger: MagicMock
client: AsyncClient, sample_room: Room, mock_logger: MagicMock
) -> None:
"""Test successful update of a booking via PUT /bookings/{booking_id}.
@@ -292,7 +292,7 @@ async def test_update_booking_success(
) as mock_update_booking:
mock_update_booking.return_value = updated_booking
response = client.put(f"/bookings/{booking_id}", json=update_data)
response = await client.put(f"/bookings/{booking_id}", json=update_data)
assert response.status_code == 200
assert response.json() == {
@@ -303,13 +303,13 @@ async def test_update_booking_success(
}
update_data["start_time"] = datetime.fromisoformat(update_data["start_time"])
update_data["end_time"] = datetime.fromisoformat(update_data["end_time"])
mock_update_booking.assert_called_once_with(ANY, booking_id, **update_data)
mock_update_booking.assert_called_once_with(ANY, booking_id, ANY, **update_data)
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_update_booking_not_found(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of non-existent booking in PUT /bookings/{booking_id}.
@@ -330,19 +330,19 @@ async def test_update_booking_not_found(
) as mock_update_booking:
mock_update_booking.side_effect = NoResultFound()
response = client.put(f"/bookings/{booking_id}", json=update_data)
response = await client.put(f"/bookings/{booking_id}", json=update_data)
assert response.status_code == 404
assert "detail" in response.json()
update_data["start_time"] = datetime.fromisoformat(update_data["start_time"])
update_data["end_time"] = datetime.fromisoformat(update_data["end_time"])
mock_update_booking.assert_called_once_with(ANY, booking_id, **update_data)
mock_update_booking.assert_called_once_with(ANY, booking_id, ANY, **update_data)
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_update_booking_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in PUT /bookings/{booking_id}.
@@ -363,19 +363,19 @@ async def test_update_booking_database_error(
) as mock_update_booking:
mock_update_booking.side_effect = SQLAlchemyError()
response = client.put(f"/bookings/{booking_id}", json=update_data)
response = await client.put(f"/bookings/{booking_id}", json=update_data)
assert response.status_code == 500
assert "detail" in response.json()
update_data["start_time"] = datetime.fromisoformat(update_data["start_time"])
update_data["end_time"] = datetime.fromisoformat(update_data["end_time"])
mock_update_booking.assert_called_once_with(ANY, booking_id, **update_data)
mock_update_booking.assert_called_once_with(ANY, booking_id, ANY, **update_data)
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_delete_booking_success(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test successful deletion of a booking via DELETE /bookings/{booking_id}.
@@ -389,17 +389,17 @@ async def test_delete_booking_success(
with patch(
"backend.routers.bookings.delete_booking", new=AsyncMock()
) as mock_delete_booking:
response = client.delete(f"/bookings/{booking_id}")
response = await client.delete(f"/bookings/{booking_id}")
assert response.status_code == 204
assert response.text == ""
mock_delete_booking.assert_called_once_with(ANY, booking_id)
mock_delete_booking.assert_called_once_with(ANY, booking_id, ANY)
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_delete_booking_not_found(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of non-existent booking in DELETE /bookings/{booking_id}.
@@ -415,17 +415,17 @@ async def test_delete_booking_not_found(
) as mock_delete_booking:
mock_delete_booking.side_effect = NoResultFound()
response = client.delete(f"/bookings/{booking_id}")
response = await client.delete(f"/bookings/{booking_id}")
assert response.status_code == 404
assert "detail" in response.json()
mock_delete_booking.assert_called_once_with(ANY, booking_id)
mock_delete_booking.assert_called_once_with(ANY, booking_id, ANY)
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True)
async def test_delete_booking_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in DELETE /bookings/{booking_id}.
@@ -441,8 +441,8 @@ async def test_delete_booking_database_error(
) as mock_delete_booking:
mock_delete_booking.side_effect = SQLAlchemyError()
response = client.delete(f"/bookings/{booking_id}")
response = await client.delete(f"/bookings/{booking_id}")
assert response.status_code == 500
assert "detail" in response.json()
mock_delete_booking.assert_called_once_with(ANY, booking_id)
mock_delete_booking.assert_called_once_with(ANY, booking_id, ANY)

View File

@@ -7,7 +7,7 @@ from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
from sqlalchemy.exc import SQLAlchemyError
from backend.models import Booking
@@ -18,7 +18,7 @@ from backend.models import InviteeList
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.invitees"], indirect=True)
async def test_get_invitees_for_booking_success(
client: TestClient,
client: AsyncClient,
sample_booking: Booking,
sample_invitees: InviteeList,
mock_logger: MagicMock,
@@ -38,9 +38,7 @@ async def test_get_invitees_for_booking_success(
"backend.routers.invitees.get_invitees_for_booking", new=AsyncMock()
) as mock_get_invitees:
mock_get_invitees.return_value = sample_invitees
response = client.get(f"/invitees/booking/{sample_booking.id}")
response = await client.get(f"/invitees/booking/{sample_booking.id}")
assert response.status_code == 200
assert response.json() == [
{
@@ -57,7 +55,7 @@ async def test_get_invitees_for_booking_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.invitees"], indirect=True)
async def test_get_invitees_for_booking_empty(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test retrieval of invitees when none exist for a booking.
@@ -73,9 +71,7 @@ async def test_get_invitees_for_booking_empty(
"backend.routers.invitees.get_invitees_for_booking", new=AsyncMock()
) as mock_get_invitees:
mock_get_invitees.return_value = []
response = client.get(f"/invitees/booking/{booking_id}")
response = await client.get(f"/invitees/booking/{booking_id}")
assert response.status_code == 200
assert response.json() == []
mock_get_invitees.assert_called_once_with(ANY, booking_id)
@@ -84,7 +80,7 @@ async def test_get_invitees_for_booking_empty(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.invitees"], indirect=True)
async def test_get_invitees_for_booking_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in GET /invitees/booking/{booking_id}.
@@ -99,9 +95,7 @@ async def test_get_invitees_for_booking_database_error(
"backend.routers.invitees.get_invitees_for_booking", new=AsyncMock()
) as mock_get_invitees:
mock_get_invitees.side_effect = SQLAlchemyError()
response = client.get(f"/invitees/booking/{booking_id}")
response = await client.get(f"/invitees/booking/{booking_id}")
assert response.status_code == 500
assert "detail" in response.json()
mock_get_invitees.assert_called_once_with(ANY, booking_id)
@@ -110,7 +104,7 @@ async def test_get_invitees_for_booking_database_error(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.invitees"], indirect=True)
async def test_add_invitee_success(
client: TestClient,
client: AsyncClient,
sample_invitee: Invitee,
mock_logger: MagicMock,
) -> None:
@@ -132,11 +126,9 @@ async def test_add_invitee_success(
"backend.routers.invitees.add_invitee_to_booking", new=AsyncMock()
) as mock_add_invitee:
mock_add_invitee.return_value = sample_invitee
response = client.post(
response = await client.post(
f"/invitees/booking/{sample_invitee.booking_id}", json=invitee_data
)
assert response.status_code == 201
result = response.json()
assert result["booking_id"] == sample_invitee.booking_id
@@ -149,7 +141,7 @@ async def test_add_invitee_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.invitees"], indirect=True)
async def test_add_invitee_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in POST /invitees/booking/{booking_id}.
@@ -168,9 +160,9 @@ async def test_add_invitee_database_error(
"backend.routers.invitees.add_invitee_to_booking", new=AsyncMock()
) as mock_add_invitee:
mock_add_invitee.side_effect = SQLAlchemyError()
response = client.post(f"/invitees/booking/{booking_id}", json=invitee_data)
response = await client.post(
f"/invitees/booking/{booking_id}", json=invitee_data
)
assert response.status_code == 400
assert "detail" in response.json()
mock_add_invitee.assert_called_once_with(
@@ -181,7 +173,7 @@ async def test_add_invitee_database_error(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.invitees"], indirect=True)
async def test_remove_invitee_success(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test successful removal of an invitee from a booking.
@@ -197,8 +189,9 @@ async def test_remove_invitee_success(
with patch(
"backend.routers.invitees.remove_invitee_from_booking", new=AsyncMock()
) as mock_remove_invitee:
response = client.delete(f"/invitees/booking/{booking_id}/user/{user_email}")
response = await client.delete(
f"/invitees/booking/{booking_id}/user/{user_email}"
)
assert response.status_code == 204
assert response.text == ""
mock_remove_invitee.assert_called_once_with(ANY, booking_id, user_email)
@@ -207,7 +200,7 @@ async def test_remove_invitee_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.invitees"], indirect=True)
async def test_remove_invitee_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors for delete.
@@ -224,9 +217,9 @@ async def test_remove_invitee_database_error(
"backend.routers.invitees.remove_invitee_from_booking", new=AsyncMock()
) as mock_remove_invitee:
mock_remove_invitee.side_effect = SQLAlchemyError()
response = client.delete(f"/invitees/booking/{booking_id}/user/{user_email}")
response = await client.delete(
f"/invitees/booking/{booking_id}/user/{user_email}"
)
assert response.status_code == 500
assert "detail" in response.json()
mock_remove_invitee.assert_called_once_with(ANY, booking_id, user_email)

View File

@@ -1,4 +1,4 @@
"""Unit tests for the backend.routers.rooms module."""
"""Tests for backend.routers.rooms in the Numinar coding project backend."""
from typing import Any
from unittest.mock import ANY
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
from sqlalchemy.exc import NoResultFound
from backend.models import Room
@@ -17,7 +17,7 @@ from backend.models import RoomList
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_get_rooms_success(
client: TestClient, sample_rooms: RoomList, mock_logger: MagicMock
client: AsyncClient, sample_rooms: RoomList, mock_logger: MagicMock
) -> None:
"""Test successful retrieval of all rooms via GET /rooms/.
@@ -31,7 +31,7 @@ async def test_get_rooms_success(
with patch("backend.routers.rooms.get_rooms", new=AsyncMock()) as mock_get_rooms:
mock_get_rooms.return_value = sample_rooms
response = client.get("/rooms/")
response = await client.get("/rooms/")
assert response.status_code == 200
assert response.json() == [
@@ -55,7 +55,7 @@ async def test_get_rooms_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_get_rooms_empty(client: TestClient, mock_logger: MagicMock) -> None:
async def test_get_rooms_empty(client: AsyncClient, mock_logger: MagicMock) -> None:
"""Test retrieval of rooms when none exist via GET /rooms/.
Verifies that the endpoint returns an empty list with a 200 status.
@@ -67,7 +67,7 @@ async def test_get_rooms_empty(client: TestClient, mock_logger: MagicMock) -> No
with patch("backend.routers.rooms.get_rooms", new=AsyncMock()) as mock_get_rooms:
mock_get_rooms.return_value = []
response = client.get("/rooms/")
response = await client.get("/rooms/")
assert response.status_code == 200
assert response.json() == []
@@ -76,7 +76,7 @@ async def test_get_rooms_empty(client: TestClient, mock_logger: MagicMock) -> No
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_get_room_success(client: TestClient, mock_logger: MagicMock) -> None:
async def test_get_room_success(client: AsyncClient, mock_logger: MagicMock) -> None:
"""Test successful retrieval of a room by ID via GET /rooms/{room_id}.
Verifies that the endpoint returns the correct room with a 200 status.
@@ -96,7 +96,7 @@ async def test_get_room_success(client: TestClient, mock_logger: MagicMock) -> N
with patch("backend.routers.rooms.get_room", new=AsyncMock()) as mock_get_room:
mock_get_room.return_value = room
response = client.get(f"/rooms/{room_id}")
response = await client.get(f"/rooms/{room_id}")
assert response.status_code == 200
assert response.json() == {
@@ -111,7 +111,7 @@ async def test_get_room_success(client: TestClient, mock_logger: MagicMock) -> N
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_get_room_not_found(client: TestClient, mock_logger: MagicMock) -> None:
async def test_get_room_not_found(client: AsyncClient, mock_logger: MagicMock) -> None:
"""Test handling of non-existent room in GET /rooms/{room_id}.
Verifies that the endpoint returns a 404 status when the room is not found.
@@ -124,7 +124,7 @@ async def test_get_room_not_found(client: TestClient, mock_logger: MagicMock) ->
with patch("backend.routers.rooms.get_room", new=AsyncMock()) as mock_get_room:
mock_get_room.side_effect = NoResultFound(f"Room with id {room_id} not found")
response = client.get(f"/rooms/{room_id}")
response = await client.get(f"/rooms/{room_id}")
assert response.status_code == 404
assert "detail" in response.json()
@@ -133,7 +133,7 @@ async def test_get_room_not_found(client: TestClient, mock_logger: MagicMock) ->
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_new_room_success(client: TestClient, mock_logger: MagicMock) -> None:
async def test_new_room_success(client: AsyncClient, mock_logger: MagicMock) -> None:
"""Test successful creation of a new room via POST /rooms/.
Verifies that the endpoint returns the created room with a 201 status.
@@ -153,7 +153,7 @@ async def test_new_room_success(client: TestClient, mock_logger: MagicMock) -> N
with patch("backend.routers.rooms.new_room", new=AsyncMock()) as mock_new_room:
mock_new_room.return_value = created_room
response = client.post("/rooms/", json=room_data)
response = await client.post("/rooms/", json=room_data)
assert response.status_code == 201
assert response.json() == room_data
@@ -162,7 +162,7 @@ async def test_new_room_success(client: TestClient, mock_logger: MagicMock) -> N
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_update_room_success(client: TestClient, mock_logger: MagicMock) -> None:
async def test_update_room_success(client: AsyncClient, mock_logger: MagicMock) -> None:
"""Test successful update of a room via PUT /rooms/{room_id}.
Verifies that the endpoint returns the updated room with a 200 status.
@@ -184,7 +184,7 @@ async def test_update_room_success(client: TestClient, mock_logger: MagicMock) -
) as mock_update_room:
mock_update_room.return_value = updated_room
response = client.put(f"/rooms/{room_id}", json=update_data)
response = await client.put(f"/rooms/{room_id}", json=update_data)
assert response.status_code == 200
assert response.json() == {
@@ -200,7 +200,7 @@ async def test_update_room_success(client: TestClient, mock_logger: MagicMock) -
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_update_room_not_found(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of non-existent room in PUT /rooms/{room_id}.
@@ -222,7 +222,7 @@ async def test_update_room_not_found(
) as mock_update_room:
mock_update_room.side_effect = ValueError()
response = client.put(f"/rooms/{room_id}", json=update_data)
response = await client.put(f"/rooms/{room_id}", json=update_data)
assert response.status_code == 404
assert "detail" in response.json()
@@ -231,7 +231,7 @@ async def test_update_room_not_found(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_delete_room_success(client: TestClient, mock_logger: MagicMock) -> None:
async def test_delete_room_success(client: AsyncClient, mock_logger: MagicMock) -> None:
"""Test successful deletion of a room via DELETE /rooms/{room_id}.
Verifies that the endpoint returns a 204 status on successful deletion.
@@ -244,7 +244,7 @@ async def test_delete_room_success(client: TestClient, mock_logger: MagicMock) -
with patch(
"backend.routers.rooms.delete_room", new=AsyncMock()
) as mock_delete_room:
response = client.delete(f"/rooms/{room_id}")
response = await client.delete(f"/rooms/{room_id}")
assert response.status_code == 204
assert response.text == ""
@@ -254,7 +254,7 @@ async def test_delete_room_success(client: TestClient, mock_logger: MagicMock) -
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.rooms"], indirect=True)
async def test_delete_room_not_found(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of non-existent room in DELETE /rooms/{room_id}.
@@ -270,7 +270,7 @@ async def test_delete_room_not_found(
) as mock_delete_room:
mock_delete_room.side_effect = ValueError()
response = client.delete(f"/rooms/{room_id}")
response = await client.delete(f"/rooms/{room_id}")
assert response.status_code == 404
assert "detail" in response.json()

View File

@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
@@ -17,7 +17,7 @@ from backend.models import UserList
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.users"], indirect=True)
async def test_read_users_success(
client: TestClient, sample_users: UserList, mock_logger: MagicMock
client: AsyncClient, sample_users: UserList, mock_logger: MagicMock
) -> None:
"""Test successful retrieval of all users via GET /users/.
@@ -31,7 +31,7 @@ async def test_read_users_success(
with patch("backend.routers.users.get_users", new=AsyncMock()) as mock_get_users:
mock_get_users.return_value = sample_users
response = client.get("/users/")
response = await client.get("/users/")
assert response.status_code == 200
assert response.json() == [
@@ -43,7 +43,7 @@ async def test_read_users_success(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.users"], indirect=True)
async def test_read_users_empty(client: TestClient, mock_logger: MagicMock) -> None:
async def test_read_users_empty(client: AsyncClient, mock_logger: MagicMock) -> None:
"""Test retrieval of users when none exist via GET /users/.
Verifies that the endpoint returns an empty list with a 200 status.
@@ -55,7 +55,7 @@ async def test_read_users_empty(client: TestClient, mock_logger: MagicMock) -> N
with patch("backend.routers.users.get_users", new=AsyncMock()) as mock_get_users:
mock_get_users.return_value = []
response = client.get("/users/")
response = await client.get("/users/")
assert response.status_code == 200
assert response.json() == []
@@ -65,7 +65,7 @@ async def test_read_users_empty(client: TestClient, mock_logger: MagicMock) -> N
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.users"], indirect=True)
async def test_read_users_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in GET /users/.
@@ -78,7 +78,7 @@ async def test_read_users_database_error(
with patch("backend.routers.users.get_users", new=AsyncMock()) as mock_get_users:
mock_get_users.side_effect = SQLAlchemyError()
response = client.get("/users/")
response = await client.get("/users/")
assert response.status_code == 500
assert "detail" in response.json()
@@ -96,7 +96,7 @@ async def test_read_users_database_error(
ids=["user1", "user2"],
)
async def test_read_user_success(
client: TestClient, email: str, expected_name: str, mock_logger: MagicMock
client: AsyncClient, email: str, expected_name: str, mock_logger: MagicMock
) -> None:
"""Test successful retrieval of a user by email via GET /users/{email}.
@@ -112,7 +112,7 @@ async def test_read_user_success(
with patch("backend.routers.users.get_user", new=AsyncMock()) as mock_get_user:
mock_get_user.return_value = user
response = client.get(f"/users/{email}")
response = await client.get(f"/users/{email}")
assert response.status_code == 200
assert response.json() == {"email": email, "name": expected_name}
@@ -127,7 +127,7 @@ async def test_read_user_success(
ids=["nonexistent_email", "invalid_email"],
)
async def test_read_user_not_found(
client: TestClient, email: str, mock_logger: MagicMock
client: AsyncClient, email: str, mock_logger: MagicMock
) -> None:
"""Test handling of non-existent user in GET /users/{email}.
@@ -141,7 +141,7 @@ async def test_read_user_not_found(
with patch("backend.routers.users.get_user", new=AsyncMock()) as mock_get_user:
mock_get_user.side_effect = NoResultFound()
response = client.get(f"/users/{email}")
response = await client.get(f"/users/{email}")
assert response.status_code == 404
assert "detail" in response.json()
@@ -151,7 +151,7 @@ async def test_read_user_not_found(
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.routers.users"], indirect=True)
async def test_read_user_database_error(
client: TestClient, mock_logger: MagicMock
client: AsyncClient, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in GET /users/{email}.
@@ -165,7 +165,7 @@ async def test_read_user_database_error(
with patch("backend.routers.users.get_user", new=AsyncMock()) as mock_get_user:
mock_get_user.side_effect = SQLAlchemyError()
response = client.get(f"/users/{email}")
response = await client.get(f"/users/{email}")
assert response.status_code == 500
assert "detail" in response.json()