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
This commit is contained in:
counterweight 2025-12-21 21:54:26 +01:00
parent 69bc8413e0
commit 6c218130e9
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
31 changed files with 1234 additions and 876 deletions

View file

@ -3,7 +3,9 @@ Booking API Tests
Tests for the user booking endpoints.
"""
from datetime import date, datetime, timedelta, timezone
from datetime import UTC, date, datetime, timedelta
import pytest
from models import Appointment, AppointmentStatus
@ -21,11 +23,14 @@ def in_days(n: int) -> date:
# 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):
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:
@ -36,15 +41,19 @@ class TestBookingPermissions:
"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())})
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):
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:
@ -55,22 +64,24 @@ class TestBookingPermissions:
"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())})
response = await client.get(
"/api/booking/slots", params={"date": str(tomorrow())}
)
assert response.status_code == 403
@pytest.mark.asyncio
@ -85,18 +96,20 @@ class TestBookingPermissions:
"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())})
response = await client.get(
"/api/booking/slots", params={"date": str(tomorrow())}
)
assert response.status_code == 401
@pytest.mark.asyncio
@ -113,6 +126,7 @@ class TestBookingPermissions:
# Get Slots Tests
# =============================================================================
class TestGetSlots:
"""Test getting available booking slots."""
@ -120,15 +134,19 @@ class TestGetSlots:
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())})
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):
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:
@ -139,15 +157,17 @@ class TestGetSlots:
"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())})
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"]
@ -156,7 +176,9 @@ class TestGetSlots:
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):
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:
@ -167,17 +189,19 @@ class TestGetSlots:
"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())})
response = await client.get(
"/api/booking/slots", params={"date": str(tomorrow())}
)
assert response.status_code == 200
data = response.json()
assert len(data["slots"]) == 3
@ -189,6 +213,7 @@ class TestGetSlots:
# Booking Tests
# =============================================================================
class TestCreateBooking:
"""Test creating bookings."""
@ -204,7 +229,7 @@ class TestCreateBooking:
"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(
@ -214,7 +239,7 @@ class TestCreateBooking:
"note": "Discussion about project",
},
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == regular_user["user"]["id"]
@ -235,20 +260,22 @@ class TestCreateBooking:
"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):
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:
@ -259,7 +286,7 @@ class TestCreateBooking:
"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(
@ -267,19 +294,21 @@ class TestCreateBooking:
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):
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:
@ -290,14 +319,14 @@ class TestCreateBooking:
"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"]
@ -306,6 +335,7 @@ class TestCreateBooking:
# Date Validation Tests
# =============================================================================
class TestBookingDateValidation:
"""Test date validation for bookings."""
@ -317,9 +347,12 @@ class TestBookingDateValidation:
"/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()
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):
@ -330,7 +363,7 @@ class TestBookingDateValidation:
"/api/booking",
json={"slot_start": f"{yesterday}T09:00:00Z"},
)
assert response.status_code == 400
@pytest.mark.asyncio
@ -342,7 +375,7 @@ class TestBookingDateValidation:
"/api/booking",
json={"slot_start": f"{too_far}T09:00:00Z"},
)
assert response.status_code == 400
assert "30" in response.json()["detail"]
@ -350,8 +383,10 @@ class TestBookingDateValidation:
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())})
response = await client.get(
"/api/booking/slots", params={"date": str(date.today())}
)
assert response.status_code == 400
@pytest.mark.asyncio
@ -359,8 +394,10 @@ class TestBookingDateValidation:
"""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)})
response = await client.get(
"/api/booking/slots", params={"date": str(yesterday)}
)
assert response.status_code == 400
@ -368,11 +405,14 @@ class TestBookingDateValidation:
# 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):
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:
@ -383,14 +423,14 @@ class TestBookingTimeValidation:
"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"]
@ -399,6 +439,7 @@ class TestBookingTimeValidation:
# Note Validation Tests
# =============================================================================
class TestBookingNoteValidation:
"""Test note validation for bookings."""
@ -414,7 +455,7 @@ class TestBookingNoteValidation:
"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:
@ -422,11 +463,13 @@ class TestBookingNoteValidation:
"/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):
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:
@ -437,7 +480,7 @@ class TestBookingNoteValidation:
"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:
@ -445,7 +488,7 @@ class TestBookingNoteValidation:
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": note},
)
assert response.status_code == 200
assert response.json()["note"] == note
@ -454,6 +497,7 @@ class TestBookingNoteValidation:
# User Appointments Tests
# =============================================================================
class TestUserAppointments:
"""Test user appointments endpoints."""
@ -462,12 +506,14 @@ class TestUserAppointments:
"""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):
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:
@ -478,7 +524,7 @@ class TestUserAppointments:
"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(
@ -489,10 +535,10 @@ class TestUserAppointments:
"/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
@ -502,11 +548,13 @@ class TestUserAppointments:
assert "Second" in notes
@pytest.mark.asyncio
async def test_admin_cannot_view_user_appointments(self, client_factory, admin_user):
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
@ -520,7 +568,9 @@ class TestCancelAppointment:
"""Test cancelling appointments."""
@pytest.mark.asyncio
async def test_cancel_own_appointment(self, client_factory, regular_user, admin_user):
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:
@ -531,7 +581,7 @@ class TestCancelAppointment:
"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(
@ -539,17 +589,19 @@ class TestCancelAppointment:
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):
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:
@ -560,7 +612,7 @@ class TestCancelAppointment:
"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(
@ -568,24 +620,28 @@ class TestCancelAppointment:
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):
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):
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:
@ -596,7 +652,7 @@ class TestCancelAppointment:
"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(
@ -605,23 +661,27 @@ class TestCancelAppointment:
)
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):
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):
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:
@ -632,7 +692,7 @@ class TestCancelAppointment:
"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(
@ -640,17 +700,17 @@ class TestCancelAppointment:
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",
@ -663,7 +723,7 @@ class TestCancelAppointment:
"""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)
past_time = datetime.now(UTC) - timedelta(hours=1)
appointment = Appointment(
user_id=regular_user["user"]["id"],
slot_start=past_time,
@ -674,11 +734,11 @@ class TestCancelAppointment:
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()
@ -687,11 +747,14 @@ class TestCancelAppointment:
# 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):
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:
@ -702,18 +765,18 @@ class TestAdminViewAppointments:
"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
@ -725,11 +788,13 @@ class TestAdminViewAppointments:
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):
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
@ -743,7 +808,9 @@ class TestAdminCancelAppointment:
"""Test admin cancelling appointments."""
@pytest.mark.asyncio
async def test_admin_can_cancel_any_appointment(self, client_factory, regular_user, admin_user):
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:
@ -754,7 +821,7 @@ class TestAdminCancelAppointment:
"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(
@ -762,18 +829,22 @@ class TestAdminCancelAppointment:
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")
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):
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:
@ -784,7 +855,7 @@ class TestAdminCancelAppointment:
"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(
@ -792,22 +863,26 @@ class TestAdminCancelAppointment:
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):
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):
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:
@ -818,7 +893,7 @@ class TestAdminCancelAppointment:
"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(
@ -826,23 +901,27 @@ class TestAdminCancelAppointment:
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")
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):
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)
past_time = datetime.now(UTC) - timedelta(hours=1)
appointment = Appointment(
user_id=regular_user["user"]["id"],
slot_start=past_time,
@ -853,11 +932,12 @@ class TestAdminCancelAppointment:
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")
response = await admin_client.post(
f"/api/admin/appointments/{apt_id}/cancel"
)
assert response.status_code == 400
assert "past" in response.json()["detail"].lower()