"""Unit tests for the backend.routers.bookings module.""" from datetime import datetime from typing import Any from unittest.mock import ANY from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch import pytest from httpx import AsyncClient from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import SQLAlchemyError from backend.models import Booking from backend.models import BookingList 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: AsyncClient, sample_bookings: BookingList, mock_logger: MagicMock ) -> None: """Test successful retrieval of all bookings for a room via GET /bookings/room/{room_id}. Verifies that the endpoint returns the expected list of bookings with a 200 status. Args: client: The FastAPI test client for making HTTP requests. sample_bookings: The mocked list of bookings to return. mock_logger: The mocked logger instance. """ room_id = 1 with ( patch( "backend.routers.bookings.get_bookings_for_room", new=AsyncMock() ) as mock_get_bookings, patch( "backend.routers.bookings.get_invitees_for_booking", new=AsyncMock() ) as mock_get_invitees, patch("backend.routers.bookings.get_room", new=AsyncMock()) as mock_get_room, ): mock_get_bookings.return_value = sample_bookings mock_get_invitees.return_value = [] mock_get_room.return_value = None response = await client.get(f"/bookings/room/{room_id}") assert response.status_code == 200 actual = response.json() expected: list[dict[str, Any]] = [ { "id": booking.id, "room_id": booking.room_id, "start_time": booking.start_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "end_time": booking.end_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "title": booking.title, "invitees": [], } for booking in sample_bookings ] # Remove 'room' key if present for item in actual: item.pop("room", None) assert actual == expected mock_get_bookings.assert_called_once_with(ANY, room_id, None) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_get_bookings_for_room_empty( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test retrieval of bookings when none exist for a room via GET /bookings/room/{room_id}. Verifies that the endpoint returns an empty list with a 200 status. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ room_id = 1 with patch( "backend.routers.bookings.get_bookings_for_room", new=AsyncMock() ) as mock_get_bookings: mock_get_bookings.return_value = [] response = await client.get(f"/bookings/room/{room_id}") assert response.status_code == 200 assert response.json() == [] mock_get_bookings.assert_called_once_with(ANY, room_id, None) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_get_bookings_for_room_database_error( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of database errors in GET /bookings/room/{room_id}. Verifies that the endpoint returns a 500 status on database failure. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ room_id = 1 with patch( "backend.routers.bookings.get_bookings_for_room", new=AsyncMock() ) as mock_get_bookings: mock_get_bookings.side_effect = SQLAlchemyError() response = await client.get(f"/bookings/room/{room_id}") assert response.status_code == 500 assert "detail" in response.json() mock_get_bookings.assert_called_once_with(ANY, room_id, None) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_get_booking_success( client: AsyncClient, sample_room: Room, mock_logger: MagicMock ) -> None: """Test successful retrieval of a booking by ID via GET /bookings/{booking_id}. Verifies that the endpoint returns the correct booking with a 200 status. Args: client: The FastAPI test client for making HTTP requests. sample_room: The mocked room object. mock_logger: The mocked logger instance. """ booking_id = 1 booking = Booking( room_id=1, start_time=datetime.fromisoformat("2025-08-25T10:00:00"), end_time=datetime.fromisoformat("2025-08-25T11:00:00"), ) booking.id = booking_id booking.room = sample_room # Attach room relationship with ( patch( "backend.routers.bookings.get_booking", new=AsyncMock() ) as mock_get_booking, patch( "backend.routers.bookings.get_invitees_for_booking", new=AsyncMock() ) as mock_get_invitees, patch("backend.routers.bookings.get_room", new=AsyncMock()) as mock_get_room, ): mock_get_booking.return_value = booking mock_get_invitees.return_value = [] mock_get_room.return_value = sample_room response = await client.get(f"/bookings/{booking_id}") assert response.status_code == 200 actual = response.json() actual.pop("room", None) assert actual == { "id": booking_id, "room_id": 1, "start_time": "2025-08-25T10:00:00", "end_time": "2025-08-25T11:00:00", "title": None, "invitees": [], } mock_get_booking.assert_called_once_with(ANY, booking_id) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_get_booking_not_found( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of non-existent booking in GET /bookings/{booking_id}. Verifies that the endpoint returns a 404 status when the booking is not found. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ booking_id = 999 with patch( "backend.routers.bookings.get_booking", new=AsyncMock() ) as mock_get_booking: mock_get_booking.side_effect = NoResultFound() response = await client.get(f"/bookings/{booking_id}") assert response.status_code == 404 assert "detail" in response.json() mock_get_booking.assert_called_once_with(ANY, booking_id) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_get_booking_database_error( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of database errors in GET /bookings/{booking_id}. Verifies that the endpoint returns a 500 status on database failure. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ booking_id = 1 with patch( "backend.routers.bookings.get_booking", new=AsyncMock() ) as mock_get_booking: mock_get_booking.side_effect = SQLAlchemyError() response = await client.get(f"/bookings/{booking_id}") assert response.status_code == 500 assert "detail" in response.json() mock_get_booking.assert_called_once_with(ANY, booking_id) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_create_booking_success( client: AsyncClient, sample_room: Room, mock_logger: MagicMock ) -> None: """Test successful creation of a new booking via POST /bookings/. Verifies that the endpoint returns the created booking with a 201 status. Args: client: The FastAPI test client for making HTTP requests. sample_room: The mocked room object. mock_logger: The mocked logger instance. """ booking_data: dict[str, Any] = { "room_id": 1, "start_time": "2025-08-25T10:00:00", "end_time": "2025-08-25T11:00:00", "title": "Test Booking Title", } created_booking = Booking(**booking_data) created_booking.id = 3 created_booking.room = sample_room # Attach room relationship with ( patch( "backend.routers.bookings.new_booking", new=AsyncMock() ) as mock_new_booking, patch( "backend.routers.bookings.get_invitees_for_booking", new=AsyncMock() ) as mock_get_invitees, patch("backend.routers.bookings.get_room", new=AsyncMock()) as mock_get_room, ): mock_new_booking.return_value = created_booking mock_get_invitees.return_value = [] mock_get_room.return_value = sample_room response = await client.post("/bookings/", json=booking_data) assert response.status_code == 201 actual = response.json() actual.pop("room", None) assert actual == { "id": 3, "room_id": 1, "start_time": "2025-08-25T10:00:00", "end_time": "2025-08-25T11:00:00", "title": "Test Booking Title", "invitees": [], } mock_new_booking.assert_called_once() @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_create_booking_database_error( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of database errors in POST /bookings/. Verifies that the endpoint returns a 500 status on database failure. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ booking_data: dict[str, Any] = { "room_id": 1, "start_time": "2025-08-25T10:00:00", "end_time": "2025-08-25T11:00:00", } async def raise_sqlalchemy_error(*args, **kwargs): # type: ignore raise SQLAlchemyError() with ( patch( "backend.routers.bookings.new_booking", new=AsyncMock(side_effect=raise_sqlalchemy_error), ) as mock_new_booking, patch( "backend.routers.bookings.get_invitees_for_booking", new=AsyncMock(return_value=[]), ), patch("backend.routers.bookings.get_room", new=AsyncMock(return_value=None)), ): response = await client.post("/bookings/", json=booking_data) assert response.status_code == 500 assert "detail" in response.json() mock_new_booking.assert_called_once() @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_update_booking_success( client: AsyncClient, sample_room: Room, mock_logger: MagicMock ) -> None: """Test successful update of a booking via PUT /bookings/{booking_id}. Verifies that the endpoint returns the updated booking with a 200 status. Args: client: The FastAPI test client for making HTTP requests. sample_room: The mocked room object. mock_logger: The mocked logger instance. """ booking_id = 1 update_data: dict[str, Any] = { "room_id": 2, "start_time": "2025-08-25T14:00:00", "end_time": "2025-08-25T15:00:00", "title": "Updated Test Booking Title", } updated_booking = Booking(**update_data) updated_booking.id = booking_id updated_booking.room = sample_room # Attach room relationship with ( patch( "backend.routers.bookings.update_booking", new=AsyncMock() ) as mock_update_booking, patch( "backend.routers.bookings.get_invitees_for_booking", new=AsyncMock() ) as mock_get_invitees, patch("backend.routers.bookings.get_room", new=AsyncMock()) as mock_get_room, ): mock_update_booking.return_value = updated_booking mock_get_invitees.return_value = [] mock_get_room.return_value = sample_room response = await client.put(f"/bookings/{booking_id}", json=update_data) assert response.status_code == 200 actual = response.json() actual.pop("room", None) assert actual == { "id": booking_id, "room_id": 2, "start_time": "2025-08-25T14:00:00", "end_time": "2025-08-25T15:00:00", "title": "Updated Test Booking Title", "invitees": [], } 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, ANY, **update_data) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_update_booking_not_found( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of non-existent booking in PUT /bookings/{booking_id}. Verifies that the endpoint returns a 404 status when the booking is not found. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ booking_id = 999 update_data: dict[str, Any] = { "room_id": 2, "start_time": "2025-08-25T14:00:00", "end_time": "2025-08-25T15:00:00", "title": "Updated Test Booking Title", } with patch( "backend.routers.bookings.update_booking", new=AsyncMock() ) as mock_update_booking: mock_update_booking.side_effect = NoResultFound() 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, ANY, **update_data) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_update_booking_database_error( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of database errors in PUT /bookings/{booking_id}. Verifies that the endpoint returns a 500 status on database failure. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ booking_id = 1 update_data: dict[str, Any] = { "room_id": 2, "start_time": "2025-08-25T14:00:00", "end_time": "2025-08-25T15:00:00", } async def raise_sqlalchemy_error(*args, **kwargs): # type: ignore raise SQLAlchemyError() with ( patch( "backend.routers.bookings.update_booking", new=AsyncMock(side_effect=raise_sqlalchemy_error), ) as mock_update_booking, patch( "backend.routers.bookings.get_invitees_for_booking", new=AsyncMock(return_value=[]), ), patch("backend.routers.bookings.get_room", new=AsyncMock(return_value=None)), ): response = await client.put(f"/bookings/{booking_id}", json=update_data) assert response.status_code == 500 assert "detail" in response.json() mock_update_booking.assert_called_once() @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_delete_booking_not_found( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of non-existent booking in DELETE /bookings/{booking_id}. Verifies that the endpoint returns a 404 status when the booking is not found. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ booking_id = 999 with patch( "backend.routers.bookings.delete_booking", new=AsyncMock() ) as mock_delete_booking: mock_delete_booking.side_effect = NoResultFound() 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, ANY) @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.routers.bookings"], indirect=True) async def test_delete_booking_database_error( client: AsyncClient, mock_logger: MagicMock ) -> None: """Test handling of database errors in DELETE /bookings/{booking_id}. Verifies that the endpoint returns a 500 status on database failure. Args: client: The FastAPI test client for making HTTP requests. mock_logger: The mocked logger instance. """ booking_id = 1 with patch( "backend.routers.bookings.delete_booking", new=AsyncMock() ) as mock_delete_booking: mock_delete_booking.side_effect = SQLAlchemyError() 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, ANY)