arbret/backend/tests/test_booking.py
counterweight 6c218130e9
Add ruff linter/formatter for Python
- Add ruff as dev dependency
- Configure ruff in pyproject.toml with strict 88-char line limit
- Ignore B008 (FastAPI Depends pattern is standard)
- Allow longer lines in tests for readability
- Fix all lint issues in source files
- Add Makefile targets: lint-backend, format-backend, fix-backend
2025-12-21 21:54:26 +01:00

943 lines
35 KiB
Python

"""
Booking API Tests
Tests for the user booking endpoints.
"""
from datetime import UTC, date, datetime, timedelta
import pytest
from models import Appointment, AppointmentStatus
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 any available time ranges" 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
@pytest.mark.asyncio
async def test_cannot_cancel_past_appointment(self, client_factory, regular_user):
"""User cannot cancel a past appointment."""
# Create a past appointment directly in DB
async with client_factory.get_db_session() as db:
past_time = datetime.now(UTC) - timedelta(hours=1)
appointment = Appointment(
user_id=regular_user["user"]["id"],
slot_start=past_time,
slot_end=past_time + timedelta(minutes=15),
status=AppointmentStatus.BOOKED,
)
db.add(appointment)
await db.commit()
await db.refresh(appointment)
apt_id = appointment.id
# Try to cancel
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/appointments/{apt_id}/cancel")
assert response.status_code == 400
assert "past" in response.json()["detail"].lower()
# =============================================================================
# Admin Appointments Tests
# =============================================================================
class TestAdminViewAppointments:
"""Test admin viewing all appointments."""
@pytest.mark.asyncio
async def test_admin_can_view_all_appointments(
self, client_factory, regular_user, admin_user
):
"""Admin can view all 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
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": "Test"},
)
# Admin views all appointments
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
response = await admin_client.get("/api/admin/appointments")
assert response.status_code == 200
data = response.json()
# Paginated response
assert "records" in data
assert "total" in data
assert "page" in data
assert "per_page" in data
assert len(data["records"]) >= 1
assert any(apt["note"] == "Test" for apt in data["records"])
@pytest.mark.asyncio
async def test_regular_user_cannot_view_all_appointments(
self, client_factory, regular_user
):
"""Regular user cannot access admin appointments endpoint."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/admin/appointments")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_all_appointments(self, client):
"""Unauthenticated user cannot view appointments."""
response = await client.get("/api/admin/appointments")
assert response.status_code == 401
class TestAdminCancelAppointment:
"""Test admin cancelling appointments."""
@pytest.mark.asyncio
async def test_admin_can_cancel_any_appointment(
self, client_factory, regular_user, admin_user
):
"""Admin can cancel any 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"}],
},
)
# 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"]
# Admin cancels
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
response = await admin_client.post(
f"/api/admin/appointments/{apt_id}/cancel"
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled_by_admin"
assert data["cancelled_at"] is not None
@pytest.mark.asyncio
async def test_regular_user_cannot_use_admin_cancel(
self, client_factory, regular_user, admin_user
):
"""Regular user cannot use admin cancel endpoint."""
# 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"]
# User tries to use admin cancel endpoint
response = await client.post(f"/api/admin/appointments/{apt_id}/cancel")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_admin_cancel_nonexistent_appointment(
self, client_factory, admin_user
):
"""Returns 404 for non-existent appointment."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post("/api/admin/appointments/99999/cancel")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_admin_cannot_cancel_already_cancelled(
self, client_factory, regular_user, admin_user
):
"""Admin 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
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"]
# User cancels their own appointment
await client.post(f"/api/appointments/{apt_id}/cancel")
# Admin tries to cancel again
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
response = await admin_client.post(
f"/api/admin/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_cancel_past_appointment(
self, client_factory, regular_user, admin_user
):
"""Admin cannot cancel a past appointment."""
# Create a past appointment directly in DB
async with client_factory.get_db_session() as db:
past_time = datetime.now(UTC) - timedelta(hours=1)
appointment = Appointment(
user_id=regular_user["user"]["id"],
slot_start=past_time,
slot_end=past_time + timedelta(minutes=15),
status=AppointmentStatus.BOOKED,
)
db.add(appointment)
await db.commit()
await db.refresh(appointment)
apt_id = appointment.id
# Admin tries to cancel
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
response = await admin_client.post(
f"/api/admin/appointments/{apt_id}/cancel"
)
assert response.status_code == 400
assert "past" in response.json()["detail"].lower()