arbret/backend/tests/test_availability.py
counterweight 19c313767c
Fix: Validate source_date in copy availability endpoint
Added validation to ensure source_date is within the allowed range
(tomorrow to +30 days) for consistency with target_dates validation.
2025-12-21 17:28:21 +01:00

540 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"]
@pytest.mark.asyncio
async def test_copy_validates_source_date(self, client_factory, admin_user):
"""Cannot copy from a past source date."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(
"/api/admin/availability/copy",
json={
"source_date": str(date.today() - timedelta(days=1)), # Yesterday
"target_dates": [str(in_days(1))],
},
)
assert response.status_code == 400
assert "past" in response.json()["detail"].lower()