""" Booking API Tests Tests for the user booking endpoints. """ from datetime import date, timedelta import pytest def tomorrow() -> date: return date.today() + timedelta(days=1) def in_days(n: int) -> date: return date.today() + timedelta(days=n) # ============================================================================= # Permission Tests # ============================================================================= class TestBookingPermissions: """Test that only regular users can book appointments.""" @pytest.mark.asyncio async def test_regular_user_can_get_slots(self, client_factory, regular_user, admin_user): """Regular user can get available slots.""" # First, admin sets up availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # Regular user gets slots async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) assert response.status_code == 200 @pytest.mark.asyncio async def test_regular_user_can_book(self, client_factory, regular_user, admin_user): """Regular user can book an appointment.""" # Admin sets up availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # Regular user books async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": "Test booking"}, ) assert response.status_code == 200 @pytest.mark.asyncio async def test_admin_cannot_get_slots(self, client_factory, admin_user): """Admin cannot access booking slots endpoint.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) assert response.status_code == 403 @pytest.mark.asyncio async def test_admin_cannot_book(self, client_factory, admin_user): """Admin cannot book appointments.""" # Admin sets up availability first async with client_factory.create(cookies=admin_user["cookies"]) as client: await client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z"}, ) assert response.status_code == 403 @pytest.mark.asyncio async def test_unauthenticated_cannot_get_slots(self, client): """Unauthenticated user cannot get slots.""" response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) assert response.status_code == 401 @pytest.mark.asyncio async def test_unauthenticated_cannot_book(self, client): """Unauthenticated user cannot book.""" response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z"}, ) assert response.status_code == 401 # ============================================================================= # Get Slots Tests # ============================================================================= class TestGetSlots: """Test getting available booking slots.""" @pytest.mark.asyncio async def test_get_slots_no_availability(self, client_factory, regular_user): """Returns empty slots when no availability set.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) assert response.status_code == 200 data = response.json() assert data["date"] == str(tomorrow()) assert data["slots"] == [] @pytest.mark.asyncio async def test_get_slots_expands_to_15min(self, client_factory, regular_user, admin_user): """Availability is expanded into 15-minute slots.""" # Admin sets 1-hour availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}], }, ) # User gets slots - should be 4 x 15-minute slots async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) assert response.status_code == 200 data = response.json() assert len(data["slots"]) == 4 # Verify times assert "09:00:00" in data["slots"][0]["start_time"] assert "09:15:00" in data["slots"][0]["end_time"] assert "09:15:00" in data["slots"][1]["start_time"] assert "09:45:00" in data["slots"][3]["start_time"] assert "10:00:00" in data["slots"][3]["end_time"] @pytest.mark.asyncio async def test_get_slots_excludes_booked(self, client_factory, regular_user, admin_user): """Already booked slots are excluded from available slots.""" # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}], }, ) # User books first slot async with client_factory.create(cookies=regular_user["cookies"]) as client: await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z"}, ) # Get slots again - should have 3 left response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) assert response.status_code == 200 data = response.json() assert len(data["slots"]) == 3 # First slot should now be 09:15 assert "09:15:00" in data["slots"][0]["start_time"] # ============================================================================= # Booking Tests # ============================================================================= class TestCreateBooking: """Test creating bookings.""" @pytest.mark.asyncio async def test_book_slot_success(self, client_factory, regular_user, admin_user): """Can successfully book an available slot.""" # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # User books async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={ "slot_start": f"{tomorrow()}T09:00:00Z", "note": "Discussion about project", }, ) assert response.status_code == 200 data = response.json() assert data["user_id"] == regular_user["user"]["id"] assert data["note"] == "Discussion about project" assert data["status"] == "booked" assert "09:00:00" in data["slot_start"] assert "09:15:00" in data["slot_end"] @pytest.mark.asyncio async def test_book_without_note(self, client_factory, regular_user, admin_user): """Can book without a note.""" # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # User books without note async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z"}, ) assert response.status_code == 200 data = response.json() assert data["note"] is None @pytest.mark.asyncio async def test_cannot_double_book_slot(self, client_factory, regular_user, admin_user, alt_regular_user): """Cannot book a slot that's already booked.""" # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # First user books async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z"}, ) assert response.status_code == 200 # Second user tries to book same slot async with client_factory.create(cookies=alt_regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z"}, ) assert response.status_code == 409 assert "already been booked" in response.json()["detail"] @pytest.mark.asyncio async def test_cannot_book_outside_availability(self, client_factory, regular_user, admin_user): """Cannot book a slot outside of availability.""" # Admin sets availability for morning only async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # User tries to book afternoon slot async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T14:00:00Z"}, ) assert response.status_code == 400 assert "not within available" in response.json()["detail"] # ============================================================================= # Date Validation Tests # ============================================================================= class TestBookingDateValidation: """Test date validation for bookings.""" @pytest.mark.asyncio async def test_cannot_book_today(self, client_factory, regular_user): """Cannot book for today (same day).""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{date.today()}T09:00:00Z"}, ) assert response.status_code == 400 assert "past" in response.json()["detail"].lower() or "today" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_cannot_book_past_date(self, client_factory, regular_user): """Cannot book for past date.""" yesterday = date.today() - timedelta(days=1) async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{yesterday}T09:00:00Z"}, ) assert response.status_code == 400 @pytest.mark.asyncio async def test_cannot_book_beyond_30_days(self, client_factory, regular_user): """Cannot book more than 30 days in advance.""" too_far = in_days(31) async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{too_far}T09:00:00Z"}, ) assert response.status_code == 400 assert "30" in response.json()["detail"] @pytest.mark.asyncio async def test_cannot_get_slots_today(self, client_factory, regular_user): """Cannot get slots for today.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/booking/slots", params={"date": str(date.today())}) assert response.status_code == 400 @pytest.mark.asyncio async def test_cannot_get_slots_past(self, client_factory, regular_user): """Cannot get slots for past date.""" yesterday = date.today() - timedelta(days=1) async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/booking/slots", params={"date": str(yesterday)}) assert response.status_code == 400 # ============================================================================= # Time Validation Tests # ============================================================================= class TestBookingTimeValidation: """Test time validation for bookings.""" @pytest.mark.asyncio async def test_slot_must_be_15min_boundary(self, client_factory, regular_user, admin_user): """Slot start time must be on 15-minute boundary.""" # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # User tries to book at 09:05 async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:05:00Z"}, ) assert response.status_code == 400 assert "15-minute" in response.json()["detail"] # ============================================================================= # Note Validation Tests # ============================================================================= class TestBookingNoteValidation: """Test note validation for bookings.""" @pytest.mark.asyncio async def test_note_max_length(self, client_factory, regular_user, admin_user): """Note cannot exceed 144 characters.""" # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # User tries to book with long note long_note = "x" * 145 async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": long_note}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_note_exactly_144_chars(self, client_factory, regular_user, admin_user): """Note of exactly 144 characters is allowed.""" # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], }, ) # User books with exactly 144 char note note = "x" * 144 async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/booking", json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": note}, ) assert response.status_code == 200 assert response.json()["note"] == note