2026-07-09 01:36:46 +02:00
|
|
|
"""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:
|
2026-09-24 21:26:18 +02:00
|
|
|
"""Raw equipment item using REAL Rentman API field names."""
|
2026-07-09 01:36:46 +02:00
|
|
|
return {
|
|
|
|
|
"id": rid,
|
|
|
|
|
"name": name,
|
2026-09-24 21:26:18 +02:00
|
|
|
"displayname": name,
|
2026-07-09 01:36:46 +02:00
|
|
|
"code": f"{name[:3].upper()}-001",
|
2026-09-24 21:26:18 +02:00
|
|
|
"folder": f"/folders/{int(rid) % 5 + 1}",
|
|
|
|
|
"shop_description_long": f"<p>Description for {name}</p>",
|
|
|
|
|
"external_remark": "",
|
|
|
|
|
"power": 750,
|
|
|
|
|
"weight": 50,
|
|
|
|
|
"empty_weight": 50,
|
|
|
|
|
"image": f"/files/{rid}",
|
|
|
|
|
"price": 150.00,
|
|
|
|
|
"in_shop": True,
|
|
|
|
|
"in_archive": False,
|
|
|
|
|
"temporary": False,
|
|
|
|
|
"updateHash": f"hash-{rid}",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_transformed(raw: dict, folder_map=None) -> dict:
|
|
|
|
|
"""Transformed equipment dict as produced by the real Rentman mapping (no network)."""
|
|
|
|
|
return {
|
|
|
|
|
"rentman_id": str(raw["id"]),
|
|
|
|
|
"name": raw["name"],
|
|
|
|
|
"number": raw.get("code", ""),
|
|
|
|
|
"category": "Tontechnik",
|
|
|
|
|
"subcategory": "Mikrofone",
|
|
|
|
|
"description": f"Description for {raw['name']}",
|
|
|
|
|
"specifications": None,
|
|
|
|
|
"images": [],
|
2026-07-09 01:36:46 +02:00
|
|
|
"rental_price": 150.00,
|
2026-09-24 21:26:18 +02:00
|
|
|
"brand": "",
|
2026-07-09 01:36:46 +02:00
|
|
|
"available": True,
|
2026-09-24 21:26:18 +02:00
|
|
|
"update_hash": raw.get("updateHash", ""),
|
2026-07-09 01:36:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transform_equipment():
|
2026-09-24 21:26:18 +02:00
|
|
|
raw = make_raw_equipment("42", "Shure SM58 Mikrofon dynamisch")
|
|
|
|
|
service = RentmanService.__new__(RentmanService) # no network on init path
|
|
|
|
|
folder_map = {
|
|
|
|
|
f"/folders/{42 % 5 + 1}": {"category": "Tontechnik", "subcategory": "Mikrofone"},
|
|
|
|
|
}
|
|
|
|
|
with patch.object(RentmanService, "get_file_url", new=AsyncMock(return_value="https://example.com/42.jpg")):
|
|
|
|
|
result = await service.transform_equipment(raw, folder_map=folder_map)
|
2026-07-09 01:36:46 +02:00
|
|
|
assert result["rentman_id"] == "42"
|
2026-09-24 21:26:18 +02:00
|
|
|
assert result["name"] == "Shure SM58 Mikrofon dynamisch"
|
|
|
|
|
assert result["category"] == "Tontechnik"
|
|
|
|
|
assert result["subcategory"] == "Mikrofone"
|
|
|
|
|
assert result["rental_price"] == 150.00
|
|
|
|
|
assert result["brand"] == "Shure"
|
2026-07-09 01:36:46 +02:00
|
|
|
assert result["available"] is True
|
2026-09-24 21:26:18 +02:00
|
|
|
assert result["description"] == "Description for Shure SM58 Mikrofon dynamisch"
|
|
|
|
|
assert result["images"] == ["https://example.com/42.jpg"]
|
2026-07-09 01:36:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)]
|
|
|
|
|
])
|
2026-09-24 21:26:18 +02:00
|
|
|
mock_rentman.get_folder_map = AsyncMock(return_value={})
|
|
|
|
|
mock_rentman.transform_equipment = AsyncMock(side_effect=make_transformed)
|
2026-07-09 01:36:46 +02:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
])
|
2026-09-24 21:26:18 +02:00
|
|
|
mock_rentman.get_folder_map = AsyncMock(return_value={})
|
|
|
|
|
mock_rentman.transform_equipment = AsyncMock(side_effect=make_transformed)
|
2026-07-09 01:36:46 +02:00
|
|
|
|
|
|
|
|
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
|