"""Tests for public/anonymous booking endpoints and the property-slug PATCH path.""" import os from datetime import datetime # Rate limiting is env-gated (PYTEST_CURRENT_TEST / DISABLE_RATE_LIMIT); set the # explicit override too in case this module is ever imported outside pytest's # normal collection (PYTEST_CURRENT_TEST is only set during actual test runs). os.environ["DISABLE_RATE_LIMIT"] = "1" import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.core.slug import DEMO_SLUG from app.models.booking import Booking from app.models.notification import Notification from app.models.property import Property from app.models.property_manager import PropertyManager from app.models.property_settings import PropertySettings from app.models.space import Space from app.models.user import User # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def public_property(db: Session) -> Property: prop = Property( name="Public Test Property", is_public=True, is_active=True, slug="public-test-property", list_on_landing=False, ) db.add(prop) db.commit() db.refresh(prop) db.add(PropertySettings(property_id=prop.id, require_approval=True)) db.commit() return prop @pytest.fixture def public_space(db: Session, public_property: Property) -> Space: space = Space( name="Public Room", type="sala", capacity=8, is_active=True, property_id=public_property.id, ) db.add(space) db.commit() db.refresh(space) return space @pytest.fixture def private_property(db: Session) -> Property: prop = Property( name="Private Test Property", is_public=False, is_active=True, slug="private-test-property", list_on_landing=False, ) db.add(prop) db.commit() db.refresh(prop) return prop @pytest.fixture def private_space(db: Session, private_property: Property) -> Space: space = Space( name="Private Room", type="sala", capacity=4, is_active=True, property_id=private_property.id, ) db.add(space) db.commit() db.refresh(space) return space @pytest.fixture def orphan_space(db: Session) -> Space: """A space with no property (property_id NULL).""" space = Space(name="Orphan Room", type="sala", capacity=2, is_active=True, property_id=None) db.add(space) db.commit() db.refresh(space) return space @pytest.fixture def demo_property(db: Session) -> Property: prop = Property( name="Proprietate Demo", is_public=True, is_active=True, slug=DEMO_SLUG, list_on_landing=True, ) db.add(prop) db.commit() db.refresh(prop) db.add(PropertySettings(property_id=prop.id, require_approval=True)) db.commit() return prop @pytest.fixture def demo_space(db: Session, demo_property: Property) -> Space: space = Space( name="Demo Room", type="sala", capacity=6, is_active=True, property_id=demo_property.id, ) db.add(space) db.commit() db.refresh(space) return space @pytest.fixture def property_manager_user(db: Session, public_property: Property) -> User: from app.core.security import get_password_hash user = User( email="propmanager@example.com", full_name="Property Manager", hashed_password=get_password_hash("managerpass"), role="manager", is_active=True, ) db.add(user) db.commit() db.refresh(user) db.add(PropertyManager(property_id=public_property.id, user_id=user.id)) db.commit() return user @pytest.fixture def manager_headers(property_manager_user: User) -> dict[str, str]: from app.core.security import create_access_token token = create_access_token(subject=int(property_manager_user.id)) return {"Authorization": f"Bearer {token}"} def _guest_payload(space_id: int, start: datetime, end: datetime, email: str = "guest@example.com") -> dict: return { "space_id": space_id, "start_datetime": start.isoformat(), "end_datetime": end.isoformat(), "title": "Guest Meeting", "description": None, "guest_name": "Guest Person", "guest_email": email, "guest_organization": None, } # --------------------------------------------------------------------------- # busy endpoint # --------------------------------------------------------------------------- def test_busy_returns_only_start_end_times( client: TestClient, db: Session, public_space: Space ) -> None: approved = Booking( space_id=public_space.id, title="Secret Title", description=None, start_datetime=datetime(2025, 6, 2, 10, 0, 0), end_datetime=datetime(2025, 6, 2, 11, 0, 0), status="approved", guest_name="Secret Guest", guest_email="secret@example.com", is_anonymous=True, ) pending = Booking( space_id=public_space.id, title="Pending Title", start_datetime=datetime(2025, 6, 2, 13, 0, 0), end_datetime=datetime(2025, 6, 2, 14, 0, 0), status="pending", guest_name="Pending Guest", guest_email="pending@example.com", is_anonymous=True, ) cancelled = Booking( space_id=public_space.id, title="Cancelled Title", start_datetime=datetime(2025, 6, 2, 16, 0, 0), end_datetime=datetime(2025, 6, 2, 17, 0, 0), status="cancelled", guest_name="Cancelled Guest", guest_email="cancelled@example.com", is_anonymous=True, ) db.add_all([approved, pending, cancelled]) db.commit() response = client.get( f"/api/public/spaces/{public_space.id}/busy", params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"}, ) assert response.status_code == 200 data = response.json() assert len(data) == 2 # approved + pending, cancelled excluded for item in data: assert set(item.keys()) == {"start_time", "end_time"} for forbidden in ("title", "user_name", "guest_name", "guest_email", "email", "status", "id"): assert forbidden not in item def test_busy_orphan_space_returns_404(client: TestClient, orphan_space: Space) -> None: response = client.get( f"/api/public/spaces/{orphan_space.id}/busy", params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"}, ) assert response.status_code == 404 def test_busy_private_property_returns_404(client: TestClient, private_space: Space) -> None: response = client.get( f"/api/public/spaces/{private_space.id}/busy", params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"}, ) assert response.status_code == 404 def test_busy_missing_space_returns_404(client: TestClient) -> None: response = client.get( "/api/public/spaces/999999/busy", params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"}, ) assert response.status_code == 404 def test_busy_rejects_timezone_aware_params(client: TestClient, public_space: Space) -> None: response = client.get( f"/api/public/spaces/{public_space.id}/busy", params={ "start": "2025-06-01T00:00:00+02:00", "end": "2025-06-05T00:00:00+02:00", }, ) assert response.status_code == 422 def test_busy_rejects_end_before_or_equal_start(client: TestClient, public_space: Space) -> None: response = client.get( f"/api/public/spaces/{public_space.id}/busy", params={"start": "2025-06-05T00:00:00", "end": "2025-06-01T00:00:00"}, ) assert response.status_code == 422 response_eq = client.get( f"/api/public/spaces/{public_space.id}/busy", params={"start": "2025-06-01T00:00:00", "end": "2025-06-01T00:00:00"}, ) assert response_eq.status_code == 422 def test_busy_rejects_range_over_60_days(client: TestClient, public_space: Space) -> None: response = client.get( f"/api/public/spaces/{public_space.id}/busy", params={"start": "2025-01-01T00:00:00", "end": "2025-06-01T00:00:00"}, ) assert response.status_code == 422 # --------------------------------------------------------------------------- # slug resolve # --------------------------------------------------------------------------- def test_get_property_by_slug(client: TestClient, public_property: Property) -> None: response = client.get(f"/api/public/properties/{public_property.slug}") assert response.status_code == 200 assert response.json()["id"] == public_property.id assert response.json()["slug"] == public_property.slug def test_get_property_by_numeric_id(client: TestClient, public_property: Property) -> None: response = client.get(f"/api/public/properties/{public_property.id}") assert response.status_code == 200 assert response.json()["slug"] == public_property.slug def test_get_property_wrong_slug_404(client: TestClient) -> None: response = client.get("/api/public/properties/does-not-exist") assert response.status_code == 404 def test_get_property_private_returns_404_not_403(client: TestClient, private_property: Property) -> None: response = client.get(f"/api/public/properties/{private_property.slug}") assert response.status_code == 404 def test_get_property_spaces_by_slug(client: TestClient, public_property: Property, public_space: Space) -> None: response = client.get(f"/api/public/properties/{public_property.slug}/spaces") assert response.status_code == 200 ids = [s["id"] for s in response.json()] assert public_space.id in ids def test_get_property_spaces_private_returns_404(client: TestClient, private_property: Property) -> None: response = client.get(f"/api/public/properties/{private_property.slug}/spaces") assert response.status_code == 404 def test_get_property_spaces_missing_returns_404(client: TestClient) -> None: response = client.get("/api/public/properties/does-not-exist/spaces") assert response.status_code == 404 # --------------------------------------------------------------------------- # availability regression: no PII leak # --------------------------------------------------------------------------- def test_availability_scrubs_user_name_and_title(client: TestClient, db: Session, public_space: Space) -> None: booking = Booking( space_id=public_space.id, title="Confidential Planning Session", start_datetime=datetime(2025, 7, 1, 10, 0, 0), end_datetime=datetime(2025, 7, 1, 11, 0, 0), status="approved", guest_name="Confidential Guest", guest_email="confidential@example.com", is_anonymous=True, ) db.add(booking) db.commit() response = client.get( f"/api/public/spaces/{public_space.id}/availability", params={"start_datetime": "2025-07-01T10:30:00", "end_datetime": "2025-07-01T10:45:00"}, ) assert response.status_code == 200 data = response.json() assert data["available"] is False assert len(data["conflicts"]) == 1 conflict = data["conflicts"][0] # No PII leaked... assert conflict["user_name"] == "" assert conflict["title"] == "" # ...but useful scheduling info is preserved. assert conflict["status"] == "approved" assert conflict["start_datetime"] assert conflict["end_datetime"] # --------------------------------------------------------------------------- # PATCH slug via PUT /manager/properties/{id} # --------------------------------------------------------------------------- def test_patch_slug_duplicate_returns_409( client: TestClient, db: Session, public_property: Property, manager_headers: dict[str, str] ) -> None: other = Property(name="Other Property", is_public=True, is_active=True, slug="other-property") db.add(other) db.commit() db.refresh(other) response = client.put( f"/api/manager/properties/{public_property.id}", json={"slug": other.slug}, headers=manager_headers, ) assert response.status_code == 409 @pytest.mark.parametrize( "bad_slug", ["ab", "has space", "123456", "admin", "-bad-start", "bad-end-", "a--b"], ) def test_patch_slug_invalid_format_returns_422( client: TestClient, public_property: Property, manager_headers: dict[str, str], bad_slug: str ) -> None: response = client.put( f"/api/manager/properties/{public_property.id}", json={"slug": bad_slug}, headers=manager_headers, ) assert response.status_code == 422 def test_patch_slug_valid_succeeds( client: TestClient, public_property: Property, manager_headers: dict[str, str] ) -> None: response = client.put( f"/api/manager/properties/{public_property.id}", json={"slug": "brand-new-slug"}, headers=manager_headers, ) assert response.status_code == 200 assert response.json()["slug"] == "brand-new-slug" def test_patch_slug_non_manager_returns_403( client: TestClient, public_property: Property, auth_headers: dict[str, str] ) -> None: response = client.put( f"/api/manager/properties/{public_property.id}", json={"slug": "some-new-slug"}, headers=auth_headers, ) assert response.status_code == 403 # --------------------------------------------------------------------------- # POST /public/bookings # --------------------------------------------------------------------------- def test_post_anonymous_booking_happy_path(client: TestClient, public_space: Space) -> None: start = datetime(2025, 8, 1, 9, 0, 0) end = datetime(2025, 8, 1, 10, 0, 0) response = client.post("/api/public/bookings", json=_guest_payload(public_space.id, start, end)) assert response.status_code == 201 data = response.json() assert data["is_anonymous"] is True assert data["guest_email"] == "guest@example.com" assert data["status"] == "pending" def test_post_anonymous_booking_pending_overlap_rejected(client: TestClient, public_space: Space) -> None: start = datetime(2025, 8, 2, 9, 0, 0) end = datetime(2025, 8, 2, 10, 0, 0) first = client.post("/api/public/bookings", json=_guest_payload(public_space.id, start, end, "first@example.com")) assert first.status_code == 201 overlap_start = datetime(2025, 8, 2, 9, 30, 0) overlap_end = datetime(2025, 8, 2, 10, 30, 0) second = client.post( "/api/public/bookings", json=_guest_payload(public_space.id, overlap_start, overlap_end, "second@example.com"), ) assert second.status_code == 400 def test_post_anonymous_booking_cap_five_pending_per_email_per_day(client: TestClient, public_space: Space) -> None: email = "capped@example.com" for hour in range(9, 14): # 5 non-overlapping slots: 9-10,10-11,...,13-14 start = datetime(2025, 8, 3, hour, 0, 0) end = datetime(2025, 8, 3, hour + 1, 0, 0) response = client.post("/api/public/bookings", json=_guest_payload(public_space.id, start, end, email)) assert response.status_code == 201 # 6th request for same guest/space/day must be rejected regardless of overlap. sixth_start = datetime(2025, 8, 3, 15, 0, 0) sixth_end = datetime(2025, 8, 3, 16, 0, 0) sixth = client.post("/api/public/bookings", json=_guest_payload(public_space.id, sixth_start, sixth_end, email)) assert sixth.status_code == 400 def test_post_anonymous_booking_demo_property_no_notifications( client: TestClient, db: Session, demo_space: Space, test_admin: User ) -> None: start = datetime(2025, 8, 4, 9, 0, 0) end = datetime(2025, 8, 4, 10, 0, 0) response = client.post("/api/public/bookings", json=_guest_payload(demo_space.id, start, end, "demoguest@example.com")) assert response.status_code == 201 booking_id = response.json()["id"] notifications = db.query(Notification).filter(Notification.booking_id == booking_id).all() assert notifications == [] def test_post_anonymous_booking_invalid_email_returns_422(client: TestClient, public_space: Space) -> None: start = datetime(2025, 8, 5, 9, 0, 0) end = datetime(2025, 8, 5, 10, 0, 0) payload = _guest_payload(public_space.id, start, end, "not-an-email") response = client.post("/api/public/bookings", json=payload) assert response.status_code == 422