bfc0050580
Phase L: Dashboard widget system - Migration 046: dashboard_configs table (user/tenant/role fallback cascade) - DashboardConfig model + dashboard_service with get/upsert per-user and tenant-default - API router: GET/PUT /api/dashboard/config, GET/PUT /api/dashboard/tenant-default - Frontend: 5 widget components (ProductionStats, QueueStatus, RecentRenders, CostOverview, WorkerStatus) - DashboardGrid with API-backed config, DashboardCustomizeModal (checkbox selection, save/cancel) - Dashboard.tsx: widget grid + analytics section (privileged users) - Admin.tsx: restructured with new section order and Maintenance 2-col grid Phase M: Test framework - Backend: pytest-asyncio + pytest-cov + factory-boy in pyproject.toml - conftest.py: excel_dir fixtures + async DB fixtures + mock storage/celery stubs - Domain tests: billing_service, cache_service, notifications_service, imports_sanity - Integration: test_api_health.py smoke test (requires running backend) - Frontend: vitest + @testing-library/react + msw added to package.json - vite.config.ts: test block (jsdom + globals + setupFiles) - tsconfig.json: exclude src/__tests__ from main tsc (test runner handles its own types) - MSW handlers for /api/auth/me, Billing.test.tsx, formatters.test.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
249 lines
8.8 KiB
Python
249 lines
8.8 KiB
Python
"""
|
||
Pytest fixtures for the Schaeffler Automat backend test suite.
|
||
|
||
The tests in this suite are divided into:
|
||
- Unit tests (no DB / network required): excel_parser, models, schemas
|
||
- Integration tests (require running Postgres + Redis): API endpoints, tasks
|
||
|
||
Unit tests run offline; integration tests are gated by the 'integration'
|
||
pytest mark so they can be skipped in CI without infrastructure.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Make sure the backend package is importable when tests are run from the
|
||
# repo root or from the backend/ directory.
|
||
# ---------------------------------------------------------------------------
|
||
BACKEND_DIR = Path(__file__).resolve().parent.parent # …/backend
|
||
if str(BACKEND_DIR) not in sys.path:
|
||
sys.path.insert(0, str(BACKEND_DIR))
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Paths
|
||
# ---------------------------------------------------------------------------
|
||
EXCEL_DIR = Path(__file__).resolve().parent.parent.parent / "Excel-Order-Lists"
|
||
|
||
EXCEL_FILES: dict[str, Path] = {
|
||
"TRB": EXCEL_DIR / "TRB_Testscope_20260128.xlsx",
|
||
"Kugellager": EXCEL_DIR / "Kugellager_Testscope_20260128.xlsx",
|
||
"CRB": EXCEL_DIR / "CRB_Testscope_20260128.xlsx",
|
||
"Gleitlager": EXCEL_DIR / "Gleitlager_Testscope_20260128.xlsx",
|
||
"SRB_TORB": EXCEL_DIR / "SRB_TORB_Testscope_20260128.xlsx",
|
||
"Linear_schiene": EXCEL_DIR / "Linear_schiene_Testscope_20260128.xlsx",
|
||
"Anschlagplatten": EXCEL_DIR / "Anschlagplatten_Testscope_20260128.xlsx",
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures – Excel file paths
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture(scope="session")
|
||
def excel_dir() -> Path:
|
||
"""Return the directory that contains all sample Excel order lists."""
|
||
assert EXCEL_DIR.is_dir(), f"Excel sample directory not found: {EXCEL_DIR}"
|
||
return EXCEL_DIR
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def excel_paths() -> dict[str, Path]:
|
||
"""Return a mapping of category key → absolute path for each sample file."""
|
||
missing = [k for k, p in EXCEL_FILES.items() if not p.exists()]
|
||
if missing:
|
||
pytest.skip(f"Sample Excel files missing: {missing}")
|
||
return EXCEL_FILES
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures – parsed Excel results (cached per test session)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_excel_all(excel_paths: dict[str, Path]) -> dict:
|
||
"""Parse all 7 sample Excel files and return {category_key: ParsedExcel}."""
|
||
from app.services.excel_parser import parse_excel
|
||
|
||
return {cat: parse_excel(path) for cat, path in excel_paths.items()}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers exposed as fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_trb(parsed_excel_all):
|
||
return parsed_excel_all["TRB"]
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_kugellager(parsed_excel_all):
|
||
return parsed_excel_all["Kugellager"]
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_crb(parsed_excel_all):
|
||
return parsed_excel_all["CRB"]
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_gleitlager(parsed_excel_all):
|
||
return parsed_excel_all["Gleitlager"]
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_srb_torb(parsed_excel_all):
|
||
return parsed_excel_all["SRB_TORB"]
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_linear_schiene(parsed_excel_all):
|
||
return parsed_excel_all["Linear_schiene"]
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def parsed_anschlagplatten(parsed_excel_all):
|
||
return parsed_excel_all["Anschlagplatten"]
|
||
|
||
|
||
# ── Test-DB (nutzt separate Test-Datenbank) ──────────────────────────────────
|
||
|
||
import os
|
||
import uuid
|
||
import pytest_asyncio
|
||
from typing import AsyncGenerator
|
||
from httpx import AsyncClient, ASGITransport
|
||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||
|
||
TEST_DB_URL = os.environ.get(
|
||
"TEST_DATABASE_URL",
|
||
"postgresql+asyncpg://schaeffler:schaeffler@localhost:5432/schaeffler_test"
|
||
)
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def test_engine():
|
||
from app.database import Base
|
||
from sqlalchemy import text
|
||
import app.models # noqa - register all models
|
||
engine = create_async_engine(TEST_DB_URL, echo=False)
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
yield engine
|
||
# Use CASCADE to handle circular FK dependencies in drop
|
||
async with engine.begin() as conn:
|
||
await conn.execute(text("DROP SCHEMA public CASCADE"))
|
||
await conn.execute(text("CREATE SCHEMA public"))
|
||
await engine.dispose()
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def db(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||
session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
yield session
|
||
await session.rollback()
|
||
|
||
|
||
# ── Mock-Storage (LocalStorage in tmpdir) ───────────────────────────────────
|
||
|
||
@pytest.fixture
|
||
def mock_storage(tmp_path, monkeypatch):
|
||
"""Ersetzt MinIO-Storage durch lokalen tmpdir."""
|
||
import app.core.storage as storage_module
|
||
from unittest.mock import MagicMock
|
||
mock = MagicMock()
|
||
mock.exists.return_value = False
|
||
mock.upload.return_value = "test/key"
|
||
mock.download_bytes.return_value = b"fake-stl-data"
|
||
mock.get_presigned_url.return_value = "http://localhost/presigned"
|
||
monkeypatch.setattr(storage_module, "_storage_instance", mock)
|
||
return mock
|
||
|
||
|
||
# ── FastAPI Test-Client ──────────────────────────────────────────────────────
|
||
|
||
@pytest_asyncio.fixture
|
||
async def client(db) -> AsyncGenerator[AsyncClient, None]:
|
||
"""HTTP-Client mit überschriebener DB-Dependency."""
|
||
from app.main import app
|
||
from app.database import get_db
|
||
|
||
async def override_get_db():
|
||
yield db
|
||
|
||
app.dependency_overrides[get_db] = override_get_db
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||
yield ac
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ── Test-User + Auth-Token ───────────────────────────────────────────────────
|
||
|
||
@pytest_asyncio.fixture
|
||
async def admin_user(db):
|
||
from app.domains.auth.models import User, UserRole
|
||
from app.utils.auth import hash_password
|
||
user = User(
|
||
id=uuid.uuid4(),
|
||
email=f"test-admin-{uuid.uuid4().hex[:8]}@test.com",
|
||
password_hash=hash_password("TestPass123!"),
|
||
full_name="Test Admin",
|
||
role=UserRole.admin,
|
||
is_active=True,
|
||
)
|
||
db.add(user)
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
return user
|
||
|
||
|
||
@pytest.fixture
|
||
def admin_token(admin_user):
|
||
from app.utils.auth import create_access_token
|
||
return create_access_token(str(admin_user.id), admin_user.role.value)
|
||
|
||
|
||
@pytest.fixture
|
||
def auth_headers(admin_token):
|
||
return {"Authorization": f"Bearer {admin_token}"}
|
||
|
||
|
||
# ── Celery-Mock ──────────────────────────────────────────────────────────────
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def mock_celery_tasks(monkeypatch):
|
||
"""Verhindert echte Celery-Task-Dispatching in Tests."""
|
||
from unittest.mock import MagicMock
|
||
mock_result = MagicMock()
|
||
mock_result.id = "test-task-" + str(uuid.uuid4())[:8]
|
||
|
||
def mock_delay(*args, **kwargs):
|
||
return mock_result
|
||
|
||
task_paths = [
|
||
"app.domains.materials.tasks.refresh_asset_library_catalog",
|
||
"app.tasks.step_tasks.process_step_file",
|
||
"app.tasks.step_tasks.render_step_thumbnail",
|
||
"app.tasks.step_tasks.generate_stl_cache",
|
||
"app.domains.imports.tasks.validate_excel_import",
|
||
"app.domains.rendering.tasks.render_still_task",
|
||
"app.domains.rendering.tasks.render_turntable_task",
|
||
]
|
||
for path in task_paths:
|
||
try:
|
||
parts = path.rsplit(".", 1)
|
||
module_path, attr = parts
|
||
import importlib
|
||
module = importlib.import_module(module_path)
|
||
task = getattr(module, attr, None)
|
||
if task and hasattr(task, "delay"):
|
||
monkeypatch.setattr(task, "delay", mock_delay)
|
||
except Exception:
|
||
pass
|