feat: sistem de linkuri publice pentru proprietăți
Expune fluxul de rezervare anonimă printr-un link public partajabil
(/book/<slug>), cu calendar public, cod QR și proprietate demo publică.
Backend:
- slug + list_on_landing pe Property; utilitar slugify (diacritice RO,
cuvinte rezervate, ban all-digit) + migrare idempotentă raw-DDL rulată
din entrypoint; SQLite WAL + busy_timeout
- GET /public/properties/{slug_or_id}(+/spaces): rezolvare slug/ID, 404 uniform
- GET /public/spaces/{id}/busy: doar {start_time,end_time} (fără PII),
pending+approved, tz-aware/interval>60z → 422, orfan/privat → 404
- availability: scrub user_name/title (fix scurgere PII)
- POST /public/bookings: overlap pending+approved, cap 5/email/spațiu/zi,
rate-limit per-IP, suprimare notificări+email pe demo, email confirmare guest
- PATCH slug (409 duplicat / 422 format / 403 non-manager); guest_email EmailStr
- email guest la aprobare/respingere cu cod #RB-<id>
- teste noi: test_public.py + test_migrate_slug.py (33 teste)
Frontend:
- rută /book/:slug; PublicBooking rescris cu calendar grid manual
(PublicCalendar + GuestBookingForm), 30min, 08-20, mobil day-view, RO
- card „Link public" în PropertyDetail: copy, edit slug, QR pe URL-ul cu ID
- secțiune proprietăți publice + CTA pe Landing; buton copy în Properties
Gate aprobat (U1-U3, G1 grid manual, G2 list_on_landing); G3-G5 în TODOS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
492
backend/tests/test_public.py
Normal file
492
backend/tests/test_public.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user