- 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.
251 lines
8.0 KiB
Python
251 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, Iterable, List, cast
|
|
|
|
from fastapi import Request
|
|
|
|
from services.navigation import NavigationService
|
|
from services.repositories import NavigationRepository
|
|
from services.session import AuthSession, SessionTokens
|
|
from models import User
|
|
|
|
|
|
@dataclass
|
|
class StubNavigationLink:
|
|
id: int
|
|
slug: str
|
|
label: str
|
|
parent_link_id: int | None = None
|
|
route_name: str | None = None
|
|
href_override: str | None = None
|
|
match_prefix: str | None = None
|
|
sort_order: int = 0
|
|
icon: str | None = None
|
|
tooltip: str | None = None
|
|
required_roles: List[str] = field(default_factory=list)
|
|
is_enabled: bool = True
|
|
is_external: bool = False
|
|
children: List["StubNavigationLink"] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class StubNavigationGroup:
|
|
id: int
|
|
slug: str
|
|
label: str
|
|
sort_order: int = 0
|
|
icon: str | None = None
|
|
tooltip: str | None = None
|
|
is_enabled: bool = True
|
|
links: List[StubNavigationLink] = field(default_factory=list)
|
|
|
|
|
|
class StubNavigationRepository(NavigationRepository):
|
|
def __init__(self, groups: Iterable[StubNavigationGroup]) -> None:
|
|
super().__init__(session=None) # type: ignore[arg-type]
|
|
self._groups = list(groups)
|
|
|
|
def list_groups_with_links(self, *, include_disabled: bool = False):
|
|
if include_disabled:
|
|
return list(self._groups)
|
|
return [group for group in self._groups if group.is_enabled]
|
|
|
|
|
|
class StubRequest:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
path_params: Dict[str, str] | None = None,
|
|
query_params: Dict[str, str] | None = None,
|
|
) -> None:
|
|
self.path_params = path_params or {}
|
|
self.query_params = query_params or {}
|
|
self._url_for_calls: List[tuple[str, Dict[str, str]]] = []
|
|
|
|
def url_for(self, name: str, **params: str) -> str:
|
|
self._url_for_calls.append((name, params))
|
|
if params:
|
|
suffix = "_".join(f"{key}-{value}" for key,
|
|
value in sorted(params.items()))
|
|
return f"/{name}/{suffix}"
|
|
return f"/{name}"
|
|
|
|
@property
|
|
def url_for_calls(self) -> List[tuple[str, Dict[str, str]]]:
|
|
return list(self._url_for_calls)
|
|
|
|
|
|
def _session(*, roles: Iterable[str], authenticated: bool = True) -> AuthSession:
|
|
tokens = SessionTokens(
|
|
access_token="token" if authenticated else None, refresh_token=None)
|
|
user = cast(User, object()) if authenticated else None
|
|
session = AuthSession(tokens=tokens, user=user, role_slugs=tuple(roles))
|
|
return session
|
|
|
|
|
|
def test_build_sidebar_filters_links_by_role():
|
|
visible_link = StubNavigationLink(
|
|
id=1,
|
|
slug="projects",
|
|
label="Projects",
|
|
href_override="/projects",
|
|
required_roles=["viewer"],
|
|
)
|
|
hidden_link = StubNavigationLink(
|
|
id=2,
|
|
slug="admin",
|
|
label="Admin",
|
|
href_override="/admin",
|
|
required_roles=["admin"],
|
|
)
|
|
group = StubNavigationGroup(id=1, slug="workspace", label="Workspace", links=[
|
|
visible_link, hidden_link])
|
|
|
|
service = NavigationService(StubNavigationRepository([group]))
|
|
dto = service.build_sidebar(
|
|
session=_session(roles=["viewer"]),
|
|
request=cast(Request, StubRequest()),
|
|
)
|
|
|
|
assert len(dto.groups) == 1
|
|
assert [link.label for link in dto.groups[0].links] == ["Projects"]
|
|
assert dto.roles == ("viewer",)
|
|
|
|
|
|
def test_build_sidebar_appends_anonymous_role_for_guests():
|
|
link = StubNavigationLink(
|
|
id=1, slug="help", label="Help", href_override="/help")
|
|
group = StubNavigationGroup(
|
|
id=1, slug="account", label="Account", links=[link])
|
|
|
|
service = NavigationService(StubNavigationRepository([group]))
|
|
dto = service.build_sidebar(session=AuthSession.anonymous(), request=None)
|
|
|
|
assert dto.roles[-1] == "anonymous"
|
|
assert dto.groups[0].links[0].href.startswith("/")
|
|
|
|
|
|
def test_build_sidebar_resolves_profitability_link_with_context():
|
|
link = StubNavigationLink(
|
|
id=1,
|
|
slug="profitability",
|
|
label="Profitability",
|
|
route_name="calculations.profitability_form",
|
|
href_override="/calculations/profitability",
|
|
)
|
|
group = StubNavigationGroup(
|
|
id=99, slug="insights", label="Insights", links=[link])
|
|
|
|
request = StubRequest(path_params={"project_id": "7", "scenario_id": "42"})
|
|
service = NavigationService(StubNavigationRepository([group]))
|
|
|
|
dto = service.build_sidebar(
|
|
session=_session(roles=["viewer"]),
|
|
request=cast(Request, request),
|
|
)
|
|
|
|
assert dto.groups[0].links[0].href == "/calculations.profitability_form/project_id-7_scenario_id-42"
|
|
assert request.url_for_calls[0][0] == "calculations.profitability_form"
|
|
assert request.url_for_calls[0][1] == {
|
|
"project_id": "7", "scenario_id": "42"}
|
|
assert dto.groups[0].links[0].match_prefix == dto.groups[0].links[0].href
|
|
|
|
|
|
def test_build_sidebar_resolves_opex_link_with_context():
|
|
link = StubNavigationLink(
|
|
id=2,
|
|
slug="opex",
|
|
label="Opex",
|
|
route_name="calculations.opex_form",
|
|
href_override="/calculations/opex",
|
|
)
|
|
group = StubNavigationGroup(
|
|
id=5, slug="workspace", label="Workspace", links=[link])
|
|
|
|
request = StubRequest(path_params={"project_id": "3", "scenario_id": "9"})
|
|
service = NavigationService(StubNavigationRepository([group]))
|
|
|
|
dto = service.build_sidebar(
|
|
session=_session(roles=["analyst"]),
|
|
request=cast(Request, request),
|
|
)
|
|
|
|
href = dto.groups[0].links[0].href
|
|
assert href == "/calculations.opex_form/project_id-3_scenario_id-9"
|
|
assert request.url_for_calls[0][0] == "calculations.opex_form"
|
|
assert request.url_for_calls[0][1] == {
|
|
"project_id": "3", "scenario_id": "9"}
|
|
|
|
|
|
def test_build_sidebar_uses_href_override_when_calculator_context_missing():
|
|
class ParamAwareStubRequest(StubRequest):
|
|
# type: ignore[override]
|
|
def url_for(self, name: str, **params: str) -> str:
|
|
if name in {
|
|
"calculations.opex_form",
|
|
"calculations.capex_form",
|
|
} and not params:
|
|
self._url_for_calls.append((name, params))
|
|
raise RuntimeError("missing params")
|
|
return super().url_for(name, **params)
|
|
|
|
link = StubNavigationLink(
|
|
id=3,
|
|
slug="capex",
|
|
label="Capex",
|
|
route_name="calculations.capex_form",
|
|
href_override="/calculations/capex",
|
|
)
|
|
group = StubNavigationGroup(
|
|
id=6, slug="workspace", label="Workspace", links=[link])
|
|
|
|
request = ParamAwareStubRequest()
|
|
service = NavigationService(StubNavigationRepository([group]))
|
|
|
|
dto = service.build_sidebar(
|
|
session=_session(roles=["analyst"]),
|
|
request=cast(Request, request),
|
|
)
|
|
|
|
assert dto.groups[0].links[0].href == "/calculations/capex"
|
|
assert request.url_for_calls[-1][0] == "calculations.capex_form"
|
|
|
|
|
|
def test_build_sidebar_skips_disabled_links_unless_included():
|
|
enabled_link = StubNavigationLink(
|
|
id=1,
|
|
slug="projects",
|
|
label="Projects",
|
|
href_override="/projects",
|
|
)
|
|
disabled_link = StubNavigationLink(
|
|
id=2,
|
|
slug="reports",
|
|
label="Reports",
|
|
href_override="/reports",
|
|
is_enabled=False,
|
|
)
|
|
group = StubNavigationGroup(
|
|
id=5,
|
|
slug="workspace",
|
|
label="Workspace",
|
|
links=[enabled_link, disabled_link],
|
|
)
|
|
|
|
service = NavigationService(StubNavigationRepository([group]))
|
|
|
|
default_sidebar = service.build_sidebar(
|
|
session=_session(roles=["viewer"]),
|
|
request=cast(Request, StubRequest()),
|
|
)
|
|
assert [link.label for link in default_sidebar.groups[0].links] == ["Projects"]
|
|
|
|
full_sidebar = service.build_sidebar(
|
|
session=_session(roles=["viewer"]),
|
|
request=cast(Request, StubRequest()),
|
|
include_disabled=True,
|
|
)
|
|
assert [link.label for link in full_sidebar.groups[0].links] == [
|
|
"Projects", "Reports"]
|