Phase 3: Appointment model & booking API with timezone fix
This commit is contained in:
parent
f6cf093cb1
commit
06817875f7
9 changed files with 946 additions and 9 deletions
|
|
@ -13,6 +13,7 @@ from routes import invites as invites_routes
|
|||
from routes import auth as auth_routes
|
||||
from routes import meta as meta_routes
|
||||
from routes import availability as availability_routes
|
||||
from routes import booking as booking_routes
|
||||
from validate_constants import validate_shared_constants
|
||||
|
||||
|
||||
|
|
@ -46,4 +47,5 @@ app.include_router(profile_routes.router)
|
|||
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(meta_routes.router)
|
||||
|
|
|
|||
|
|
@ -269,3 +269,27 @@ class Availability(Base):
|
|||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class Appointment(Base):
|
||||
"""User appointment bookings."""
|
||||
__tablename__ = "appointments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("slot_start", name="uq_appointment_slot_start"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
user: Mapped[User] = relationship("User", foreign_keys=[user_id], lazy="joined")
|
||||
slot_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(String(144), nullable=True)
|
||||
status: Mapped[AppointmentStatus] = mapped_column(
|
||||
Enum(AppointmentStatus), nullable=False, default=AppointmentStatus.BOOKED
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
|
|
|||
197
backend/routes/booking.py
Normal file
197
backend/routes/booking.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Booking routes for users to book appointments."""
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from models import User, Availability, Appointment, AppointmentStatus, Permission
|
||||
from schemas import (
|
||||
BookableSlot,
|
||||
AvailableSlotsResponse,
|
||||
BookingRequest,
|
||||
AppointmentResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/booking", tags=["booking"])
|
||||
|
||||
# From shared/constants.json
|
||||
SLOT_DURATION_MINUTES = 15
|
||||
MIN_ADVANCE_DAYS = 1
|
||||
MAX_ADVANCE_DAYS = 30
|
||||
|
||||
|
||||
def _get_bookable_date_range() -> tuple[date, date]:
|
||||
"""Get the valid date range for booking (tomorrow to +30 days)."""
|
||||
today = date.today()
|
||||
min_date = today + timedelta(days=MIN_ADVANCE_DAYS)
|
||||
max_date = today + timedelta(days=MAX_ADVANCE_DAYS)
|
||||
return min_date, max_date
|
||||
|
||||
|
||||
def _validate_booking_date(d: date) -> None:
|
||||
"""Validate a date is within the bookable range."""
|
||||
min_date, max_date = _get_bookable_date_range()
|
||||
if d < min_date:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot book for today or past dates. Earliest bookable date: {min_date}",
|
||||
)
|
||||
if d > max_date:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot book more than {MAX_ADVANCE_DAYS} days ahead. Latest bookable: {max_date}",
|
||||
)
|
||||
|
||||
|
||||
def _expand_availability_to_slots(
|
||||
availability_slots: list[Availability],
|
||||
target_date: date,
|
||||
) -> list[BookableSlot]:
|
||||
"""Expand availability time ranges into 15-minute bookable slots."""
|
||||
result: list[BookableSlot] = []
|
||||
|
||||
for avail in availability_slots:
|
||||
# Create datetime objects for start and end
|
||||
current = datetime.combine(target_date, avail.start_time, tzinfo=timezone.utc)
|
||||
end = datetime.combine(target_date, avail.end_time, tzinfo=timezone.utc)
|
||||
|
||||
# Generate 15-minute slots
|
||||
while current + timedelta(minutes=SLOT_DURATION_MINUTES) <= end:
|
||||
slot_end = current + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||
result.append(BookableSlot(start_time=current, end_time=slot_end))
|
||||
current = slot_end
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/slots", response_model=AvailableSlotsResponse)
|
||||
async def get_available_slots(
|
||||
target_date: date = Query(..., alias="date", description="Date to get slots for"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)),
|
||||
) -> AvailableSlotsResponse:
|
||||
"""Get available booking slots for a specific date."""
|
||||
_validate_booking_date(target_date)
|
||||
|
||||
# Get availability for this date
|
||||
result = await db.execute(
|
||||
select(Availability)
|
||||
.where(Availability.date == target_date)
|
||||
.order_by(Availability.start_time)
|
||||
)
|
||||
availability_slots = result.scalars().all()
|
||||
|
||||
if not availability_slots:
|
||||
return AvailableSlotsResponse(date=target_date, slots=[])
|
||||
|
||||
# Expand to 15-minute slots
|
||||
all_slots = _expand_availability_to_slots(availability_slots, target_date)
|
||||
|
||||
# Get existing booked appointments for this date
|
||||
day_start = datetime.combine(target_date, time.min, tzinfo=timezone.utc)
|
||||
day_end = datetime.combine(target_date, time.max, tzinfo=timezone.utc)
|
||||
|
||||
result = await db.execute(
|
||||
select(Appointment.slot_start)
|
||||
.where(
|
||||
and_(
|
||||
Appointment.slot_start >= day_start,
|
||||
Appointment.slot_start <= day_end,
|
||||
Appointment.status == AppointmentStatus.BOOKED,
|
||||
)
|
||||
)
|
||||
)
|
||||
booked_starts = {row[0] for row in result.fetchall()}
|
||||
|
||||
# Filter out already booked slots
|
||||
available_slots = [
|
||||
slot for slot in all_slots
|
||||
if slot.start_time not in booked_starts
|
||||
]
|
||||
|
||||
return AvailableSlotsResponse(date=target_date, slots=available_slots)
|
||||
|
||||
|
||||
@router.post("", response_model=AppointmentResponse)
|
||||
async def create_booking(
|
||||
request: BookingRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)),
|
||||
) -> AppointmentResponse:
|
||||
"""Book an appointment slot."""
|
||||
slot_date = request.slot_start.date()
|
||||
_validate_booking_date(slot_date)
|
||||
|
||||
# Validate slot is on 15-minute boundary
|
||||
if request.slot_start.minute not in (0, 15, 30, 45):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slot start time must be on 15-minute boundary",
|
||||
)
|
||||
if request.slot_start.second != 0 or request.slot_start.microsecond != 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slot start time must not have seconds or microseconds",
|
||||
)
|
||||
|
||||
# Verify slot falls within availability
|
||||
slot_start_time = request.slot_start.time()
|
||||
slot_end_time = (request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)).time()
|
||||
|
||||
result = await db.execute(
|
||||
select(Availability)
|
||||
.where(
|
||||
and_(
|
||||
Availability.date == slot_date,
|
||||
Availability.start_time <= slot_start_time,
|
||||
Availability.end_time >= slot_end_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
matching_availability = result.scalar_one_or_none()
|
||||
|
||||
if not matching_availability:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Selected slot is not within available time ranges",
|
||||
)
|
||||
|
||||
# Create the appointment
|
||||
slot_end = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||
appointment = Appointment(
|
||||
user_id=current_user.id,
|
||||
slot_start=request.slot_start,
|
||||
slot_end=slot_end,
|
||||
note=request.note,
|
||||
status=AppointmentStatus.BOOKED,
|
||||
)
|
||||
|
||||
db.add(appointment)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This slot has already been booked. Please select another slot.",
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -183,6 +183,51 @@ class CopyAvailabilityRequest(BaseModel):
|
|||
target_dates: list[date]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Booking Schemas
|
||||
# =============================================================================
|
||||
|
||||
class BookableSlot(BaseModel):
|
||||
"""A bookable 15-minute slot."""
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
|
||||
|
||||
class AvailableSlotsResponse(BaseModel):
|
||||
"""Response for available slots on a given date."""
|
||||
date: date
|
||||
slots: list[BookableSlot]
|
||||
|
||||
|
||||
class BookingRequest(BaseModel):
|
||||
"""Request to book an appointment."""
|
||||
slot_start: datetime
|
||||
note: str | None = None
|
||||
|
||||
@field_validator("note")
|
||||
@classmethod
|
||||
def validate_note_length(cls, v: str | None) -> str | None:
|
||||
if v is not None and len(v) > 144:
|
||||
raise ValueError("Note must be at most 144 characters")
|
||||
return v
|
||||
|
||||
|
||||
class AppointmentResponse(BaseModel):
|
||||
"""Response model for an appointment."""
|
||||
id: int
|
||||
user_id: int
|
||||
user_email: str
|
||||
slot_start: datetime
|
||||
slot_end: datetime
|
||||
note: str | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
cancelled_at: datetime | None
|
||||
|
||||
|
||||
PaginatedAppointments = PaginatedResponse[AppointmentResponse]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Meta/Constants Schemas
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ async def regular_user(client_factory):
|
|||
password = "password123"
|
||||
|
||||
async with client_factory.get_db_session() as db:
|
||||
await create_user_with_roles(db, email, password, [ROLE_REGULAR])
|
||||
user = await create_user_with_roles(db, email, password, [ROLE_REGULAR])
|
||||
user_id = user.id
|
||||
|
||||
# Login to get cookies
|
||||
response = await client_factory.post(
|
||||
|
|
@ -162,6 +163,32 @@ async def regular_user(client_factory):
|
|||
"password": password,
|
||||
"cookies": dict(response.cookies),
|
||||
"response": response,
|
||||
"user": {"id": user_id, "email": email},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def alt_regular_user(client_factory):
|
||||
"""Create a second regular user for tests needing multiple users."""
|
||||
email = unique_email("alt_regular")
|
||||
password = "password123"
|
||||
|
||||
async with client_factory.get_db_session() as db:
|
||||
user = await create_user_with_roles(db, email, password, [ROLE_REGULAR])
|
||||
user_id = user.id
|
||||
|
||||
# Login to get cookies
|
||||
response = await client_factory.post(
|
||||
"/api/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"cookies": dict(response.cookies),
|
||||
"response": response,
|
||||
"user": {"id": user_id, "email": email},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
449
backend/tests/test_booking.py
Normal file
449
backend/tests/test_booking.py
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
"""
|
||||
Booking API Tests
|
||||
|
||||
Tests for the user booking endpoints.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
import pytest
|
||||
|
||||
|
||||
def tomorrow() -> date:
|
||||
return date.today() + timedelta(days=1)
|
||||
|
||||
|
||||
def in_days(n: int) -> date:
|
||||
return date.today() + timedelta(days=n)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Permission Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBookingPermissions:
|
||||
"""Test that only regular users can book appointments."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_can_get_slots(self, client_factory, regular_user, admin_user):
|
||||
"""Regular user can get available slots."""
|
||||
# First, admin sets up availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# Regular user gets slots
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/booking/slots", params={"date": str(tomorrow())})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_can_book(self, client_factory, regular_user, admin_user):
|
||||
"""Regular user can book an appointment."""
|
||||
# Admin sets up availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# Regular user books
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": "Test booking"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_get_slots(self, client_factory, admin_user):
|
||||
"""Admin cannot access booking slots endpoint."""
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/booking/slots", params={"date": str(tomorrow())})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_book(self, client_factory, admin_user):
|
||||
"""Admin cannot book appointments."""
|
||||
# Admin sets up availability first
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
await client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_get_slots(self, client):
|
||||
"""Unauthenticated user cannot get slots."""
|
||||
response = await client.get("/api/booking/slots", params={"date": str(tomorrow())})
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_book(self, client):
|
||||
"""Unauthenticated user cannot book."""
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Get Slots Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGetSlots:
|
||||
"""Test getting available booking slots."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_slots_no_availability(self, client_factory, regular_user):
|
||||
"""Returns empty slots when no availability set."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/booking/slots", params={"date": str(tomorrow())})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["date"] == str(tomorrow())
|
||||
assert data["slots"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_slots_expands_to_15min(self, client_factory, regular_user, admin_user):
|
||||
"""Availability is expanded into 15-minute slots."""
|
||||
# Admin sets 1-hour availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User gets slots - should be 4 x 15-minute slots
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/booking/slots", params={"date": str(tomorrow())})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["slots"]) == 4
|
||||
|
||||
# Verify times
|
||||
assert "09:00:00" in data["slots"][0]["start_time"]
|
||||
assert "09:15:00" in data["slots"][0]["end_time"]
|
||||
assert "09:15:00" in data["slots"][1]["start_time"]
|
||||
assert "09:45:00" in data["slots"][3]["start_time"]
|
||||
assert "10:00:00" in data["slots"][3]["end_time"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_slots_excludes_booked(self, client_factory, regular_user, admin_user):
|
||||
"""Already booked slots are excluded from available slots."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books first slot
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
|
||||
# Get slots again - should have 3 left
|
||||
response = await client.get("/api/booking/slots", params={"date": str(tomorrow())})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["slots"]) == 3
|
||||
# First slot should now be 09:15
|
||||
assert "09:15:00" in data["slots"][0]["start_time"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Booking Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateBooking:
|
||||
"""Test creating bookings."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_book_slot_success(self, client_factory, regular_user, admin_user):
|
||||
"""Can successfully book an available slot."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={
|
||||
"slot_start": f"{tomorrow()}T09:00:00Z",
|
||||
"note": "Discussion about project",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == regular_user["user"]["id"]
|
||||
assert data["note"] == "Discussion about project"
|
||||
assert data["status"] == "booked"
|
||||
assert "09:00:00" in data["slot_start"]
|
||||
assert "09:15:00" in data["slot_end"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_book_without_note(self, client_factory, regular_user, admin_user):
|
||||
"""Can book without a note."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books without note
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["note"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_double_book_slot(self, client_factory, regular_user, admin_user, alt_regular_user):
|
||||
"""Cannot book a slot that's already booked."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# First user books
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Second user tries to book same slot
|
||||
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "already been booked" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_book_outside_availability(self, client_factory, regular_user, admin_user):
|
||||
"""Cannot book a slot outside of availability."""
|
||||
# Admin sets availability for morning only
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User tries to book afternoon slot
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T14:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "not within available" in response.json()["detail"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Date Validation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBookingDateValidation:
|
||||
"""Test date validation for bookings."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_book_today(self, client_factory, regular_user):
|
||||
"""Cannot book for today (same day)."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{date.today()}T09:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "past" in response.json()["detail"].lower() or "today" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_book_past_date(self, client_factory, regular_user):
|
||||
"""Cannot book for past date."""
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{yesterday}T09:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_book_beyond_30_days(self, client_factory, regular_user):
|
||||
"""Cannot book more than 30 days in advance."""
|
||||
too_far = in_days(31)
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{too_far}T09:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "30" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_get_slots_today(self, client_factory, regular_user):
|
||||
"""Cannot get slots for today."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/booking/slots", params={"date": str(date.today())})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_get_slots_past(self, client_factory, regular_user):
|
||||
"""Cannot get slots for past date."""
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/booking/slots", params={"date": str(yesterday)})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Time Validation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBookingTimeValidation:
|
||||
"""Test time validation for bookings."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slot_must_be_15min_boundary(self, client_factory, regular_user, admin_user):
|
||||
"""Slot start time must be on 15-minute boundary."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User tries to book at 09:05
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:05:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "15-minute" in response.json()["detail"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Note Validation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBookingNoteValidation:
|
||||
"""Test note validation for bookings."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_note_max_length(self, client_factory, regular_user, admin_user):
|
||||
"""Note cannot exceed 144 characters."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User tries to book with long note
|
||||
long_note = "x" * 145
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": long_note},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_note_exactly_144_chars(self, client_factory, regular_user, admin_user):
|
||||
"""Note of exactly 144 characters is allowed."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books with exactly 144 char note
|
||||
note = "x" * 144
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": note},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["note"] == note
|
||||
|
||||
|
|
@ -15,9 +15,12 @@ type AvailabilityDay = components["schemas"]["AvailabilityDay"];
|
|||
type AvailabilityResponse = components["schemas"]["AvailabilityResponse"];
|
||||
type TimeSlot = components["schemas"]["TimeSlot"];
|
||||
|
||||
// Helper to format date as YYYY-MM-DD
|
||||
// Helper to format date as YYYY-MM-DD in local timezone
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().split("T")[0];
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Helper to get next N days starting from tomorrow
|
||||
|
|
|
|||
|
|
@ -356,6 +356,46 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/booking/slots": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Available Slots
|
||||
* @description Get available booking slots for a specific date.
|
||||
*/
|
||||
get: operations["get_available_slots_api_booking_slots_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/booking": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Create Booking
|
||||
* @description Book an appointment slot.
|
||||
*/
|
||||
post: operations["create_booking_api_booking_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/meta/constants": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -390,6 +430,39 @@ export interface components {
|
|||
/** Email */
|
||||
email: string;
|
||||
};
|
||||
/**
|
||||
* AppointmentResponse
|
||||
* @description Response model for an appointment.
|
||||
*/
|
||||
AppointmentResponse: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** User Id */
|
||||
user_id: number;
|
||||
/** User Email */
|
||||
user_email: string;
|
||||
/**
|
||||
* Slot Start
|
||||
* Format: date-time
|
||||
*/
|
||||
slot_start: string;
|
||||
/**
|
||||
* Slot End
|
||||
* Format: date-time
|
||||
*/
|
||||
slot_end: string;
|
||||
/** Note */
|
||||
note: string | null;
|
||||
/** Status */
|
||||
status: string;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
/** Cancelled At */
|
||||
cancelled_at: string | null;
|
||||
};
|
||||
/**
|
||||
* AvailabilityDay
|
||||
* @description Availability for a single day.
|
||||
|
|
@ -411,6 +484,48 @@ export interface components {
|
|||
/** Days */
|
||||
days: components["schemas"]["AvailabilityDay"][];
|
||||
};
|
||||
/**
|
||||
* AvailableSlotsResponse
|
||||
* @description Response for available slots on a given date.
|
||||
*/
|
||||
AvailableSlotsResponse: {
|
||||
/**
|
||||
* Date
|
||||
* Format: date
|
||||
*/
|
||||
date: string;
|
||||
/** Slots */
|
||||
slots: components["schemas"]["BookableSlot"][];
|
||||
};
|
||||
/**
|
||||
* BookableSlot
|
||||
* @description A bookable 15-minute slot.
|
||||
*/
|
||||
BookableSlot: {
|
||||
/**
|
||||
* Start Time
|
||||
* Format: date-time
|
||||
*/
|
||||
start_time: string;
|
||||
/**
|
||||
* End Time
|
||||
* Format: date-time
|
||||
*/
|
||||
end_time: string;
|
||||
};
|
||||
/**
|
||||
* BookingRequest
|
||||
* @description Request to book an appointment.
|
||||
*/
|
||||
BookingRequest: {
|
||||
/**
|
||||
* Slot Start
|
||||
* Format: date-time
|
||||
*/
|
||||
slot_start: string;
|
||||
/** Note */
|
||||
note?: string | null;
|
||||
};
|
||||
/**
|
||||
* ConstantsResponse
|
||||
* @description Response model for shared constants.
|
||||
|
|
@ -1304,6 +1419,71 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_available_slots_api_booking_slots_get: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description Date to get slots for */
|
||||
date: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AvailableSlotsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_booking_api_booking_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BookingRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AppointmentResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_constants_api_meta_constants_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,20 @@ function getTomorrowDisplay(): string {
|
|||
return tomorrow.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Helper to get a date string in YYYY-MM-DD format using local timezone
|
||||
function formatDateLocal(d: Date): string {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getTomorrowDateStr(): string {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return formatDateLocal(tomorrow);
|
||||
}
|
||||
|
||||
test.describe("Availability Page - Admin Access", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await clearAuth(page);
|
||||
|
|
@ -204,9 +218,7 @@ test.describe("Availability API", () => {
|
|||
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (authCookie) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateStr = tomorrow.toISOString().split("T")[0];
|
||||
const dateStr = getTomorrowDateStr();
|
||||
|
||||
const response = await request.put(`${API_URL}/api/admin/availability`, {
|
||||
headers: {
|
||||
|
|
@ -234,9 +246,7 @@ test.describe("Availability API", () => {
|
|||
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (authCookie) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateStr = tomorrow.toISOString().split("T")[0];
|
||||
const dateStr = getTomorrowDateStr();
|
||||
|
||||
const response = await request.get(
|
||||
`${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue