feat: Space Booking System - MVP complet

Sistem web pentru rezervarea de birouri și săli de ședință
cu flux de aprobare administrativă.

Stack: FastAPI + Vue.js 3 + SQLite + TypeScript

Features implementate:
- Autentificare JWT + Self-registration cu email verification
- CRUD Spații, Utilizatori, Settings (Admin)
- Calendar interactiv (FullCalendar) cu drag-and-drop
- Creare rezervări cu validare (durată, program, overlap, max/zi)
- Rezervări recurente (săptămânal)
- Admin: aprobare/respingere/anulare cereri
- Admin: creare directă rezervări (bypass approval)
- Admin: editare orice rezervare
- User: editare/anulare rezervări proprii
- Notificări in-app (bell icon + dropdown)
- Notificări email (async SMTP cu BackgroundTasks)
- Jurnal acțiuni administrative (audit log)
- Rapoarte avansate (utilizare, top users, approval rate)
- Șabloane rezervări (booking templates)
- Atașamente fișiere (upload/download)
- Conflict warnings (verificare disponibilitate real-time)
- Integrare Google Calendar (OAuth2)
- Suport timezone (UTC storage + user preference)
- 225+ teste backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-09 17:51:29 +00:00
commit df4031d99c
113 changed files with 24491 additions and 0 deletions

View File

@@ -0,0 +1,328 @@
"""Tests for recurring booking endpoints."""
from datetime import date, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.booking import Booking
def test_create_recurring_booking_success(
client: TestClient, user_token: str, test_space, db: Session
):
"""Test successful creation of recurring bookings."""
# Create 4 Monday bookings (4 weeks)
start_date = date.today() + timedelta(days=7) # Next week
# Find the next Monday
while start_date.weekday() != 0: # 0 = Monday
start_date += timedelta(days=1)
end_date = start_date + timedelta(days=21) # 3 weeks later
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_time": "10:00",
"duration_minutes": 60,
"title": "Weekly Team Sync",
"description": "Regular team meeting",
"recurrence_days": [0], # Monday only
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 201
data = response.json()
assert data["total_requested"] == 4
assert data["total_created"] == 4
assert data["total_skipped"] == 0
assert len(data["created_bookings"]) == 4
assert len(data["skipped_dates"]) == 0
# Verify all bookings were created
bookings = db.query(Booking).filter(Booking.title == "Weekly Team Sync").all()
assert len(bookings) == 4
def test_create_recurring_booking_multiple_days(
client: TestClient, user_token: str, test_space, db: Session
):
"""Test recurring booking on multiple days (Mon, Wed, Fri)."""
start_date = date.today() + timedelta(days=7)
# Find the next Monday
while start_date.weekday() != 0:
start_date += timedelta(days=1)
end_date = start_date + timedelta(days=14) # 2 weeks
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_time": "14:00",
"duration_minutes": 90,
"title": "MWF Sessions",
"recurrence_days": [0, 2, 4], # Mon, Wed, Fri
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 201
data = response.json()
# Could be 6 or 7 depending on whether start_date itself is included
assert data["total_created"] >= 6
assert data["total_created"] <= 7
assert data["total_skipped"] == 0
def test_create_recurring_booking_skip_conflicts(
client: TestClient, user_token: str, test_space, admin_token: str, db: Session
):
"""Test skipping conflicted dates."""
start_date = date.today() + timedelta(days=7)
# Find the next Monday
while start_date.weekday() != 0:
start_date += timedelta(days=1)
# Create a conflicting booking on the 2nd Monday
conflict_date = start_date + timedelta(days=7)
client.post(
"/api/bookings",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_datetime": f"{conflict_date.isoformat()}T10:00:00",
"end_datetime": f"{conflict_date.isoformat()}T11:00:00",
"title": "Conflicting Booking",
},
)
# Now create recurring booking that will hit the conflict
end_date = start_date + timedelta(days=21)
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_time": "10:00",
"duration_minutes": 60,
"title": "Weekly Recurring",
"recurrence_days": [0], # Monday
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 201
data = response.json()
assert data["total_requested"] == 4
assert data["total_created"] == 3 # One skipped
assert data["total_skipped"] == 1
assert len(data["skipped_dates"]) == 1
assert data["skipped_dates"][0]["date"] == conflict_date.isoformat()
assert "deja rezervat" in data["skipped_dates"][0]["reason"].lower()
def test_create_recurring_booking_stop_on_conflict(
client: TestClient, user_token: str, test_space, db: Session
):
"""Test stopping on first conflict."""
start_date = date.today() + timedelta(days=7)
# Find the next Monday
while start_date.weekday() != 0:
start_date += timedelta(days=1)
# Create a conflicting booking on the 2nd Monday
conflict_date = start_date + timedelta(days=7)
client.post(
"/api/bookings",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_datetime": f"{conflict_date.isoformat()}T10:00:00",
"end_datetime": f"{conflict_date.isoformat()}T11:00:00",
"title": "Conflicting Booking",
},
)
# Now create recurring booking with skip_conflicts=False
end_date = start_date + timedelta(days=21)
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_time": "10:00",
"duration_minutes": 60,
"title": "Weekly Recurring",
"recurrence_days": [0],
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": False, # Stop on conflict
},
)
assert response.status_code == 201
data = response.json()
assert data["total_requested"] == 4
assert data["total_created"] == 1 # Only first one before conflict
assert data["total_skipped"] == 1
def test_create_recurring_booking_max_occurrences(
client: TestClient, user_token: str, test_space
):
"""Test limiting to 52 occurrences."""
start_date = date.today() + timedelta(days=1)
# Request 52+ weeks but within 1 year limit (365 days)
end_date = start_date + timedelta(days=364) # Within 1 year
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_time": "10:00",
"duration_minutes": 60,
"title": "Long Recurring",
"recurrence_days": [0], # Monday
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 201
data = response.json()
# Should be capped at 52 occurrences
assert data["total_requested"] <= 52
assert data["total_created"] <= 52
def test_create_recurring_booking_validation(client: TestClient, user_token: str):
"""Test validation (invalid days, date range, etc.)."""
start_date = date.today() + timedelta(days=7)
end_date = start_date + timedelta(days=14)
# Invalid recurrence day (> 6)
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": 1,
"start_time": "10:00",
"duration_minutes": 60,
"title": "Invalid Day",
"recurrence_days": [0, 7], # 7 is invalid
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 422
assert "Days must be 0-6" in response.json()["detail"][0]["msg"]
# End date before start date
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": 1,
"start_time": "10:00",
"duration_minutes": 60,
"title": "Invalid Range",
"recurrence_days": [0],
"start_date": end_date.isoformat(),
"end_date": start_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 422
assert "end_date must be after start_date" in response.json()["detail"][0]["msg"]
# Date range > 1 year
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": 1,
"start_time": "10:00",
"duration_minutes": 60,
"title": "Too Long",
"recurrence_days": [0],
"start_date": start_date.isoformat(),
"end_date": (start_date + timedelta(days=400)).isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 422
assert "cannot exceed 1 year" in response.json()["detail"][0]["msg"]
def test_create_recurring_booking_invalid_time_format(
client: TestClient, user_token: str, test_space
):
"""Test invalid time format."""
start_date = date.today() + timedelta(days=7)
end_date = start_date + timedelta(days=14)
# Test with malformed time string
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": test_space.id,
"start_time": "abc", # Invalid format
"duration_minutes": 60,
"title": "Invalid Time",
"recurrence_days": [0],
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 400
assert "Invalid start_time format" in response.json()["detail"]
def test_create_recurring_booking_space_not_found(
client: TestClient, user_token: str
):
"""Test recurring booking with non-existent space."""
start_date = date.today() + timedelta(days=7)
end_date = start_date + timedelta(days=14)
response = client.post(
"/api/bookings/recurring",
headers={"Authorization": f"Bearer {user_token}"},
json={
"space_id": 99999, # Non-existent
"start_time": "10:00",
"duration_minutes": 60,
"title": "Test",
"recurrence_days": [0],
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"skip_conflicts": True,
},
)
assert response.status_code == 404
assert "Space not found" in response.json()["detail"]