arbret/backend/tests/test_booking.py

658 lines
26 KiB
Python

"""
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
# =============================================================================
# User Appointments Tests
# =============================================================================
class TestUserAppointments:
"""Test user appointments endpoints."""
@pytest.mark.asyncio
async def test_get_my_appointments_empty(self, client_factory, regular_user):
"""Returns empty list when user has no appointments."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/appointments")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_get_my_appointments_with_bookings(self, client_factory, regular_user, admin_user):
"""Returns user's appointments."""
# 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 two slots
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": "First"},
)
await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:15:00Z", "note": "Second"},
)
# Get appointments
response = await client.get("/api/appointments")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
# Sorted by date descending
notes = [apt["note"] for apt in data]
assert "First" in notes
assert "Second" in notes
@pytest.mark.asyncio
async def test_admin_cannot_view_user_appointments(self, client_factory, admin_user):
"""Admin cannot access user appointments endpoint."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/appointments")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_appointments(self, client):
"""Unauthenticated user cannot view appointments."""
response = await client.get("/api/appointments")
assert response.status_code == 401
class TestCancelAppointment:
"""Test cancelling appointments."""
@pytest.mark.asyncio
async def test_cancel_own_appointment(self, client_factory, regular_user, admin_user):
"""User can cancel their own appointment."""
# 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:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# Cancel
response = await client.post(f"/api/appointments/{apt_id}/cancel")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled_by_user"
assert data["cancelled_at"] is not None
@pytest.mark.asyncio
async def test_cannot_cancel_others_appointment(self, client_factory, regular_user, alt_regular_user, admin_user):
"""User cannot cancel another user's appointment."""
# 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:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# Second user tries to cancel
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client:
response = await client.post(f"/api/appointments/{apt_id}/cancel")
assert response.status_code == 403
assert "another user" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_cannot_cancel_nonexistent_appointment(self, client_factory, regular_user):
"""Returns 404 for non-existent appointment."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post("/api/appointments/99999/cancel")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_cannot_cancel_already_cancelled(self, client_factory, regular_user, admin_user):
"""Cannot cancel an already cancelled appointment."""
# 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 and cancels
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
await client.post(f"/api/appointments/{apt_id}/cancel")
# Try to cancel again
response = await client.post(f"/api/appointments/{apt_id}/cancel")
assert response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"]
@pytest.mark.asyncio
async def test_admin_cannot_use_user_cancel_endpoint(self, client_factory, admin_user):
"""Admin cannot use user cancel endpoint."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post("/api/appointments/1/cancel")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_cancelled_slot_becomes_available(self, client_factory, regular_user, admin_user):
"""After cancelling, the slot becomes available again."""
# 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": "09:30:00"}],
},
)
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# Check slots - should have 1 slot left (09:15)
slots_response = await client.get(
"/api/booking/slots",
params={"date": str(tomorrow())},
)
assert len(slots_response.json()["slots"]) == 1
# Cancel
await client.post(f"/api/appointments/{apt_id}/cancel")
# Check slots - should have 2 slots now
slots_response = await client.get(
"/api/booking/slots",
params={"date": str(tomorrow())},
)
assert len(slots_response.json()["slots"]) == 2