Add check to both user and admin cancel endpoints to reject cancellation of appointments whose slot_start is in the past. This matches the spec requirement that cancellations can only happen 'before the appointment'. Added tests for both user and admin cancel endpoints. Also includes frontend styling updates.
858 lines
34 KiB
Python
858 lines
34 KiB
Python
"""
|
|
Booking API Tests
|
|
|
|
Tests for the user booking endpoints.
|
|
"""
|
|
from datetime import date, datetime, timedelta, timezone
|
|
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 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
|
|
|
|
@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(timezone.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()
|
|
assert len(data) >= 1
|
|
assert any(apt["note"] == "Test" for apt in data)
|
|
|
|
@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(timezone.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()
|
|
|