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"]