feat(navigation): Enhance navigation links and add legacy route redirects
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 14s
CI / deploy (push) Has been skipped

- 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.
This commit is contained in:
2025-11-13 20:23:53 +01:00
parent ed8e05147c
commit 4d0e1a9989
12 changed files with 1050 additions and 73 deletions

View File

@@ -18,6 +18,7 @@ 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
@@ -29,6 +30,11 @@ 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]:
@@ -60,6 +66,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
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)
@@ -85,26 +92,64 @@ def app(session_factory: sessionmaker) -> FastAPI:
] = _override_ingestion_service
with UnitOfWork(session_factory=session_factory) as uow:
assert uow.users is not None
uow.ensure_default_roles()
user = User(
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,
)
uow.users.create(user)
user = uow.users.get(user.id, with_roles=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:
session = AuthSession(
tokens=SessionTokens(
access_token=random_token(),
refresh_token=random_token(),
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.user = user
session.set_role_slugs(role_slugs)
request.state.auth_session = session
return session
@@ -114,7 +159,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
@pytest.fixture()
def client(app: FastAPI) -> Iterator[TestClient]:
test_client = TestClient(app)
test_client = TestClient(app, headers={TEST_USER_HEADER: "admin"})
try:
yield test_client
finally:
@@ -124,13 +169,52 @@ def client(app: FastAPI) -> Iterator[TestClient]:
@pytest_asyncio.fixture()
async def async_client(app: FastAPI) -> AsyncClient:
return AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
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