test(T05): contact router, rental router, email service, rate limiting tests + retry queue
This commit is contained in:
Binary file not shown.
Binary file not shown.
+18
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Binary file not shown.
@@ -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())
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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"""
|
||||
<h2>Neue Kontaktanfrage</h2>
|
||||
@@ -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
|
||||
|
||||
+52
-77
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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="<p>Test</p>",
|
||||
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="<p>Body</p>",
|
||||
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="<p>Retry</p>",
|
||||
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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user