diff --git a/backend/.coverage b/backend/.coverage index 568c3d8..22ade90 100644 Binary files a/backend/.coverage and b/backend/.coverage differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index 08b227d..aaf1808 100644 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and b/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/main.py b/backend/app/main.py index d1b181b..b7be580 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,11 +6,11 @@ from fastapi.middleware.cors import CORSMiddleware from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import get_settings -from app.database import engine, init_db +from app.database import engine, init_db, async_session from app.cache import cache from app.routers import equipment, health, admin, rental_requests, contact from app.services.sync_service import SyncService -from app.database import async_session +from app.services.email_service import EmailService logger = logging.getLogger(__name__) settings = get_settings() @@ -27,6 +27,14 @@ async def run_equipment_sync() -> None: logger.info("Scheduled sync complete: %s", result) +async def run_email_retry() -> None: + """Scheduled job: retry failed emails every 15 minutes.""" + logger.info("Starting scheduled email retry") + async with async_session() as db: + sent = await EmailService.retry_failed_emails(db) + logger.info("Email retry complete: %d emails sent", sent) + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: init DB, start scheduler, connect cache.""" @@ -44,8 +52,15 @@ async def lifespan(app: FastAPI): id="equipment_sync", replace_existing=True, ) + scheduler.add_job( + run_email_retry, + trigger="cron", + minute="*/15", + id="email_retry", + replace_existing=True, + ) scheduler.start() - logger.info("APScheduler started with equipment_sync job (every 6h)") + logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)") yield diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 51d7e33..e577eed 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,5 +4,6 @@ from app.models.rental_request import RentalRequest, RentalRequestItem from app.models.contact import Contact from app.models.admin_user import AdminUser from app.models.sync_log import SyncLog +from app.models.email_queue import EmailQueue -__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog"] +__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog", "EmailQueue"] diff --git a/backend/app/models/__pycache__/__init__.cpython-313.pyc b/backend/app/models/__pycache__/__init__.cpython-313.pyc index 45bbd7b..da5cae6 100644 Binary files a/backend/app/models/__pycache__/__init__.cpython-313.pyc and b/backend/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/models/email_queue.py b/backend/app/models/email_queue.py new file mode 100644 index 0000000..2fbaf76 --- /dev/null +++ b/backend/app/models/email_queue.py @@ -0,0 +1,18 @@ +"""Email queue model for failed email retry.""" +from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, func +from app.database import Base + + +class EmailQueue(Base): + __tablename__ = "email_queue" + + id = Column(Integer, primary_key=True, autoincrement=True) + to_addr = Column(String(255), nullable=False) + subject = Column(String(255), nullable=False) + html_body = Column(Text, nullable=False) + text_body = Column(Text) + reply_to = Column(String(255)) + status = Column(String(32), default="pending", nullable=False) + attempts = Column(Integer, default=0, nullable=False) + created_at = Column(TIMESTAMP, server_default=func.now()) + updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/__pycache__/contact.cpython-313.pyc b/backend/app/routers/__pycache__/contact.cpython-313.pyc index 4ff5111..1328dda 100644 Binary files a/backend/app/routers/__pycache__/contact.cpython-313.pyc and b/backend/app/routers/__pycache__/contact.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc b/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc index 9cdcca8..5b8c0a0 100644 Binary files a/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc and b/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc differ diff --git a/backend/app/routers/contact.py b/backend/app/routers/contact.py index fcf9d23..281ce81 100644 --- a/backend/app/routers/contact.py +++ b/backend/app/routers/contact.py @@ -35,14 +35,15 @@ async def create_contact( email_service = EmailService() try: - await email_service.send_contact_email({ + sent = await email_service.send_contact_email({ "name": payload.name, "email": str(payload.email), "phone": payload.phone, "message": payload.message, - }) - contact.email_sent = True - await db.commit() + }, db=db) + if sent: + contact.email_sent = True + await db.commit() except Exception: pass diff --git a/backend/app/routers/rental_requests.py b/backend/app/routers/rental_requests.py index 8b3020c..6faeee6 100644 --- a/backend/app/routers/rental_requests.py +++ b/backend/app/routers/rental_requests.py @@ -133,6 +133,7 @@ async def create_rental_request( reference_number=ref_number, event_name=payload.event_name, items=items_data, + db=db, ) except Exception: # Email failure should not affect response diff --git a/backend/app/services/__pycache__/email_service.cpython-313.pyc b/backend/app/services/__pycache__/email_service.cpython-313.pyc index 0565297..34b3225 100644 Binary files a/backend/app/services/__pycache__/email_service.cpython-313.pyc and b/backend/app/services/__pycache__/email_service.cpython-313.pyc differ diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 28a5d30..a5aee9d 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,17 +1,24 @@ -"""Email service using aiosmtplib for contact and rental confirmation emails.""" +"""Email service using aiosmtplib for contact and rental confirmation emails. + +Includes a failed-email queue: when SMTP fails and a db session is provided, +the email is persisted to the email_queue table for later retry via APScheduler. +""" import logging from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from typing import Any +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession import aiosmtplib from app.config import get_settings +from app.models.email_queue import EmailQueue logger = logging.getLogger(__name__) settings = get_settings() class EmailService: - """Send transactional emails via SMTP.""" + """Send transactional emails via SMTP with failed-email queue support.""" async def send_email( self, @@ -20,8 +27,13 @@ class EmailService: html_body: str, text_body: str = "", reply_to: str | None = None, + db: AsyncSession | None = None, ) -> bool: - """Send an email via aiosmtplib. Returns True on success.""" + """Send an email via aiosmtplib. Returns True on success. + + If SMTP fails and a db session is provided, the email is saved to + the email_queue table for later retry. + """ msg = MIMEMultipart("alternative") msg["From"] = settings.smtp_from msg["To"] = to_addr @@ -43,9 +55,27 @@ class EmailService: return True except Exception as exc: logger.error("Email send failed to %s: %s", to_addr, exc) + if db is not None: + try: + queued = EmailQueue( + to_addr=to_addr, + subject=subject, + html_body=html_body, + text_body=text_body, + reply_to=reply_to, + status="pending", + attempts=0, + ) + db.add(queued) + await db.commit() + logger.info("Email queued for retry: %s", to_addr) + except Exception as queue_exc: + logger.error("Failed to queue email: %s", queue_exc) return False - async def send_contact_email(self, contact_data: dict[str, Any]) -> bool: + async def send_contact_email( + self, contact_data: dict[str, Any], db: AsyncSession | None = None + ) -> bool: """Send contact form data to info@hms-licht-ton.de.""" html = f"""

Neue Kontaktanfrage

@@ -68,6 +98,7 @@ class EmailService: html_body=html, text_body=text, reply_to=contact_data.get("email"), + db=db, ) async def send_rental_confirmation( @@ -76,6 +107,7 @@ class EmailService: reference_number: str, event_name: str, items: list[dict[str, Any]], + db: AsyncSession | None = None, ) -> bool: """Send rental request confirmation to customer.""" items_html = "".join( @@ -107,4 +139,48 @@ class EmailService: subject=f"Mietanfrage bestaetigt – {reference_number}", html_body=html, text_body=text, + db=db, ) + + @staticmethod + async def retry_failed_emails(db: AsyncSession, max_attempts: int = 5) -> int: + """Retry all pending emails in the queue. Returns count of successfully sent emails.""" + result = await db.execute( + select(EmailQueue).where( + EmailQueue.status == "pending", + EmailQueue.attempts < max_attempts, + ) + ) + pending = result.scalars().all() + sent_count = 0 + + for item in pending: + item.attempts += 1 + msg = MIMEMultipart("alternative") + msg["From"] = settings.smtp_from + msg["To"] = item.to_addr + msg["Subject"] = item.subject + if item.reply_to: + msg["Reply-To"] = item.reply_to + msg.attach(MIMEText(item.text_body or item.html_body, "plain")) + msg.attach(MIMEText(item.html_body, "html")) + + try: + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + start_tls=True, + ) + item.status = "sent" + sent_count += 1 + logger.info("Retry succeeded for email to %s", item.to_addr) + except Exception as exc: + logger.error("Retry failed for email to %s: %s", item.to_addr, exc) + if item.attempts >= max_attempts: + item.status = "failed" + + await db.commit() + return sent_count diff --git a/backend/test_report.md b/backend/test_report.md index b24d483..d864d57 100644 --- a/backend/test_report.md +++ b/backend/test_report.md @@ -1,89 +1,64 @@ -# Test Report – T04: Rentman Integration + Admin Auth +# Test Report – T05: Email Service + Contact & Rental Request Backend -**Datum:** 2026-07-09 -**Task:** T04 (Rentman Integration: Import + Request + Admin Auth) -**Projekt:** hms-licht-ton - -## Test Commands Run +## Test Commands +### Targeted T05 Tests ```bash -cd backend && python -m pytest tests/ -v --cov=app --cov-report=term-missing +python -m pytest tests/test_contact_router.py tests/test_rental_router.py tests/test_email_service.py tests/test_rate_limiting.py -v ``` +**Result: 23 passed in 0.81s** -## Results +### Full Suite with Coverage +```bash +python -m pytest tests/ -v --cov=app --cov-report=term-missing +``` +**Result: 57 passed, 4 warnings in 7.31s** +**Coverage: 78% overall** -- **Total tests:** 34 -- **Passed:** 34 -- **Failed:** 0 -- **Coverage:** 71% overall +## Test Files Created -## Test Breakdown +### tests/test_contact_router.py (6 tests) +- test_contact_valid_returns_200 – POST /api/contact valid → 200, success=True +- test_contact_invalid_email_returns_422 – invalid email → 422 +- test_contact_privacy_consent_false_returns_422 – privacy_consent=false → 422 +- test_contact_triggers_email – mock EmailService.send_contact_email called with correct data +- test_contact_saved_to_db – Contact record saved with email_sent=True +- test_contact_email_failure_does_not_break_response – email failure still returns 200 -### test_admin_auth.py (9 tests) – T04 -- test_password_hash_and_verify ✅ -- test_create_and_verify_token ✅ -- test_verify_invalid_token ✅ -- test_verify_expired_token ✅ -- test_login_success (valid creds → 200 + token) ✅ -- test_login_failure_wrong_password (→ 401) ✅ -- test_login_failure_unknown_user (→ 401) ✅ -- test_me_without_token (→ 401) ✅ -- test_me_with_valid_token (→ 200 + username) ✅ +### tests/test_rental_router.py (7 tests) +- test_rental_valid_returns_201 – valid POST → 201 with HMS-YYYY-NNNNN reference +- test_rental_date_end_before_start_returns_422 – date_end < date_start → 422 +- test_rental_empty_items_returns_422 – empty items array → 422 +- test_rental_saved_to_db – RentalRequest + RentalRequestItem saved, rentman_sync_status=success +- test_rental_triggers_rentman – RentmanService.create_project_request + add_equipment_to_request called +- test_rental_triggers_email – EmailService.send_rental_confirmation called with ref + items +- test_rental_rentman_failure_still_returns_201 – Rentman failure still returns 201 -### test_admin_router.py (6 tests) – T04 -- test_sync_without_token (→ 401) ✅ -- test_sync_status_without_token (→ 401) ✅ -- test_sync_log_without_token (→ 401) ✅ -- test_sync_with_valid_token (→ 200 + sync_id) ✅ -- test_sync_status_with_valid_token (→ 200 + status) ✅ -- test_sync_log_with_valid_token (→ 200 + paginated) ✅ +### tests/test_email_service.py (7 tests) +- test_send_contact_email_success – aiosmtplib.send called, MIMEMultipart contains name + message +- test_send_rental_confirmation_success – email contains reference_number + items +- test_send_email_smtp_failure_returns_false – SMTP exception → returns False, no crash +- test_send_contact_email_smtp_failure_returns_false – contact email SMTP failure → False +- test_send_rental_confirmation_smtp_failure_returns_false – rental email SMTP failure → False +- test_send_email_failure_saves_to_queue – failed email saved to email_queue table +- test_retry_failed_emails_resends_pending – retry sends queued email, marks as sent -### test_rentman_import.py (5 tests) – T04 -- test_transform_equipment (field mapping) ✅ -- test_paginated_import (250 items, 3 pages, upsert verified) ✅ -- test_sync_upsert_existing (update not duplicate) ✅ -- test_sync_failure_logs_error (error logged in sync_log) ✅ -- test_get_all_equipment_paginates (iterates until data empty) ✅ +### tests/test_rate_limiting.py (3 tests) +- test_contact_rate_limit_allows_5_blocks_6th – 5 requests OK, 6th → 429 +- test_rental_rate_limit_allows_5_blocks_6th – 5 requests OK, 6th → 429 +- test_contact_rate_window_reset – rate window reset allows new requests after TTL -### test_rentman_request.py (5 tests) – T04 -- test_build_project_request_payload (full field mapping) ✅ -- test_build_project_request_payload_no_company (fallback to contact_name) ✅ -- test_build_equipment_payload (equipment mapping with linked_equipment) ✅ -- test_projectrequest_success (mock POST returns ID) ✅ -- test_equipment_retry (3 retries with exponential backoff) ✅ +## New Feature: Email Retry Queue +- `app/models/email_queue.py` – EmailQueue model (id, to_addr, subject, html_body, text_body, reply_to, status, attempts, created_at, updated_at) +- `app/services/email_service.py` – send_email now accepts optional `db` param; on SMTP failure saves to queue +- `EmailService.retry_failed_emails(db)` – static method to retry pending emails (max 5 attempts) +- `app/main.py` – APScheduler job `run_email_retry` every 15 minutes +- Routers updated to pass `db` to email service methods -### test_equipment_router.py (8 tests) – T03 -- test_list_equipment (paginated response) ✅ -- test_search_equipment (filter by name) ✅ -- test_filter_category (filter by category) ✅ -- test_sort_name_asc ✅ -- test_sort_name_desc ✅ -- test_categories (distinct categories) ✅ -- test_equipment_detail (full detail) ✅ -- test_equipment_not_found (404) ✅ - -### test_health.py (1 test) – T03 -- test_health_endpoint (status, db, redis) ✅ - -## Coverage by Module - -| Module | Coverage | -|--------|---------| -| app/auth.py | 86% | -| app/routers/admin.py | 84% | -| app/services/sync_service.py | 82% | -| app/services/rentman_service.py | 75% | -| app/routers/equipment.py | 66% | -| app/main.py | 64% | -| app/routers/health.py | 60% | -| app/models/* | 100% | -| app/schemas/* | 76-100% | -| app/config.py | 100% | - -## Notes - -- Rentman API fully mocked in tests (no live calls) -- Redis cache fully mocked in tests -- SQLite in-memory database used for tests -- APScheduler startup tested via lifespan context -- Rate limiting tested via mock counter +## Smoke Test +- All 57 tests pass (23 new T05 + 34 existing) +- No existing tests broken +- Coverage: 78% overall, email_service 90%, contact.py 67%, rental_requests.py 46% +- Rate limiting works via mock cache.incr_rate with sequential return values +- Email queue persistence verified with in-memory SQLite +- Retry mechanism verified: pending → sent on success diff --git a/backend/tests/test_contact_router.py b/backend/tests/test_contact_router.py new file mode 100644 index 0000000..7649a4a --- /dev/null +++ b/backend/tests/test_contact_router.py @@ -0,0 +1,120 @@ +"""Tests for contact router (T05).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from sqlalchemy import select + +from app.models.contact import Contact + + +@pytest.mark.asyncio +async def test_contact_valid_returns_200(client): + """POST /api/contact with valid data returns 200 and saves to contacts table.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/contact", json={ + "name": "Max Mustermann", + "email": "max@example.com", + "phone": "+49 123 456789", + "message": "Ich brauche eine PA-Anlage fuer ein Festival.", + "privacy_consent": True, + }) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + +@pytest.mark.asyncio +async def test_contact_invalid_email_returns_422(client): + """POST /api/contact with invalid email returns 422.""" + resp = await client.post("/api/contact", json={ + "name": "Max Mustermann", + "email": "not-an-email", + "phone": "+49 123 456789", + "message": "Test message", + "privacy_consent": True, + }) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_contact_privacy_consent_false_returns_422(client): + """POST /api/contact with privacy_consent=false returns 422.""" + resp = await client.post("/api/contact", json={ + "name": "Max Mustermann", + "email": "max@example.com", + "phone": "+49 123 456789", + "message": "Test message", + "privacy_consent": False, + }) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_contact_triggers_email(client): + """POST /api/contact triggers send_contact_email on EmailService.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ) as mock_send: + resp = await client.post("/api/contact", json={ + "name": "Erika Musterfrau", + "email": "erika@example.com", + "phone": "+49 987 654321", + "message": "Ich benoetige Beleuchtung fuer eine Gala.", + "privacy_consent": True, + }) + assert resp.status_code == 200 + mock_send.assert_awaited_once() + call_args = mock_send.call_args[0][0] + assert call_args["name"] == "Erika Musterfrau" + assert call_args["email"] == "erika@example.com" + assert call_args["message"] == "Ich benoetige Beleuchtung fuer eine Gala." + + +@pytest.mark.asyncio +async def test_contact_saved_to_db(client, test_db): + """POST /api/contact saves a record to the contacts table.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/contact", json={ + "name": "DB Testuser", + "email": "db@example.com", + "phone": "+49 111 222333", + "message": "Datenbank-Verifikation", + "privacy_consent": True, + }) + assert resp.status_code == 200 + + result = await test_db.execute(select(Contact).where(Contact.email == "db@example.com")) + contact = result.scalar_one_or_none() + assert contact is not None + assert contact.name == "DB Testuser" + assert contact.message == "Datenbank-Verifikation" + assert contact.privacy_consent is True + assert contact.email_sent is True + + +@pytest.mark.asyncio +async def test_contact_email_failure_does_not_break_response(client): + """POST /api/contact still returns 200 when email sending fails.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=False, + ): + resp = await client.post("/api/contact", json={ + "name": "Fail Test", + "email": "fail@example.com", + "phone": "+49 000 000000", + "message": "Email soll fehlschlagen", + "privacy_consent": True, + }) + assert resp.status_code == 200 + assert resp.json()["success"] is True diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py new file mode 100644 index 0000000..5436d42 --- /dev/null +++ b/backend/tests/test_email_service.py @@ -0,0 +1,169 @@ +"""Tests for email service (T05).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from email.mime.multipart import MIMEMultipart + +from app.services.email_service import EmailService + + +@pytest.mark.asyncio +async def test_send_contact_email_success(): + """send_contact_email sends correct email via aiosmtplib.""" + service = EmailService() + contact_data = { + "name": "Test User", + "email": "test@example.com", + "phone": "+49 123 456", + "message": "Hallo Welt", + } + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock) as mock_send: + result = await service.send_contact_email(contact_data) + assert result is True + mock_send.assert_awaited_once() + sent_msg = mock_send.call_args[0][0] + assert isinstance(sent_msg, MIMEMultipart) + assert "Hallo Welt" in str(sent_msg) + assert "Test User" in str(sent_msg) + + +@pytest.mark.asyncio +async def test_send_rental_confirmation_success(): + """send_rental_confirmation sends email with reference_number and items.""" + service = EmailService() + items = [ + {"equipment_name": "L-Acoustics K2", "quantity": 2}, + {"equipment_name": "d&b T10", "quantity": 4}, + ] + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock) as mock_send: + result = await service.send_rental_confirmation( + to_addr="customer@example.com", + reference_number="HMS-2026-00042", + event_name="Rock Festival", + items=items, + ) + assert result is True + mock_send.assert_awaited_once() + sent_msg = mock_send.call_args[0][0] + msg_str = str(sent_msg) + assert "HMS-2026-00042" in msg_str + assert "Rock Festival" in msg_str + assert "L-Acoustics K2" in msg_str + assert "d&b T10" in msg_str + + +@pytest.mark.asyncio +async def test_send_email_smtp_failure_returns_false(): + """SMTP failure returns False, no crash, graceful handling.""" + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("SMTP refused")): + result = await service.send_email( + to_addr="fail@example.com", + subject="Test Subject", + html_body="

Test

", + text_body="Test", + ) + assert result is False + + +@pytest.mark.asyncio +async def test_send_contact_email_smtp_failure_returns_false(): + """send_contact_email returns False on SMTP failure without crashing.""" + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("Connection refused")): + result = await service.send_contact_email({ + "name": "Fail User", + "email": "fail@example.com", + "phone": "+49 000", + "message": "Soll fehlschlagen", + }) + assert result is False + + +@pytest.mark.asyncio +async def test_send_rental_confirmation_smtp_failure_returns_false(): + """send_rental_confirmation returns False on SMTP failure without crashing.""" + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("Timeout")): + result = await service.send_rental_confirmation( + to_addr="fail@example.com", + reference_number="HMS-2026-99999", + event_name="Fail Event", + items=[{"equipment_name": "Speaker", "quantity": 1}], + ) + assert result is False + + +@pytest.mark.asyncio +async def test_send_email_failure_saves_to_queue(): + """send_email saves failed email to email_queue when db is provided.""" + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from app.database import Base + from app.models.email_queue import EmailQueue + from sqlalchemy import select + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("SMTP down")): + async with session_maker() as db: + result = await service.send_email( + to_addr="queue@example.com", + subject="Queued Email", + html_body="

Body

", + text_body="Body", + reply_to="reply@example.com", + db=db, + ) + assert result is False + + async with session_maker() as db: + queued = await db.execute(select(EmailQueue).where(EmailQueue.to_addr == "queue@example.com")) + item = queued.scalar_one_or_none() + assert item is not None + assert item.subject == "Queued Email" + assert item.status == "pending" + assert item.attempts == 0 + assert item.reply_to == "reply@example.com" + + await engine.dispose() + + +@pytest.mark.asyncio +async def test_retry_failed_emails_resends_pending(): + """retry_failed_emails retries pending queue items and marks sent on success.""" + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from app.database import Base + from app.models.email_queue import EmailQueue + from sqlalchemy import select + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with session_maker() as db: + db.add(EmailQueue( + to_addr="retry@example.com", + subject="Retry Test", + html_body="

Retry

", + text_body="Retry", + status="pending", + attempts=0, + )) + await db.commit() + + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock): + async with session_maker() as db: + sent_count = await EmailService.retry_failed_emails(db) + assert sent_count == 1 + + async with session_maker() as db: + result = await db.execute(select(EmailQueue).where(EmailQueue.to_addr == "retry@example.com")) + item = result.scalar_one_or_none() + assert item.status == "sent" + assert item.attempts == 1 + + await engine.dispose() diff --git a/backend/tests/test_rate_limiting.py b/backend/tests/test_rate_limiting.py new file mode 100644 index 0000000..7ad5eb3 --- /dev/null +++ b/backend/tests/test_rate_limiting.py @@ -0,0 +1,140 @@ +"""Tests for rate limiting on contact and rental endpoints (T05).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.database import Base, get_db +from app.models import EquipmentCache + + +VALID_CONTACT = { + "name": "Rate Limit", + "email": "rate@example.com", + "phone": "+49 123", + "message": "Rate limit test", + "privacy_consent": True, +} + +VALID_RENTAL = { + "event_name": "Rate Limit Fest", + "date_start": "2026-09-01", + "date_end": "2026-09-02", + "location": "Hamburg", + "contact_name": "RL User", + "contact_email": "rl@example.com", + "items": [{"equipment_id": 1, "quantity": 1}], +} + + +def _make_rate_limit_client(test_engine, rate_counts: list[int]): + """Create a client whose cache.incr_rate returns sequential values from rate_counts.""" + session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + call_index = {"i": 0} + + async def override_get_db(): + async with session_maker() as session: + yield session + + async def mock_incr_rate(key: str, window: int = 60) -> int: + idx = call_index["i"] + if idx < len(rate_counts): + val = rate_counts[idx] + else: + val = rate_counts[-1] + 1 + call_index["i"] += 1 + return val + + mock_cache = MagicMock() + mock_cache.get = AsyncMock(return_value=None) + mock_cache.set = AsyncMock(return_value=None) + mock_cache.delete_pattern = AsyncMock(return_value=0) + mock_cache.incr_rate = mock_incr_rate + mock_cache.connect = AsyncMock(return_value=None) + mock_cache._redis = MagicMock() + mock_cache._redis.ping = AsyncMock(return_value=True) + + async def _yield_client(): + with patch("app.cache.cache", mock_cache), \ + patch("app.routers.equipment.cache", mock_cache), \ + patch("app.routers.admin.cache", mock_cache), \ + patch("app.routers.rental_requests.cache", mock_cache), \ + patch("app.routers.contact.cache", mock_cache), \ + patch("app.services.sync_service.cache", mock_cache): + from app.main import app + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() + + return _yield_client + + +@pytest.mark.asyncio +async def test_contact_rate_limit_allows_5_blocks_6th(test_engine): + """5 requests OK, 6th request returns 429 for contact endpoint.""" + rate_counts = [1, 2, 3, 4, 5, 6] + client_gen = _make_rate_limit_client(test_engine, rate_counts) + async for client in client_gen(): + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + for i in range(5): + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200, f"Request {i+1} should succeed" + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 429 + + +@pytest.mark.asyncio +async def test_rental_rate_limit_allows_5_blocks_6th(test_engine, seeded_equipment): + """5 requests OK, 6th request returns 429 for rental-requests endpoint.""" + rate_counts = [1, 2, 3, 4, 5, 6] + client_gen = _make_rate_limit_client(test_engine, rate_counts) + async for client in client_gen(): + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rl-test"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-rl"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + for i in range(5): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL) + assert resp.status_code == 201, f"Request {i+1} should succeed" + resp = await client.post("/api/rental-requests", json=VALID_RENTAL) + assert resp.status_code == 429 + + +@pytest.mark.asyncio +async def test_contact_rate_window_reset(test_engine): + """Rate window resets after TTL: first 5 OK, then 429, then after reset 5 more OK.""" + rate_counts = [1, 2, 3, 4, 5, 6, 1, 2] + client_gen = _make_rate_limit_client(test_engine, rate_counts) + async for client in client_gen(): + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + for i in range(5): + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200 + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 429 + # After window reset, counter starts at 1 again + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200 + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200 + + diff --git a/backend/tests/test_rental_router.py b/backend/tests/test_rental_router.py new file mode 100644 index 0000000..b4684b5 --- /dev/null +++ b/backend/tests/test_rental_router.py @@ -0,0 +1,175 @@ +"""Tests for rental request router (T05).""" +import pytest +from unittest.mock import AsyncMock, patch +from sqlalchemy import select + +from app.models.rental_request import RentalRequest, RentalRequestItem + + +VALID_RENTAL_PAYLOAD = { + "event_name": "Sommerfestival 2026", + "date_start": "2026-08-15", + "date_end": "2026-08-17", + "location": "Berlin", + "person_count": 500, + "contact_name": "Max Veranstalter", + "contact_company": "Event GmbH", + "contact_email": "max@event.de", + "contact_phone": "+49 30 1234567", + "contact_street": "Hauptstr. 42", + "contact_postalcode": "10115", + "contact_city": "Berlin", + "message": "Bitte um Angebot", + "items": [{"equipment_id": 1, "quantity": 2}], +} + + +@pytest.mark.asyncio +async def test_rental_valid_returns_201(client, seeded_equipment): + """POST /api/rental-requests with valid data returns 201 with reference number.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-123"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-1"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + data = resp.json() + assert "reference_number" in data + ref = data["reference_number"] + assert ref.startswith("HMS-") + parts = ref.split("-") + assert len(parts) == 3 + assert parts[1].isdigit() + assert len(parts[2]) == 5 + assert parts[2].isdigit() + assert data["status"] == "pending" + + +@pytest.mark.asyncio +async def test_rental_date_end_before_start_returns_422(client, seeded_equipment): + """POST /api/rental-requests with date_end < date_start returns 422.""" + payload = {**VALID_RENTAL_PAYLOAD, "date_start": "2026-08-20", "date_end": "2026-08-15"} + resp = await client.post("/api/rental-requests", json=payload) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_rental_empty_items_returns_422(client, seeded_equipment): + """POST /api/rental-requests with empty items array returns 422.""" + payload = {**VALID_RENTAL_PAYLOAD, "items": []} + resp = await client.post("/api/rental-requests", json=payload) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_rental_saved_to_db(client, seeded_equipment, test_db): + """POST /api/rental-requests saves to rental_requests and rental_request_items tables.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-456"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-2"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + ref_number = resp.json()["reference_number"] + + result = await test_db.execute( + select(RentalRequest).where(RentalRequest.reference_number == ref_number) + ) + rental = result.scalar_one_or_none() + assert rental is not None + assert rental.event_name == "Sommerfestival 2026" + assert rental.contact_name == "Max Veranstalter" + assert rental.status == "pending" + assert rental.rentman_request_id == "rentman-456" + assert rental.rentman_sync_status == "success" + + items_result = await test_db.execute( + select(RentalRequestItem).where(RentalRequestItem.rental_request_id == rental.id) + ) + items = items_result.scalars().all() + assert len(items) == 1 + assert items[0].equipment_id == 1 + assert items[0].quantity == 2 + assert items[0].equipment_name == "L-Acoustics K2" + + +@pytest.mark.asyncio +async def test_rental_triggers_rentman(client, seeded_equipment): + """POST /api/rental-requests triggers Rentman create_project_request.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-789"}, + ) as mock_create, patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-3"}, + ) as mock_add, patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + mock_create.assert_awaited_once() + mock_add.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_rental_triggers_email(client, seeded_equipment): + """POST /api/rental-requests triggers confirmation email.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-email-test"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-4"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ) as mock_email: + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + mock_email.assert_awaited_once() + call_kwargs = mock_email.call_args.kwargs + assert call_kwargs["to_addr"] == "max@event.de" + assert call_kwargs["event_name"] == "Sommerfestival 2026" + assert call_kwargs["reference_number"].startswith("HMS-") + assert len(call_kwargs["items"]) == 1 + + +@pytest.mark.asyncio +async def test_rental_rentman_failure_still_returns_201(client, seeded_equipment): + """POST /api/rental-requests still returns 201 when Rentman API fails.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + side_effect=Exception("Rentman API error"), + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201