Files
conference-room-booking-system/backend/tests/conftest.py
T

506 lines
15 KiB
Python
Raw Normal View History

"""Test configuration and fixtures for the backend tests."""
2025-08-25 21:38:31 -04:00
import logging
2025-08-27 15:51:05 -04:00
from collections.abc import AsyncGenerator
2025-08-25 21:38:31 -04:00
from collections.abc import Generator
from datetime import datetime
2025-08-27 11:17:36 -04:00
from datetime import timedelta
from datetime import timezone
2025-09-03 15:37:25 -04:00
from pathlib import Path
2025-09-26 06:24:01 -04:00
from typing import Awaitable
from typing import Callable
from typing import TypeVar
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
2025-08-25 21:38:31 -04:00
from unittest.mock import patch
import pytest
2025-08-27 15:51:05 -04:00
import pytest_asyncio
2025-09-03 15:37:25 -04:00
from dotenv import load_dotenv
2025-09-26 06:24:01 -04:00
from fastapi import Request
from fastapi import Response
2025-09-03 17:47:01 -04:00
from fastapi.responses import JSONResponse
2025-08-27 15:51:05 -04:00
from httpx import ASGITransport
from httpx import AsyncClient
2025-09-26 06:24:01 -04:00
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
2025-09-03 15:37:25 -04:00
# Ensure .env is loaded from base or parent directory before any project
# imports
def _load_env_from_base_or_parent() -> None:
2025-09-03 15:37:25 -04:00
base = Path(__file__).parent.parent # project root
env_path = base / ".env"
if env_path.exists():
load_dotenv(dotenv_path=env_path)
else:
parent_env = base.parent / ".env"
if parent_env.exists():
load_dotenv(dotenv_path=parent_env)
_load_env_from_base_or_parent()
from backend.main import app # noqa: E402
from backend.models import Booking # noqa: E402
from backend.models import BookingList # noqa: E402
from backend.models import Room # noqa: E402
from backend.models import RoomList # noqa: E402
from backend.models import User # noqa: E402
from backend.models import UserList # noqa: E402
2025-09-30 09:01:42 -04:00
from backend.schemas.bookings import BookingCreate # noqa: E402
from tests.types import BookingCreateData # noqa: E402
from tests.types import BookingUpdateData # noqa: E402
from tests.types import RoomData # noqa: E402
from tests.types import RoomUpdateData # noqa: E402
from tests.types import UserData # noqa: E402
2025-09-26 06:24:01 -04:00
# Modern handler type for exception handlers
ExcT = TypeVar("ExcT", bound=Exception)
HandlerType = Callable[[Request, ExcT], Response | Awaitable[Response]]
@pytest.fixture(autouse=True)
def clear_dependency_overrides() -> Generator[None, None, None]:
"""Fixture to clear FastAPI app dependency overrides before and after each test.
Yields:
None
"""
app.dependency_overrides.clear()
yield
app.dependency_overrides.clear()
@pytest.fixture
2025-09-26 06:24:01 -04:00
def async_session() -> AsyncMock:
2025-08-26 15:47:08 -04:00
"""Fixture to provide a mocked AsyncSession for database interactions.
Returns:
A mocked AsyncSession object for database interactions.
"""
return AsyncMock(spec=AsyncSession)
2025-08-24 21:20:24 -04:00
2025-08-27 15:51:05 -04:00
@pytest_asyncio.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
"""Fixture to provide an AsyncClient for async endpoint testing.
2025-08-26 15:47:08 -04:00
2025-08-27 15:51:05 -04:00
Yields:
An HTTPX AsyncClient instance for making async HTTP requests.
2025-08-26 15:47:08 -04:00
"""
2025-08-27 15:51:05 -04:00
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
2025-08-25 21:38:31 -04:00
@pytest.fixture
def mock_logger(request: pytest.FixtureRequest) -> Generator[MagicMock, None, None]:
"""Fixture to mock a logger for a specified module path.
Args:
request: Pytest request object, used to pass the module path for patching.
2025-08-26 15:47:08 -04:00
Yields:
2025-09-03 17:47:01 -04:00
MagicMock: A mocked logger instance for the specified module path.
Raises:
ValueError: If the module path parameter is not provided.
2025-08-26 15:47:08 -04:00
Usage:
To use this fixture in a test module, parameterize it with the module path
2025-08-27 15:51:05 -04:00
where the logger is defined.
2025-08-25 21:38:31 -04:00
"""
2025-09-03 17:47:01 -04:00
# Require explicit module path for logger
if not hasattr(request, "param"):
raise ValueError(
"mock_logger fixture requires a module path parameter, e.g."
" 'backend.routers.invitees'"
)
module_path = request.param + ".logger"
2025-08-25 21:38:31 -04:00
mock_logger_instance = MagicMock(spec=logging.Logger)
with patch(module_path, mock_logger_instance):
yield mock_logger_instance
2025-08-27 15:51:05 -04:00
@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()
2025-08-25 21:38:31 -04:00
@pytest.fixture
def sample_users() -> UserList:
2025-08-26 15:47:08 -04:00
"""Fixture to provide sample User objects for testing.
Returns:
A list of sample User objects.
"""
2025-08-25 21:38:31 -04:00
return [
User(email="user1@example.com", name="User One"),
User(email="user2@example.com", name="User Two"),
]
2025-09-26 06:24:01 -04:00
@pytest.fixture
def sample_users_data(sample_users: UserList) -> list[UserData]:
"""Fixture to provide sample user data dictionaries for testing.
Args:
sample_users: The list of sample User objects.
Returns:
A list of dictionaries representing user data.
"""
return [{"email": user.email, "name": user.name} for user in sample_users]
2025-08-25 21:38:31 -04:00
@pytest.fixture
def sample_rooms() -> RoomList:
2025-08-26 15:47:08 -04:00
"""Fixture to provide sample Room objects for testing.
Returns:
A list of sample Room objects.
"""
2025-08-25 21:38:31 -04:00
return [
Room(
id=1,
name="Room 1",
location="Building A",
equipment="Projector",
capacity=10,
),
Room(
id=2,
name="Room 2",
location="Building B",
equipment="Whiteboard",
capacity=15,
),
]
@pytest.fixture
def sample_room(sample_rooms: RoomList) -> Room:
2025-08-26 15:47:08 -04:00
"""Fixture to provide a sample Room object for testing.
Args:
sample_rooms: The list of sample Room objects.
Returns:
A single sample Room object.
"""
2025-08-25 21:38:31 -04:00
return sample_rooms[0]
2025-09-26 06:24:01 -04:00
@pytest.fixture
2025-09-30 09:01:42 -04:00
def sample_room_data(sample_rooms: RoomList) -> RoomData:
2025-09-26 06:24:01 -04:00
"""Fixture to provide sample room creation data for testing.
Args:
sample_rooms: The list of sample Room objects.
Returns:
A dictionary with sample room creation data.
"""
room = sample_rooms[0]
return {
"id": room.id,
"name": room.name,
"location": room.location,
"equipment": room.equipment,
"capacity": room.capacity,
}
@pytest.fixture
def room_update_data() -> RoomUpdateData:
"""Fixture to provide sample room update data for testing.
Returns:
A dictionary with sample room update data.
"""
return {
"name": "Updated Room Name",
"location": "Updated Location",
"equipment": "Updated Equipment",
"capacity": 20,
}
@pytest.fixture
def updated_room(sample_room: Room, room_update_data: RoomUpdateData) -> Room:
"""Fixture to provide an updated Room object for testing.
Args:
sample_room: The original sample Room object.
room_update_data: The dictionary with updated room data.
Returns:
An updated Room object with new data applied.
"""
updated = Room(
id=sample_room.id,
name=room_update_data["name"],
location=room_update_data["location"],
equipment=room_update_data["equipment"],
capacity=room_update_data["capacity"],
)
return updated
2025-08-25 21:38:31 -04:00
@pytest.fixture
2025-09-30 09:01:42 -04:00
def utcnow() -> datetime:
"""Fixture to provide a reference UTC datetime for tests.
Returns:
A datetime object representing the current UTC time.
"""
return datetime.now(timezone.utc)
@pytest.fixture
def sample_bookings(utcnow: datetime, sample_room: Room) -> BookingList:
2025-08-26 15:47:08 -04:00
"""Fixture to provide sample Booking objects for testing.
Args:
2025-09-30 09:01:42 -04:00
utcnow: The reference UTC datetime.
2025-08-26 15:47:08 -04:00
sample_room: The sample Room object to attach to bookings.
Returns:
A list of sample Booking objects with attached room relationships.
"""
2025-09-30 09:01:42 -04:00
# Always set bookings in the future (current month or later)
2025-08-25 21:38:31 -04:00
booking1 = Booking(
room_id=1,
2025-09-30 09:01:42 -04:00
start_time=utcnow + timedelta(days=1, hours=3),
end_time=utcnow + timedelta(days=1, hours=4),
2025-08-27 17:56:42 -04:00
title="Sample Booking 1",
2025-09-26 06:24:01 -04:00
invitees=["user1@example.com", "user2@example.com"],
2025-08-25 21:38:31 -04:00
)
2025-09-30 09:01:42 -04:00
booking1.id = 1
booking1.room = sample_room
2025-08-25 21:38:31 -04:00
booking2 = Booking(
room_id=1,
2025-09-30 09:01:42 -04:00
start_time=utcnow + timedelta(days=1, hours=12),
end_time=utcnow + timedelta(days=1, hours=13),
2025-08-27 17:56:42 -04:00
title="Sample Booking 2",
2025-09-26 06:24:01 -04:00
invitees=["user1@example.com", "user2@example.com", "user3@example.com"],
2025-08-25 21:38:31 -04:00
)
2025-09-30 09:01:42 -04:00
booking2.id = 2
booking2.room = sample_room
2025-08-25 21:38:31 -04:00
return [booking1, booking2]
@pytest.fixture
def sample_booking(sample_bookings: BookingList) -> Booking:
2025-08-26 15:47:08 -04:00
"""Fixture to provide a sample Booking object for testing.
Args:
sample_bookings: The list of sample Booking objects.
Returns:
A single sample Booking object.
"""
2025-08-25 21:38:31 -04:00
return sample_bookings[0]
@pytest.fixture
2025-09-26 06:24:01 -04:00
def sample_booking_data(sample_booking: Booking) -> BookingCreateData:
"""Fixture to provide sample booking creation data for testing.
2025-08-26 15:47:08 -04:00
Args:
2025-09-26 06:24:01 -04:00
sample_booking: The sample Booking object to base the data on.
2025-08-26 15:47:08 -04:00
Returns:
2025-09-26 06:24:01 -04:00
A dictionary with sample booking creation data.
2025-08-26 15:47:08 -04:00
"""
2025-09-26 06:24:01 -04:00
return {
"room_id": sample_booking.room_id,
"start_time": sample_booking.start_time.isoformat(),
"end_time": sample_booking.end_time.isoformat(),
"title": sample_booking.title,
"invitees": sample_booking.invitees,
}
2025-08-26 17:15:56 -04:00
@pytest.fixture
2025-09-26 06:24:01 -04:00
def conflict_booking(sample_booking: Booking) -> Booking:
"""Fixture to provide a Booking object that conflicts with sample_booking.
2025-08-26 17:15:56 -04:00
Args:
2025-09-26 06:24:01 -04:00
sample_booking: The sample Booking object to base the conflict on.
2025-08-26 17:15:56 -04:00
Returns:
2025-09-26 06:24:01 -04:00
A Booking object that overlaps in time with sample_booking.
2025-08-26 17:15:56 -04:00
"""
2025-09-26 06:24:01 -04:00
conflict = Booking(
room_id=sample_booking.room_id,
start_time=sample_booking.start_time + timedelta(minutes=1),
end_time=sample_booking.end_time + timedelta(minutes=1),
title="Conflicting Booking",
invitees=["user3@example.com", "user4@example.com"],
)
conflict.id = 3
conflict.room = sample_booking.room # Preserve room relationship
return conflict
@pytest.fixture
def conflict_booking_data(conflict_booking: Booking) -> BookingCreateData:
"""Helper function to generate booking creation data for a conflicting booking.
Args:
conflict_booking: The conflicting Booking object.
Returns:
A dictionary with booking creation data that would cause a conflict.
"""
return {
"room_id": conflict_booking.room_id,
"start_time": conflict_booking.start_time.isoformat(),
"end_time": conflict_booking.end_time.isoformat(),
"title": conflict_booking.title,
"invitees": conflict_booking.invitees,
}
@pytest.fixture
2025-09-30 09:01:42 -04:00
def booking_update_data(utcnow: datetime, sample_booking: Booking) -> BookingUpdateData:
2025-09-26 06:24:01 -04:00
"""Fixture to provide sample booking update data for testing.
Args:
2025-09-30 09:01:42 -04:00
utcnow: The reference UTC datetime.
2025-09-26 06:24:01 -04:00
sample_booking: The sample Booking object to base the data on.
Returns:
A dictionary with sample booking update data.
"""
return {
"id": sample_booking.id,
"title": "Updated Booking Title",
2025-09-30 09:01:42 -04:00
"start_time": (utcnow + timedelta(hours=5)).isoformat(),
"end_time": (utcnow + timedelta(hours=6)).isoformat(),
2025-09-26 06:24:01 -04:00
}
@pytest.fixture
def updated_booking(
sample_booking: Booking, booking_update_data: BookingUpdateData
) -> Booking:
"""Fixture to provide an updated Booking object for testing.
Args:
sample_booking: The original sample Booking object.
booking_update_data: The dictionary with updated booking data.
Returns:
An updated Booking object with new data applied.
"""
updated = Booking(
room_id=2, # Match the update data in the test
2025-09-30 11:31:21 -04:00
title=booking_update_data.get("title", sample_booking.title),
2025-09-26 06:24:01 -04:00
start_time=datetime.fromisoformat(
2025-09-30 11:31:21 -04:00
booking_update_data.get("start_time", sample_booking.start_time.isoformat())
2025-09-26 06:24:01 -04:00
),
end_time=datetime.fromisoformat(
2025-09-30 11:31:21 -04:00
booking_update_data.get("end_time", sample_booking.end_time.isoformat())
2025-09-26 06:24:01 -04:00
),
invitees=sample_booking.invitees,
)
updated.id = sample_booking.id # Preserve ID
updated.room = sample_booking.room # Preserve room relationship
return updated
@pytest.fixture
def conflict_booking_update_data(sample_bookings: BookingList) -> BookingUpdateData:
"""Fixture to provide booking update data that would cause a conflict.
Args:
sample_bookings: The list of sample Booking objects.
Returns:
A dictionary with booking update data that would cause a conflict.
"""
return {
"id": sample_bookings[0].id,
"title": "Conflict Update",
"room_id": sample_bookings[1].room_id,
"start_time": sample_bookings[1].start_time.isoformat(),
"end_time": sample_bookings[1].end_time.isoformat(),
}
2025-09-03 17:47:01 -04:00
@pytest.fixture(autouse=True)
def override_exception_handler() -> Generator[None, None, None]:
2025-09-26 06:24:01 -04:00
"""Override exception handlers for testing to ensure correct status codes.
2025-09-03 17:47:01 -04:00
Yields:
None
"""
2025-09-26 06:24:01 -04:00
original_handlers: dict[type[Exception], HandlerType[Exception] | None] = {
Exception: app.exception_handlers.get(Exception),
NoResultFound: app.exception_handlers.get(NoResultFound),
ValueError: app.exception_handlers.get(ValueError),
SQLAlchemyError: app.exception_handlers.get(SQLAlchemyError),
}
2025-09-03 17:47:01 -04:00
async def test_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(
status_code=500, content={"detail": "Internal server error"}
)
2025-09-26 06:24:01 -04:00
async def not_found_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=404, content={"detail": "Booking not found"})
async def value_error_handler(request: Request, exc: Exception) -> JSONResponse:
# Use 409 for booking conflict, 400 for other value errors
detail = str(exc)
if "overlap" in detail:
return JSONResponse(status_code=409, content={"detail": detail})
return JSONResponse(status_code=400, content={"detail": detail})
async def sqlalchemy_error_handler(
request: Request, exc: Exception
) -> JSONResponse:
return JSONResponse(status_code=500, content={"detail": "Database error"})
2025-09-03 17:47:01 -04:00
app.add_exception_handler(Exception, test_exception_handler)
2025-09-26 06:24:01 -04:00
app.add_exception_handler(NoResultFound, not_found_handler)
app.add_exception_handler(ValueError, value_error_handler)
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
2025-09-03 17:47:01 -04:00
yield
2025-09-26 06:24:01 -04:00
# Restore original handlers
for exc_type, handler in original_handlers.items():
if handler is not None:
app.add_exception_handler(exc_type, handler)
else:
if exc_type in app.exception_handlers:
del app.exception_handlers[exc_type]
2025-09-30 09:01:42 -04:00
@pytest.fixture
def booking_create(sample_booking: Booking) -> BookingCreate:
"""Fixture for BookingCreate input data for create tests, derived from sample_booking.
Args:
sample_booking: The sample Booking object to base the creation data on.
Returns:
A BookingCreate object with the relevant data.
"""
return BookingCreate(
room_id=sample_booking.room_id,
start_time=sample_booking.start_time,
end_time=sample_booking.end_time,
title=sample_booking.title,
invitees=list(sample_booking.invitees),
)