test(T05): contact router, rental router, email service, rate limiting tests + retry queue

This commit is contained in:
Implementation Engineer
2026-07-10 01:05:58 +02:00
parent db5080df48
commit e220ff7db9
17 changed files with 780 additions and 89 deletions
BIN
View File
Binary file not shown.
Binary file not shown.
+18 -3
View File
@@ -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
+2 -1
View File
@@ -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"]
+18
View File
@@ -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())
+5 -4
View File
@@ -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
+1
View File
@@ -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
+80 -4
View File
@@ -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
View File
@@ -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
+120
View File
@@ -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
+169
View File
@@ -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()
+140
View File
@@ -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
+175
View File
@@ -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