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:
Claude Agent
2026-07-11 19:04:58 +00:00
parent e38d78ecb8
commit a15951c645
27 changed files with 3311 additions and 375 deletions

View 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