- 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
526 lines
20 KiB
Python
526 lines
20 KiB
Python
"""
|
|
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"]
|
|
|