Phase 5: User appointments view and cancellation with UI and e2e tests
This commit is contained in:
parent
8ff03a8ec3
commit
5108a620e7
14 changed files with 1539 additions and 4 deletions
|
|
@ -48,4 +48,5 @@ app.include_router(invites_routes.router)
|
|||
app.include_router(invites_routes.admin_router)
|
||||
app.include_router(availability_routes.router)
|
||||
app.include_router(booking_routes.router)
|
||||
app.include_router(booking_routes.appointments_router)
|
||||
app.include_router(meta_routes.router)
|
||||
|
|
|
|||
|
|
@ -195,3 +195,86 @@ async def create_booking(
|
|||
cancelled_at=appointment.cancelled_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User's Appointments Endpoints
|
||||
# =============================================================================
|
||||
|
||||
appointments_router = APIRouter(prefix="/api/appointments", tags=["appointments"])
|
||||
|
||||
|
||||
@appointments_router.get("", response_model=list[AppointmentResponse])
|
||||
async def get_my_appointments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_permission(Permission.VIEW_OWN_APPOINTMENTS)),
|
||||
) -> list[AppointmentResponse]:
|
||||
"""Get the current user's appointments, sorted by date (upcoming first)."""
|
||||
result = await db.execute(
|
||||
select(Appointment)
|
||||
.where(Appointment.user_id == current_user.id)
|
||||
.order_by(Appointment.slot_start.desc())
|
||||
)
|
||||
appointments = result.scalars().all()
|
||||
|
||||
return [
|
||||
AppointmentResponse(
|
||||
id=apt.id,
|
||||
user_id=apt.user_id,
|
||||
user_email=current_user.email,
|
||||
slot_start=apt.slot_start,
|
||||
slot_end=apt.slot_end,
|
||||
note=apt.note,
|
||||
status=apt.status.value,
|
||||
created_at=apt.created_at,
|
||||
cancelled_at=apt.cancelled_at,
|
||||
)
|
||||
for apt in appointments
|
||||
]
|
||||
|
||||
|
||||
@appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
|
||||
async def cancel_my_appointment(
|
||||
appointment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)),
|
||||
) -> AppointmentResponse:
|
||||
"""Cancel one of the current user's appointments."""
|
||||
# Get the appointment
|
||||
result = await db.execute(
|
||||
select(Appointment).where(Appointment.id == appointment_id)
|
||||
)
|
||||
appointment = result.scalar_one_or_none()
|
||||
|
||||
if not appointment:
|
||||
raise HTTPException(status_code=404, detail="Appointment not found")
|
||||
|
||||
# Verify ownership
|
||||
if appointment.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Cannot cancel another user's appointment")
|
||||
|
||||
# Check if already cancelled
|
||||
if appointment.status != AppointmentStatus.BOOKED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot cancel appointment with status '{appointment.status.value}'"
|
||||
)
|
||||
|
||||
# Cancel the appointment
|
||||
appointment.status = AppointmentStatus.CANCELLED_BY_USER
|
||||
appointment.cancelled_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
|
||||
return AppointmentResponse(
|
||||
id=appointment.id,
|
||||
user_id=appointment.user_id,
|
||||
user_email=current_user.email,
|
||||
slot_start=appointment.slot_start,
|
||||
slot_end=appointment.slot_end,
|
||||
note=appointment.note,
|
||||
status=appointment.status.value,
|
||||
created_at=appointment.created_at,
|
||||
cancelled_at=appointment.cancelled_at,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -447,3 +447,212 @@ class TestBookingNoteValidation:
|
|||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue