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,305 @@
"""Tests for booking email notifications."""
from datetime import datetime
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.space import Space
from app.models.user import User
@patch("app.api.bookings.send_booking_notification", new_callable=AsyncMock)
def test_booking_creation_sends_email_to_admins(
mock_email: AsyncMock,
client: TestClient,
user_token: str,
test_space: Space,
test_user: User,
test_admin: User,
db: Session,
) -> None:
"""Test that creating a booking sends email notifications to all admins."""
from app.core.security import get_password_hash
# Create another admin user
admin2 = User(
email="admin2@example.com",
full_name="Second Admin",
hashed_password=get_password_hash("password"),
role="admin",
is_active=True,
)
db.add(admin2)
db.commit()
db.refresh(admin2)
# Create a booking
booking_data = {
"space_id": test_space.id,
"start_datetime": "2024-06-15T10:00:00",
"end_datetime": "2024-06-15T12:00:00",
"title": "Team Planning Session",
"description": "Q3 planning and retrospective",
}
response = client.post(
"/api/bookings",
json=booking_data,
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 201
# Verify email was sent to both admins (2 calls)
assert mock_email.call_count == 2
# Verify the calls contain the correct parameters
calls = mock_email.call_args_list
admin_emails = {test_admin.email, admin2.email}
called_emails = {call[0][2] for call in calls} # Third argument is user_email
assert called_emails == admin_emails
# Verify all calls have event_type "created"
for call in calls:
assert call[0][1] == "created" # Second argument is event_type
assert call[0][3] == test_user.full_name # Fourth argument is user_name
@patch("app.api.bookings.send_booking_notification", new_callable=AsyncMock)
def test_booking_approval_sends_email_to_user(
mock_email: AsyncMock,
client: TestClient,
admin_token: str,
test_admin: User,
test_space: Space,
test_user: User,
db: Session,
) -> None:
"""Test that approving a booking sends email notification to the user."""
# Create a pending booking
booking = Booking(
user_id=test_user.id,
space_id=test_space.id,
title="Team Meeting",
description="Q3 Planning",
start_datetime=datetime(2024, 6, 15, 10, 0, 0),
end_datetime=datetime(2024, 6, 15, 12, 0, 0),
status="pending",
created_at=datetime.utcnow(),
)
db.add(booking)
db.commit()
db.refresh(booking)
# Approve the booking
response = client.put(
f"/api/admin/bookings/{booking.id}/approve",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
# Verify email was sent
mock_email.assert_called_once()
# Verify call parameters
call_args = mock_email.call_args[0]
assert call_args[1] == "approved" # event_type
assert call_args[2] == test_user.email # user_email
assert call_args[3] == test_user.full_name # user_name
assert call_args[4] is None # extra_data
@patch("app.api.bookings.send_booking_notification", new_callable=AsyncMock)
def test_booking_rejection_sends_email_with_reason(
mock_email: AsyncMock,
client: TestClient,
admin_token: str,
test_space: Space,
test_user: User,
db: Session,
) -> None:
"""Test that rejecting a booking sends email notification with rejection reason."""
# Create a pending booking
booking = Booking(
user_id=test_user.id,
space_id=test_space.id,
title="Team Meeting",
description="Q3 Planning",
start_datetime=datetime(2024, 6, 15, 10, 0, 0),
end_datetime=datetime(2024, 6, 15, 12, 0, 0),
status="pending",
created_at=datetime.utcnow(),
)
db.add(booking)
db.commit()
db.refresh(booking)
# Reject the booking with reason
rejection_reason = "Space maintenance scheduled"
response = client.put(
f"/api/admin/bookings/{booking.id}/reject",
json={"reason": rejection_reason},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
# Verify email was sent
mock_email.assert_called_once()
# Verify call parameters
call_args = mock_email.call_args[0]
assert call_args[1] == "rejected" # event_type
assert call_args[2] == test_user.email # user_email
assert call_args[3] == test_user.full_name # user_name
# Verify extra_data contains rejection_reason
extra_data = call_args[4]
assert extra_data is not None
assert extra_data["rejection_reason"] == rejection_reason
@patch("app.api.bookings.send_booking_notification", new_callable=AsyncMock)
def test_admin_cancel_sends_email_with_reason(
mock_email: AsyncMock,
client: TestClient,
admin_token: str,
test_space: Space,
test_user: User,
db: Session,
) -> None:
"""Test that admin canceling a booking sends email notification with cancellation reason."""
# Create an approved booking
booking = Booking(
user_id=test_user.id,
space_id=test_space.id,
title="Team Meeting",
description="Q3 Planning",
start_datetime=datetime(2024, 6, 15, 10, 0, 0),
end_datetime=datetime(2024, 6, 15, 12, 0, 0),
status="approved",
created_at=datetime.utcnow(),
)
db.add(booking)
db.commit()
db.refresh(booking)
# Cancel the booking with reason
cancellation_reason = "Emergency maintenance required"
response = client.put(
f"/api/admin/bookings/{booking.id}/cancel",
json={"cancellation_reason": cancellation_reason},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
# Verify email was sent
mock_email.assert_called_once()
# Verify call parameters
call_args = mock_email.call_args[0]
assert call_args[1] == "canceled" # event_type
assert call_args[2] == test_user.email # user_email
assert call_args[3] == test_user.full_name # user_name
# Verify extra_data contains cancellation_reason
extra_data = call_args[4]
assert extra_data is not None
assert extra_data["cancellation_reason"] == cancellation_reason
@patch("app.api.bookings.send_booking_notification", new_callable=AsyncMock)
def test_booking_rejection_without_reason(
mock_email: AsyncMock,
client: TestClient,
admin_token: str,
test_space: Space,
test_user: User,
db: Session,
) -> None:
"""Test that rejecting a booking without reason sends email with None reason."""
# Create a pending booking
booking = Booking(
user_id=test_user.id,
space_id=test_space.id,
title="Team Meeting",
start_datetime=datetime(2024, 6, 15, 10, 0, 0),
end_datetime=datetime(2024, 6, 15, 12, 0, 0),
status="pending",
created_at=datetime.utcnow(),
)
db.add(booking)
db.commit()
db.refresh(booking)
# Reject the booking without reason
response = client.put(
f"/api/admin/bookings/{booking.id}/reject",
json={},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
# Verify email was sent
mock_email.assert_called_once()
# Verify call parameters
call_args = mock_email.call_args[0]
assert call_args[1] == "rejected" # event_type
# Verify extra_data contains rejection_reason as None
extra_data = call_args[4]
assert extra_data is not None
assert extra_data["rejection_reason"] is None
@patch("app.api.bookings.send_booking_notification", new_callable=AsyncMock)
def test_admin_cancel_without_reason(
mock_email: AsyncMock,
client: TestClient,
admin_token: str,
test_space: Space,
test_user: User,
db: Session,
) -> None:
"""Test that admin canceling without reason sends email with None reason."""
# Create a pending booking
booking = Booking(
user_id=test_user.id,
space_id=test_space.id,
title="Client Meeting",
start_datetime=datetime(2024, 6, 16, 14, 0, 0),
end_datetime=datetime(2024, 6, 16, 16, 0, 0),
status="pending",
created_at=datetime.utcnow(),
)
db.add(booking)
db.commit()
db.refresh(booking)
# Cancel the booking without reason
response = client.put(
f"/api/admin/bookings/{booking.id}/cancel",
json={},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
# Verify email was sent
mock_email.assert_called_once()
# Verify call parameters
call_args = mock_email.call_args[0]
assert call_args[1] == "canceled" # event_type
# Verify extra_data contains cancellation_reason as None
extra_data = call_args[4]
assert extra_data is not None
assert extra_data["cancellation_reason"] is None