- Updated navigation links in `init_db.py` to include href overrides and parent slugs for profitability, opex, and capex planners. - Modified `NavigationService` to handle child links and href overrides, ensuring proper routing when context is missing. - Adjusted scenario detail and list templates to use new route names for opex and capex forms, with legacy fallbacks. - Introduced integration tests for legacy calculation routes to ensure proper redirection and error handling. - Added tests for navigation sidebar to validate role-based access and link visibility. - Enhanced navigation sidebar tests to include calculation links and contextual URLs based on project and scenario IDs.
221 lines
7.1 KiB
Python
221 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Iterator
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.testclient import TestClient
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.engine import Engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from config.database import Base
|
|
from dependencies import get_auth_session, get_import_ingestion_service, get_unit_of_work
|
|
from models import User
|
|
from routes.auth import router as auth_router
|
|
from routes.dashboard import router as dashboard_router
|
|
from routes.calculations import router as calculations_router
|
|
from routes.navigation import router as navigation_router
|
|
from routes.projects import router as projects_router
|
|
from routes.scenarios import router as scenarios_router
|
|
from routes.imports import router as imports_router
|
|
from routes.exports import router as exports_router
|
|
from routes.reports import router as reports_router
|
|
from routes.ui import router as ui_router
|
|
from services.importers import ImportIngestionService
|
|
from services.unit_of_work import UnitOfWork
|
|
from services.session import AuthSession, SessionTokens
|
|
from tests.utils.security import random_password, random_token
|
|
|
|
BASE_TESTSERVER_URL = "http://testserver"
|
|
|
|
|
|
TEST_USER_HEADER = "X-Test-User"
|
|
|
|
|
|
@pytest.fixture()
|
|
def engine() -> Iterator[Engine]:
|
|
engine = create_engine(
|
|
"sqlite+pysqlite:///:memory:",
|
|
future=True,
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(bind=engine)
|
|
try:
|
|
yield engine
|
|
finally:
|
|
Base.metadata.drop_all(bind=engine)
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture()
|
|
def session_factory(engine: Engine) -> Iterator[sessionmaker]:
|
|
testing_session = sessionmaker(
|
|
bind=engine, expire_on_commit=False, future=True)
|
|
yield testing_session
|
|
|
|
|
|
@pytest.fixture()
|
|
def app(session_factory: sessionmaker) -> FastAPI:
|
|
application = FastAPI()
|
|
application.include_router(auth_router)
|
|
application.include_router(dashboard_router)
|
|
application.include_router(calculations_router)
|
|
application.include_router(projects_router)
|
|
application.include_router(navigation_router)
|
|
application.include_router(scenarios_router)
|
|
application.include_router(imports_router)
|
|
application.include_router(exports_router)
|
|
application.include_router(reports_router)
|
|
application.include_router(ui_router)
|
|
|
|
def _override_uow() -> Iterator[UnitOfWork]:
|
|
with UnitOfWork(session_factory=session_factory) as uow:
|
|
yield uow
|
|
|
|
application.dependency_overrides[get_unit_of_work] = _override_uow
|
|
|
|
def _ingestion_uow_factory() -> UnitOfWork:
|
|
return UnitOfWork(session_factory=session_factory)
|
|
|
|
ingestion_service = ImportIngestionService(_ingestion_uow_factory)
|
|
|
|
def _override_ingestion_service() -> ImportIngestionService:
|
|
return ingestion_service
|
|
|
|
application.dependency_overrides[
|
|
get_import_ingestion_service
|
|
] = _override_ingestion_service
|
|
|
|
with UnitOfWork(session_factory=session_factory) as uow:
|
|
assert uow.users is not None and uow.roles is not None
|
|
roles = {role.name: role for role in uow.ensure_default_roles()}
|
|
admin_user = User(
|
|
email="test-superuser@example.com",
|
|
username="test-superuser",
|
|
password_hash=User.hash_password(random_password()),
|
|
is_active=True,
|
|
is_superuser=True,
|
|
)
|
|
viewer_user = User(
|
|
email="test-viewer@example.com",
|
|
username="test-viewer",
|
|
password_hash=User.hash_password(random_password()),
|
|
is_active=True,
|
|
is_superuser=False,
|
|
)
|
|
uow.users.create(admin_user)
|
|
uow.users.create(viewer_user)
|
|
uow.users.assign_role(
|
|
user_id=admin_user.id,
|
|
role_id=roles["admin"].id,
|
|
granted_by=admin_user.id,
|
|
)
|
|
uow.users.assign_role(
|
|
user_id=viewer_user.id,
|
|
role_id=roles["viewer"].id,
|
|
granted_by=admin_user.id,
|
|
)
|
|
admin_user = uow.users.get(admin_user.id, with_roles=True)
|
|
viewer_user = uow.users.get(viewer_user.id, with_roles=True)
|
|
|
|
application.state.test_users = {
|
|
"admin": admin_user,
|
|
"viewer": viewer_user,
|
|
}
|
|
|
|
def _resolve_user(alias: str) -> tuple[User, tuple[str, ...]]:
|
|
normalised = alias.strip().lower()
|
|
user = application.state.test_users.get(normalised)
|
|
if user is None:
|
|
raise ValueError(f"Unknown test user alias: {alias}")
|
|
roles = tuple(role.name for role in user.roles)
|
|
return user, roles
|
|
|
|
def _override_auth_session(request: Request) -> AuthSession:
|
|
alias = request.headers.get(TEST_USER_HEADER, "admin").strip().lower()
|
|
if alias == "anonymous":
|
|
session = AuthSession.anonymous()
|
|
else:
|
|
user, role_slugs = _resolve_user(alias or "admin")
|
|
session = AuthSession(
|
|
tokens=SessionTokens(
|
|
access_token=random_token(),
|
|
refresh_token=random_token(),
|
|
),
|
|
user=user,
|
|
)
|
|
session.set_role_slugs(role_slugs)
|
|
request.state.auth_session = session
|
|
return session
|
|
|
|
application.dependency_overrides[get_auth_session] = _override_auth_session
|
|
return application
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(app: FastAPI) -> Iterator[TestClient]:
|
|
test_client = TestClient(app, headers={TEST_USER_HEADER: "admin"})
|
|
try:
|
|
yield test_client
|
|
finally:
|
|
test_client.close()
|
|
|
|
|
|
@pytest_asyncio.fixture()
|
|
async def async_client(app: FastAPI) -> AsyncClient:
|
|
return AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
headers={TEST_USER_HEADER: "admin"},
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def test_user_headers() -> Callable[[str | None], dict[str, str]]:
|
|
def _factory(alias: str | None = "admin") -> dict[str, str]:
|
|
if alias is None:
|
|
return {}
|
|
return {TEST_USER_HEADER: alias.lower()}
|
|
|
|
return _factory
|
|
|
|
|
|
@pytest.fixture()
|
|
def unit_of_work_factory(session_factory: sessionmaker) -> Callable[[], UnitOfWork]:
|
|
def _factory() -> UnitOfWork:
|
|
return UnitOfWork(session_factory=session_factory)
|
|
|
|
return _factory
|
|
|
|
|
|
@pytest.fixture()
|
|
def app_url_for(app: FastAPI) -> Callable[..., str]:
|
|
def _builder(route_name: str, **path_params: object) -> str:
|
|
normalised_params = {
|
|
key: str(value)
|
|
for key, value in path_params.items()
|
|
if value is not None
|
|
}
|
|
return f"{BASE_TESTSERVER_URL}{app.url_path_for(route_name, **normalised_params)}"
|
|
|
|
return _builder
|
|
|
|
|
|
@pytest.fixture()
|
|
def scenario_calculation_url(
|
|
app_url_for: Callable[..., str]
|
|
) -> Callable[[str, int, int], str]:
|
|
def _builder(route_name: str, project_id: int, scenario_id: int) -> str:
|
|
return app_url_for(
|
|
route_name,
|
|
project_id=project_id,
|
|
scenario_id=scenario_id,
|
|
)
|
|
|
|
return _builder
|