Phase 1: Add Availability model and API
- Create Availability model with date, start_time, end_time - Add availability schemas with 15-minute boundary validation - Add admin endpoints: - GET /api/admin/availability - query by date range - PUT /api/admin/availability - set slots for a date - POST /api/admin/availability/copy - copy to multiple days - Add 26 tests covering permissions, CRUD, and validation
This commit is contained in:
parent
6c1a05d93d
commit
64d2e99d73
5 changed files with 788 additions and 4 deletions
|
|
@ -12,6 +12,7 @@ from routes import profile as profile_routes
|
||||||
from routes import invites as invites_routes
|
from routes import invites as invites_routes
|
||||||
from routes import auth as auth_routes
|
from routes import auth as auth_routes
|
||||||
from routes import meta as meta_routes
|
from routes import meta as meta_routes
|
||||||
|
from routes import availability as availability_routes
|
||||||
from validate_constants import validate_shared_constants
|
from validate_constants import validate_shared_constants
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,4 +45,5 @@ app.include_router(audit_routes.router)
|
||||||
app.include_router(profile_routes.router)
|
app.include_router(profile_routes.router)
|
||||||
app.include_router(invites_routes.router)
|
app.include_router(invites_routes.router)
|
||||||
app.include_router(invites_routes.admin_router)
|
app.include_router(invites_routes.admin_router)
|
||||||
|
app.include_router(availability_routes.router)
|
||||||
app.include_router(meta_routes.router)
|
app.include_router(meta_routes.router)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from datetime import datetime, UTC
|
from datetime import datetime, date, time, UTC
|
||||||
from enum import Enum as PyEnum
|
from enum import Enum as PyEnum
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
from sqlalchemy import Integer, String, Float, DateTime, ForeignKey, Table, Column, Enum, select
|
from sqlalchemy import Integer, String, Float, DateTime, Date, Time, ForeignKey, Table, Column, Enum, UniqueConstraint, select
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
@ -248,3 +248,24 @@ class Invite(Base):
|
||||||
)
|
)
|
||||||
spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Availability(Base):
|
||||||
|
"""Admin availability slots for booking."""
|
||||||
|
__tablename__ = "availability"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("date", "start_time", name="uq_availability_date_start"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
|
start_time: Mapped[time] = mapped_column(Time, nullable=False)
|
||||||
|
end_time: Mapped[time] = mapped_column(Time, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
|
||||||
192
backend/routes/availability.py
Normal file
192
backend/routes/availability.py
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""Availability routes for admin to manage booking availability."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select, delete, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from auth import require_permission
|
||||||
|
from database import get_db
|
||||||
|
from models import User, Availability, Permission
|
||||||
|
from schemas import (
|
||||||
|
TimeSlot,
|
||||||
|
AvailabilityDay,
|
||||||
|
AvailabilityResponse,
|
||||||
|
SetAvailabilityRequest,
|
||||||
|
CopyAvailabilityRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/availability", tags=["availability"])
|
||||||
|
|
||||||
|
# From shared/constants.json
|
||||||
|
MAX_ADVANCE_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _get_date_range_bounds() -> tuple[date, date]:
|
||||||
|
"""Get the valid date range for availability (tomorrow to +30 days)."""
|
||||||
|
today = date.today()
|
||||||
|
min_date = today + timedelta(days=1) # Tomorrow
|
||||||
|
max_date = today + timedelta(days=MAX_ADVANCE_DAYS)
|
||||||
|
return min_date, max_date
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_date_in_range(d: date, min_date: date, max_date: date) -> None:
|
||||||
|
"""Validate a date is within the allowed range."""
|
||||||
|
if d < min_date:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot set availability for past dates. Earliest allowed: {min_date}",
|
||||||
|
)
|
||||||
|
if d > max_date:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot set availability more than {MAX_ADVANCE_DAYS} days ahead. Latest allowed: {max_date}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=AvailabilityResponse)
|
||||||
|
async def get_availability(
|
||||||
|
from_date: date = Query(..., alias="from", description="Start date (inclusive)"),
|
||||||
|
to_date: date = Query(..., alias="to", description="End date (inclusive)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||||
|
) -> AvailabilityResponse:
|
||||||
|
"""Get availability slots for a date range."""
|
||||||
|
if from_date > to_date:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="'from' date must be before or equal to 'to' date",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query availability in range
|
||||||
|
result = await db.execute(
|
||||||
|
select(Availability)
|
||||||
|
.where(and_(Availability.date >= from_date, Availability.date <= to_date))
|
||||||
|
.order_by(Availability.date, Availability.start_time)
|
||||||
|
)
|
||||||
|
slots = result.scalars().all()
|
||||||
|
|
||||||
|
# Group by date
|
||||||
|
days_dict: dict[date, list[TimeSlot]] = {}
|
||||||
|
for slot in slots:
|
||||||
|
if slot.date not in days_dict:
|
||||||
|
days_dict[slot.date] = []
|
||||||
|
days_dict[slot.date].append(TimeSlot(
|
||||||
|
start_time=slot.start_time,
|
||||||
|
end_time=slot.end_time,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Convert to response format
|
||||||
|
days = [
|
||||||
|
AvailabilityDay(date=d, slots=days_dict[d])
|
||||||
|
for d in sorted(days_dict.keys())
|
||||||
|
]
|
||||||
|
|
||||||
|
return AvailabilityResponse(days=days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=AvailabilityDay)
|
||||||
|
async def set_availability(
|
||||||
|
request: SetAvailabilityRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||||
|
) -> AvailabilityDay:
|
||||||
|
"""Set availability for a specific date. Replaces any existing availability."""
|
||||||
|
min_date, max_date = _get_date_range_bounds()
|
||||||
|
_validate_date_in_range(request.date, min_date, max_date)
|
||||||
|
|
||||||
|
# Validate slots don't overlap
|
||||||
|
sorted_slots = sorted(request.slots, key=lambda s: s.start_time)
|
||||||
|
for i in range(len(sorted_slots) - 1):
|
||||||
|
if sorted_slots[i].end_time > sorted_slots[i + 1].start_time:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Time slots overlap: {sorted_slots[i].end_time} > {sorted_slots[i + 1].start_time}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate each slot's end_time > start_time
|
||||||
|
for slot in request.slots:
|
||||||
|
if slot.end_time <= slot.start_time:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Slot end time must be after start time: {slot.start_time} - {slot.end_time}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete existing availability for this date
|
||||||
|
await db.execute(
|
||||||
|
delete(Availability).where(Availability.date == request.date)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new availability slots
|
||||||
|
for slot in request.slots:
|
||||||
|
availability = Availability(
|
||||||
|
date=request.date,
|
||||||
|
start_time=slot.start_time,
|
||||||
|
end_time=slot.end_time,
|
||||||
|
)
|
||||||
|
db.add(availability)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return AvailabilityDay(date=request.date, slots=request.slots)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/copy", response_model=AvailabilityResponse)
|
||||||
|
async def copy_availability(
|
||||||
|
request: CopyAvailabilityRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||||
|
) -> AvailabilityResponse:
|
||||||
|
"""Copy availability from one day to multiple target days."""
|
||||||
|
min_date, max_date = _get_date_range_bounds()
|
||||||
|
|
||||||
|
# Validate target dates
|
||||||
|
for target_date in request.target_dates:
|
||||||
|
_validate_date_in_range(target_date, min_date, max_date)
|
||||||
|
|
||||||
|
# Get source availability
|
||||||
|
result = await db.execute(
|
||||||
|
select(Availability)
|
||||||
|
.where(Availability.date == request.source_date)
|
||||||
|
.order_by(Availability.start_time)
|
||||||
|
)
|
||||||
|
source_slots = result.scalars().all()
|
||||||
|
|
||||||
|
if not source_slots:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"No availability found for source date {request.source_date}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy to each target date
|
||||||
|
copied_days: list[AvailabilityDay] = []
|
||||||
|
for target_date in request.target_dates:
|
||||||
|
if target_date == request.source_date:
|
||||||
|
continue # Skip copying to self
|
||||||
|
|
||||||
|
# Delete existing availability for target date
|
||||||
|
await db.execute(
|
||||||
|
delete(Availability).where(Availability.date == target_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy slots
|
||||||
|
target_slots: list[TimeSlot] = []
|
||||||
|
for source_slot in source_slots:
|
||||||
|
new_availability = Availability(
|
||||||
|
date=target_date,
|
||||||
|
start_time=source_slot.start_time,
|
||||||
|
end_time=source_slot.end_time,
|
||||||
|
)
|
||||||
|
db.add(new_availability)
|
||||||
|
target_slots.append(TimeSlot(
|
||||||
|
start_time=source_slot.start_time,
|
||||||
|
end_time=source_slot.end_time,
|
||||||
|
))
|
||||||
|
|
||||||
|
copied_days.append(AvailabilityDay(date=target_date, slots=target_slots))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return AvailabilityResponse(days=copied_days)
|
||||||
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Pydantic schemas for API request/response models."""
|
"""Pydantic schemas for API request/response models."""
|
||||||
from datetime import datetime
|
from datetime import datetime, date, time
|
||||||
from typing import Generic, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
class UserCredentials(BaseModel):
|
class UserCredentials(BaseModel):
|
||||||
|
|
@ -140,6 +140,49 @@ class AdminUserResponse(BaseModel):
|
||||||
email: str
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Availability Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TimeSlot(BaseModel):
|
||||||
|
"""A single time slot (start and end time)."""
|
||||||
|
start_time: time
|
||||||
|
end_time: time
|
||||||
|
|
||||||
|
@field_validator("start_time", "end_time")
|
||||||
|
@classmethod
|
||||||
|
def validate_15min_boundary(cls, v: time) -> time:
|
||||||
|
"""Ensure times are on 15-minute boundaries."""
|
||||||
|
if v.minute not in (0, 15, 30, 45):
|
||||||
|
raise ValueError("Time must be on 15-minute boundary (:00, :15, :30, :45)")
|
||||||
|
if v.second != 0 or v.microsecond != 0:
|
||||||
|
raise ValueError("Time must not have seconds or microseconds")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityDay(BaseModel):
|
||||||
|
"""Availability for a single day."""
|
||||||
|
date: date
|
||||||
|
slots: list[TimeSlot]
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityResponse(BaseModel):
|
||||||
|
"""Response model for availability query."""
|
||||||
|
days: list[AvailabilityDay]
|
||||||
|
|
||||||
|
|
||||||
|
class SetAvailabilityRequest(BaseModel):
|
||||||
|
"""Request to set availability for a specific date."""
|
||||||
|
date: date
|
||||||
|
slots: list[TimeSlot]
|
||||||
|
|
||||||
|
|
||||||
|
class CopyAvailabilityRequest(BaseModel):
|
||||||
|
"""Request to copy availability from one day to others."""
|
||||||
|
source_date: date
|
||||||
|
target_dates: list[date]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Meta/Constants Schemas
|
# Meta/Constants Schemas
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
526
backend/tests/test_availability.py
Normal file
526
backend/tests/test_availability.py
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
"""
|
||||||
|
Availability API Tests
|
||||||
|
|
||||||
|
Tests for the admin availability management endpoints.
|
||||||
|
"""
|
||||||
|
from datetime import date, time, 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 TestAvailabilityPermissions:
|
||||||
|
"""Test that only admins can access availability endpoints."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_can_get_availability(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(tomorrow()), "to": str(in_days(7))},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_can_set_availability(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_regular_user_cannot_get_availability(self, client_factory, regular_user):
|
||||||
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(tomorrow()), "to": str(in_days(7))},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_regular_user_cannot_set_availability(self, client_factory, regular_user):
|
||||||
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthenticated_cannot_get_availability(self, client):
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(tomorrow()), "to": str(in_days(7))},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthenticated_cannot_set_availability(self, client):
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Set Availability Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestSetAvailability:
|
||||||
|
"""Test setting availability for a date."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_single_slot(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["date"] == str(tomorrow())
|
||||||
|
assert len(data["slots"]) == 1
|
||||||
|
assert data["slots"][0]["start_time"] == "09:00:00"
|
||||||
|
assert data["slots"][0]["end_time"] == "12:00:00"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_multiple_slots(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [
|
||||||
|
{"start_time": "09:00:00", "end_time": "12:00:00"},
|
||||||
|
{"start_time": "14:00:00", "end_time": "17:00:00"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["slots"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_empty_slots_clears_availability(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# First set some availability
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then clear it
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={"date": str(tomorrow()), "slots": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["slots"]) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_replaces_existing_availability(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set initial availability
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace with different slots
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "14:00:00", "end_time": "16:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the replacement
|
||||||
|
get_response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(tomorrow()), "to": str(tomorrow())},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = get_response.json()
|
||||||
|
assert len(data["days"]) == 1
|
||||||
|
assert len(data["days"][0]["slots"]) == 1
|
||||||
|
assert data["days"][0]["slots"][0]["start_time"] == "14:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Validation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAvailabilityValidation:
|
||||||
|
"""Test validation rules for availability."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cannot_set_past_date(self, client_factory, admin_user):
|
||||||
|
yesterday = date.today() - timedelta(days=1)
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(yesterday),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "past" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cannot_set_today(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(date.today()),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "past" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cannot_set_beyond_30_days(self, client_factory, admin_user):
|
||||||
|
too_far = in_days(31)
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(too_far),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "30" in response.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_time_must_be_15min_boundary(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "09:05:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422 # Pydantic validation error
|
||||||
|
assert "15-minute" in response.json()["detail"][0]["msg"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_end_time_must_be_after_start_time(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [{"start_time": "12:00:00", "end_time": "09:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "after" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_slots_cannot_overlap(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(tomorrow()),
|
||||||
|
"slots": [
|
||||||
|
{"start_time": "09:00:00", "end_time": "12:00:00"},
|
||||||
|
{"start_time": "11:00:00", "end_time": "14:00:00"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "overlap" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Get Availability Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGetAvailability:
|
||||||
|
"""Test retrieving availability."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_empty_availability(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(tomorrow()), "to": str(in_days(7))},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["days"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_availability_in_range(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set availability for multiple days
|
||||||
|
for i in range(1, 4):
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(i)),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get range that includes all
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(in_days(1)), "to": str(in_days(3))},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["days"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_availability_partial_range(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set availability for 5 days
|
||||||
|
for i in range(1, 6):
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(i)),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get only a subset
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(in_days(2)), "to": str(in_days(4))},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["days"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_availability_invalid_range(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(in_days(7)), "to": str(in_days(1))},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "before" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Copy Availability Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCopyAvailability:
|
||||||
|
"""Test copying availability from one day to others."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_to_single_day(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set source availability
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(1)),
|
||||||
|
"slots": [
|
||||||
|
{"start_time": "09:00:00", "end_time": "12:00:00"},
|
||||||
|
{"start_time": "14:00:00", "end_time": "17:00:00"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy to another day
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/availability/copy",
|
||||||
|
json={
|
||||||
|
"source_date": str(in_days(1)),
|
||||||
|
"target_dates": [str(in_days(2))],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["days"]) == 1
|
||||||
|
assert data["days"][0]["date"] == str(in_days(2))
|
||||||
|
assert len(data["days"][0]["slots"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_to_multiple_days(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set source availability
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(1)),
|
||||||
|
"slots": [{"start_time": "10:00:00", "end_time": "11:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy to multiple days
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/availability/copy",
|
||||||
|
json={
|
||||||
|
"source_date": str(in_days(1)),
|
||||||
|
"target_dates": [str(in_days(2)), str(in_days(3)), str(in_days(4))],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["days"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_replaces_existing(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set different availability on target
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(2)),
|
||||||
|
"slots": [{"start_time": "08:00:00", "end_time": "09:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set source availability
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(1)),
|
||||||
|
"slots": [{"start_time": "14:00:00", "end_time": "15:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy (should replace)
|
||||||
|
await client.post(
|
||||||
|
"/api/admin/availability/copy",
|
||||||
|
json={
|
||||||
|
"source_date": str(in_days(1)),
|
||||||
|
"target_dates": [str(in_days(2))],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify target was replaced
|
||||||
|
response = await client.get(
|
||||||
|
"/api/admin/availability",
|
||||||
|
params={"from": str(in_days(2)), "to": str(in_days(2))},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["days"]) == 1
|
||||||
|
assert len(data["days"][0]["slots"]) == 1
|
||||||
|
assert data["days"][0]["slots"][0]["start_time"] == "14:00:00"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_no_source_availability(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/availability/copy",
|
||||||
|
json={
|
||||||
|
"source_date": str(in_days(1)),
|
||||||
|
"target_dates": [str(in_days(2))],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "no availability" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_skips_self(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set source availability
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(1)),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy including self in targets
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/availability/copy",
|
||||||
|
json={
|
||||||
|
"source_date": str(in_days(1)),
|
||||||
|
"target_dates": [str(in_days(1)), str(in_days(2))],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
# Should only have copied to day 2, not day 1 (self)
|
||||||
|
assert len(data["days"]) == 1
|
||||||
|
assert data["days"][0]["date"] == str(in_days(2))
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_validates_target_dates(self, client_factory, admin_user):
|
||||||
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||||
|
# Set source availability
|
||||||
|
await client.put(
|
||||||
|
"/api/admin/availability",
|
||||||
|
json={
|
||||||
|
"date": str(in_days(1)),
|
||||||
|
"slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to copy to a date beyond 30 days
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/availability/copy",
|
||||||
|
json={
|
||||||
|
"source_date": str(in_days(1)),
|
||||||
|
"target_dates": [str(in_days(31))],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "30" in response.json()["detail"]
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue