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
+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