feat: T03+T04 backend - FastAPI, DB models, Rentman integration, admin auth, APScheduler
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,107 @@
|
||||
"""Pytest fixtures: test database, client, mock Redis, mock admin user."""
|
||||
import asyncio
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.cache import cache
|
||||
from app.models import EquipmentCache, RentalRequest, RentalRequestItem, Contact, AdminUser, SyncLog
|
||||
from app.auth import get_password_hash
|
||||
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_engine():
|
||||
"""Create an in-memory SQLite engine for tests."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_db(test_engine):
|
||||
"""Yield a test database session."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def client(test_engine):
|
||||
"""Yield an async test client with test database and mock cache."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def override_get_db():
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
# Mock cache to avoid Redis dependency
|
||||
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 = AsyncMock(return_value=1)
|
||||
mock_cache.connect = AsyncMock(return_value=None)
|
||||
mock_cache._redis = MagicMock()
|
||||
mock_cache._redis.ping = AsyncMock(return_value=True)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def seeded_admin(test_engine):
|
||||
"""Seed an admin user into the test database and return credentials."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_maker() as session:
|
||||
admin = AdminUser(
|
||||
username="admin",
|
||||
password_hash=get_password_hash("testpassword"),
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
return {"username": "admin", "password": "testpassword"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def seeded_equipment(test_engine):
|
||||
"""Seed sample equipment into the test database."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_maker() as session:
|
||||
items = [
|
||||
EquipmentCache(rentman_id="101", name="L-Acoustics K2", number="K2-001", category="Lautsprecher", brand="L-Acoustics", available=True),
|
||||
EquipmentCache(rentman_id="102", name="L-Acoustics KS28", number="KS28-001", category="Subwoofer", brand="L-Acoustics", available=True),
|
||||
EquipmentCache(rentman_id="103", name="d&b T10", number="T10-001", category="Lautsprecher", brand="d&b audiotechnik", available=True),
|
||||
]
|
||||
for item in items:
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
return items
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for admin authentication (T04)."""
|
||||
import pytest
|
||||
from app.auth import create_access_token, verify_token, get_password_hash, verify_password
|
||||
|
||||
|
||||
def test_password_hash_and_verify():
|
||||
plain = "mysecret"
|
||||
hashed = get_password_hash(plain)
|
||||
assert hashed != plain
|
||||
assert verify_password(plain, hashed) is True
|
||||
assert verify_password("wrong", hashed) is False
|
||||
|
||||
|
||||
def test_create_and_verify_token():
|
||||
token = create_access_token({"sub": "admin"})
|
||||
assert token is not None
|
||||
payload = verify_token(token)
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "admin"
|
||||
|
||||
|
||||
def test_verify_invalid_token():
|
||||
payload = verify_token("invalid.token.here")
|
||||
assert payload is None
|
||||
|
||||
|
||||
def test_verify_expired_token():
|
||||
from datetime import timedelta
|
||||
token = create_access_token({"sub": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||
payload = verify_token(token)
|
||||
assert payload is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client, seeded_admin):
|
||||
"""POST /api/admin/login with valid credentials returns 200 with token."""
|
||||
resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert "hms_admin_token" in resp.cookies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_failure_wrong_password(client, seeded_admin):
|
||||
"""POST /api/admin/login with wrong password returns 401."""
|
||||
resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "wrongpassword",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_failure_unknown_user(client, seeded_admin):
|
||||
"""POST /api/admin/login with unknown user returns 401."""
|
||||
resp = await client.post("/api/admin/login", json={
|
||||
"username": "unknown",
|
||||
"password": "testpassword",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_without_token(client):
|
||||
"""GET /api/admin/me without token returns 401."""
|
||||
resp = await client.get("/api/admin/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_with_valid_token(client, seeded_admin):
|
||||
"""GET /api/admin/me with valid token returns 200 with username."""
|
||||
# Login first
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
resp = await client.get("/api/admin/me", cookies={"hms_admin_token": token})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "admin"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for admin sync endpoints (T04)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_without_token(client):
|
||||
"""POST /api/admin/sync without JWT returns 401."""
|
||||
resp = await client.post("/api/admin/sync")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_without_token(client):
|
||||
"""GET /api/admin/sync-status without JWT returns 401."""
|
||||
resp = await client.get("/api/admin/sync-status")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_log_without_token(client):
|
||||
"""GET /api/admin/sync-log without JWT returns 401."""
|
||||
resp = await client.get("/api/admin/sync-log")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_with_valid_token(client, seeded_admin, test_db):
|
||||
"""POST /api/admin/sync with valid JWT triggers equipment sync."""
|
||||
# Login
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
# Mock sync service
|
||||
with patch.object(SyncService, "run_sync", new_callable=AsyncMock) as mock_sync:
|
||||
mock_sync.return_value = {"sync_id": 42, "items_processed": 10, "items_failed": 0, "status": "completed"}
|
||||
resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sync_id"] == 42
|
||||
assert data["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_with_valid_token(client, seeded_admin):
|
||||
"""GET /api/admin/sync-status with valid JWT returns status."""
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
with patch.object(SyncService, "get_last_sync", new_callable=AsyncMock) as mock_status:
|
||||
mock_status.return_value = {"last_sync": None, "items_processed": 0, "status": "never"}
|
||||
resp = await client.get("/api/admin/sync-status", cookies={"hms_admin_token": token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "never"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_log_with_valid_token(client, seeded_admin):
|
||||
"""GET /api/admin/sync-log with valid JWT returns paginated log."""
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
mock_log = MagicMock()
|
||||
mock_log.id = 1
|
||||
mock_log.sync_type = "equipment"
|
||||
mock_log.status = "completed"
|
||||
mock_log.items_processed = 100
|
||||
mock_log.items_failed = 0
|
||||
mock_log.error_message = None
|
||||
from datetime import datetime
|
||||
mock_log.started_at = datetime(2026, 7, 9, 12, 0, 0)
|
||||
mock_log.completed_at = datetime(2026, 7, 9, 12, 5, 0)
|
||||
|
||||
with patch.object(SyncService, "get_sync_log_paginated", new_callable=AsyncMock) as mock_log_fn:
|
||||
mock_log_fn.return_value = {
|
||||
"items": [mock_log],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 1,
|
||||
}
|
||||
resp = await client.get("/api/admin/sync-log", cookies={"hms_admin_token": token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["status"] == "completed"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for equipment router (T03)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_equipment(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?page=1&page_size=10")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
assert "total_pages" in data
|
||||
assert data["total"] == 3
|
||||
assert len(data["items"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_equipment(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?search=K2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert "K2" in data["items"][0]["name"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_category(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?category=Lautsprecher")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "Lautsprecher"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sort_name_asc(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?sort=name_asc")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sort_name_desc(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?sort=name_desc")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert names == sorted(names, reverse=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_categories(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment/categories")
|
||||
assert resp.status_code == 200
|
||||
cats = resp.json()
|
||||
assert "Lautsprecher" in cats
|
||||
assert "Subwoofer" in cats
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_equipment_detail(client, seeded_equipment):
|
||||
resp = await client.get(f"/api/equipment/{seeded_equipment[0].id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "L-Acoustics K2"
|
||||
assert data["brand"] == "L-Acoustics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_equipment_not_found(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment/999999")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for health endpoint (T03)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(client):
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "db" in data
|
||||
assert "redis" in data
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for Rentman equipment import pipeline (T04)."""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from sqlalchemy import select
|
||||
from app.models.equipment import EquipmentCache
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.services.sync_service import SyncService
|
||||
from app.services.rentman_service import RentmanService
|
||||
|
||||
|
||||
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
|
||||
return {
|
||||
"id": rid,
|
||||
"name": name,
|
||||
"number": f"{name[:3].upper()}-001",
|
||||
"code": f"{name[:3].upper()}-001",
|
||||
"equipment_group": {"name": category},
|
||||
"description": f"Description for {name}",
|
||||
"specifications": {"weight": 50, "power": 750},
|
||||
"images": [{"url": f"https://example.com/{rid}.jpg"}],
|
||||
"rental_price": 150.00,
|
||||
"brand": "L-Acoustics",
|
||||
"available": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transform_equipment():
|
||||
raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher")
|
||||
result = RentmanService.transform_equipment(raw)
|
||||
assert result["rentman_id"] == "42"
|
||||
assert result["name"] == "K2 Line Array"
|
||||
assert result["category"] == "Lautsprecher"
|
||||
assert result["images"] == ["https://example.com/42.jpg"]
|
||||
assert result["brand"] == "L-Acoustics"
|
||||
assert result["available"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paginated_import(test_db):
|
||||
"""Import service iterates all pages (3 pages with 250 items)."""
|
||||
page1 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100)], "itemCount": 250}
|
||||
page2 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100, 200)], "itemCount": 250}
|
||||
page3 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(200, 250)], "itemCount": 250}
|
||||
page4 = {"data": [], "itemCount": 250}
|
||||
|
||||
mock_rentman = MagicMock()
|
||||
mock_rentman.get_all_equipment = AsyncMock(return_value=[
|
||||
*[make_raw_equipment(str(i), f"Item {i}") for i in range(250)]
|
||||
])
|
||||
|
||||
with patch("app.services.sync_service.cache") as mock_cache:
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
sync_service = SyncService(test_db, rentman=mock_rentman)
|
||||
result = await sync_service.run_sync()
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["items_processed"] == 250
|
||||
assert result["items_failed"] == 0
|
||||
|
||||
# Verify equipment was upserted
|
||||
db_result = await test_db.execute(select(EquipmentCache))
|
||||
items = db_result.scalars().all()
|
||||
assert len(items) == 250
|
||||
|
||||
# Verify sync_log entry
|
||||
log_result = await test_db.execute(select(SyncLog))
|
||||
logs = log_result.scalars().all()
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "completed"
|
||||
assert logs[0].items_processed == 250
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_upsert_existing(test_db):
|
||||
"""Upsert should update existing equipment, not duplicate."""
|
||||
existing = EquipmentCache(rentman_id="100", name="Old Name", category="Old")
|
||||
test_db.add(existing)
|
||||
await test_db.commit()
|
||||
|
||||
mock_rentman = MagicMock()
|
||||
mock_rentman.get_all_equipment = AsyncMock(return_value=[
|
||||
make_raw_equipment("100", "New Name", "Lautsprecher")
|
||||
])
|
||||
|
||||
with patch("app.services.sync_service.cache") as mock_cache:
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
sync_service = SyncService(test_db, rentman=mock_rentman)
|
||||
result = await sync_service.run_sync()
|
||||
|
||||
assert result["items_processed"] == 1
|
||||
db_result = await test_db.execute(select(EquipmentCache))
|
||||
items = db_result.scalars().all()
|
||||
assert len(items) == 1
|
||||
assert items[0].name == "New Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_failure_logs_error(test_db):
|
||||
"""Sync failure should be logged with error message."""
|
||||
mock_rentman = MagicMock()
|
||||
mock_rentman.get_all_equipment = AsyncMock(side_effect=Exception("API unreachable"))
|
||||
|
||||
with patch("app.services.sync_service.cache") as mock_cache:
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
sync_service = SyncService(test_db, rentman=mock_rentman)
|
||||
result = await sync_service.run_sync()
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["items_processed"] == 0
|
||||
|
||||
log_result = await test_db.execute(select(SyncLog))
|
||||
log = log_result.scalar_one()
|
||||
assert log.status == "failed"
|
||||
assert "API unreachable" in (log.error_message or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_equipment_paginates():
|
||||
"""RentmanService.get_all_equipment iterates until data is empty."""
|
||||
page1 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100)]}
|
||||
page2 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100, 150)]}
|
||||
page3 = {"data": []}
|
||||
|
||||
svc = RentmanService(token="test-token")
|
||||
svc.get_equipment_page = AsyncMock(side_effect=[page1, page2, page3])
|
||||
result = await svc.get_all_equipment(limit=100)
|
||||
|
||||
assert len(result) == 150
|
||||
assert svc.get_equipment_page.call_count == 3
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for Rentman request submission pipeline (T04)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from app.services.rentman_service import RentmanService
|
||||
|
||||
|
||||
def test_build_project_request_payload():
|
||||
data = {
|
||||
"event_name": "Sommerfest 2026",
|
||||
"date_start": "2026-08-15",
|
||||
"date_end": "2026-08-16",
|
||||
"contact_name": "Max Mustermann",
|
||||
"contact_company": "Firma GmbH",
|
||||
"contact_email": "max@example.com",
|
||||
"contact_phone": "+49 170 1234567",
|
||||
"contact_street": "Hauptstr. 42a",
|
||||
"contact_postalcode": "80000",
|
||||
"contact_city": "Muenchen",
|
||||
"location": "Muenchen",
|
||||
"message": "Brauchen PA",
|
||||
}
|
||||
payload = RentmanService.build_project_request_payload(data)
|
||||
assert payload["name"] == "Sommerfest 2026"
|
||||
assert payload["planperiod_start"] == "2026-08-15T08:00:00+02:00"
|
||||
assert payload["planperiod_end"] == "2026-08-16T02:00:00+02:00"
|
||||
assert payload["usageperiod_start"] == "2026-08-15T18:00:00+02:00"
|
||||
assert payload["usageperiod_end"] == "2026-08-16T23:59:00+02:00"
|
||||
assert payload["contact_name"] == "Firma GmbH"
|
||||
assert payload["contact_person_first_name"] == "Max"
|
||||
assert payload["contact_person_lastname"] == "Mustermann"
|
||||
assert payload["contact_person_email"] == "max@example.com"
|
||||
assert payload["location_mailing_street"] == "Hauptstr. 42a"
|
||||
assert payload["location_mailing_number"] == "42a"
|
||||
assert payload["location_mailing_postalcode"] == "80000"
|
||||
assert payload["location_mailing_city"] == "Muenchen"
|
||||
assert payload["remark"] == "Brauchen PA"
|
||||
|
||||
|
||||
def test_build_project_request_payload_no_company():
|
||||
"""When no company, contact_name should use contact_name."""
|
||||
data = {
|
||||
"event_name": "Test",
|
||||
"date_start": "2026-08-15",
|
||||
"date_end": "2026-08-16",
|
||||
"contact_name": "Anna Schmidt",
|
||||
"contact_company": None,
|
||||
"contact_email": "anna@example.com",
|
||||
"contact_street": "Testweg 5",
|
||||
"contact_postalcode": "10000",
|
||||
"contact_city": "Berlin",
|
||||
"location": "Berlin",
|
||||
}
|
||||
payload = RentmanService.build_project_request_payload(data)
|
||||
assert payload["contact_name"] == "Anna Schmidt"
|
||||
assert payload["contact_person_first_name"] == "Anna"
|
||||
assert payload["contact_person_lastname"] == "Schmidt"
|
||||
assert payload["location_mailing_number"] == "5"
|
||||
|
||||
|
||||
def test_build_equipment_payload():
|
||||
item = {
|
||||
"equipment_name": "L-Acoustics K2",
|
||||
"rentman_equipment_id": "101",
|
||||
"quantity": 4,
|
||||
"unit_price": 150.00,
|
||||
}
|
||||
payload = RentmanService.build_equipment_payload(item)
|
||||
assert payload["name"] == "L-Acoustics K2"
|
||||
assert payload["quantity"] == 4
|
||||
assert payload["quantity_total"] == 4
|
||||
assert payload["unit_price"] == 150.00
|
||||
assert payload["linked_equipment"] == "/equipment/101"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projectrequest_success():
|
||||
"""Mock Rentman API: create project request returns ID, equipment added."""
|
||||
svc = RentmanService(token="test-token")
|
||||
svc.create_project_request = AsyncMock(return_value={"id": "9999", "name": "Test Event"})
|
||||
svc.add_equipment_to_request = AsyncMock(return_value={"id": "equip-1"})
|
||||
|
||||
project_payload = {"name": "Test Event", "usageperiod_start": "2026-08-15T18:00:00+02:00"}
|
||||
result = await svc.create_project_request(project_payload)
|
||||
assert result["id"] == "9999"
|
||||
|
||||
equip_payload = {"name": "K2", "quantity": 2}
|
||||
eq_result = await svc.add_equipment_to_request("9999", equip_payload)
|
||||
assert eq_result["id"] == "equip-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_equipment_retry():
|
||||
"""Failed equipment POST should be retried (exponential backoff simulation)."""
|
||||
svc = RentmanService(token="test-token")
|
||||
call_count = 0
|
||||
|
||||
async def mock_add(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise Exception("Temporary failure")
|
||||
return {"id": "success"}
|
||||
|
||||
svc.add_equipment_to_request = mock_add
|
||||
|
||||
# Simulate retry logic
|
||||
max_retries = 3
|
||||
result = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = await svc.add_equipment_to_request("1", {"name": "test"})
|
||||
break
|
||||
except Exception:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
import asyncio
|
||||
await asyncio.sleep(0.01 * (2 ** attempt))
|
||||
|
||||
assert result == {"id": "success"}
|
||||
assert call_count == 3
|
||||
Reference in New Issue
Block a user