feat: T03+T04 backend - FastAPI, DB models, Rentman integration, admin auth, APScheduler

This commit is contained in:
Implementation Engineer
2026-07-09 01:36:46 +02:00
parent 3bfa54b4b3
commit 88cff68f73
78 changed files with 2174 additions and 0 deletions
View File
Binary file not shown.
+107
View File
@@ -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
+87
View File
@@ -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"
+101
View File
@@ -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"
+77
View File
@@ -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
+12
View File
@@ -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
+131
View File
@@ -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
+120
View File
@@ -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