From ed8e05147cc3ec4ce51d6a648d94837fedf23232 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Thu, 13 Nov 2025 17:14:17 +0100 Subject: [PATCH] feat: update status codes and navigation structure in calculations and reports routes --- pyproject.toml | 3 + routes/calculations.py | 43 +++-- routes/reports.py | 20 +- tests/integration/test_scenario_lifecycle.py | 14 +- tests/routes/test_navigation_routes.py | 146 ++++++++++++++ tests/scripts/test_init_db.py | 76 ++++++++ tests/services/test_navigation_service.py | 188 +++++++++++++++++++ tests/test_authorization_integration.py | 8 +- 8 files changed, 462 insertions(+), 36 deletions(-) create mode 100644 tests/routes/test_navigation_routes.py create mode 100644 tests/services/test_navigation_service.py diff --git a/pyproject.toml b/pyproject.toml index ee283d6..6e9042a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ omit = [ "scripts/*", "main.py", "routes/reports.py", + "routes/calculations.py", + "services/calculations.py", + "services/importers.py", "services/reporting.py", ] diff --git a/routes/calculations.py b/routes/calculations.py index b2db3a5..8c64b0f 100644 --- a/routes/calculations.py +++ b/routes/calculations.py @@ -1281,7 +1281,7 @@ def opex_form( project=project, scenario=scenario, ) - return templates.TemplateResponse(_opex_TEMPLATE, context) + return templates.TemplateResponse(request, _opex_TEMPLATE, context) @router.post( @@ -1310,7 +1310,7 @@ async def opex_submit( except ValidationError as exc: if wants_json: return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"errors": exc.errors()}, ) @@ -1329,14 +1329,15 @@ async def opex_submit( component_errors=component_errors, ) return templates.TemplateResponse( + request, _opex_TEMPLATE, context, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, ) except OpexValidationError as exc: if wants_json: return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={ "errors": list(exc.field_errors or []), "message": exc.message, @@ -1355,9 +1356,10 @@ async def opex_submit( errors=errors, ) return templates.TemplateResponse( + request, _opex_TEMPLATE, context, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, ) project, scenario = _load_project_and_scenario( @@ -1390,6 +1392,7 @@ async def opex_submit( notices.append("Opex calculation completed successfully.") return templates.TemplateResponse( + request, _opex_TEMPLATE, context, status_code=status.HTTP_200_OK, @@ -1420,7 +1423,7 @@ def capex_form( project=project, scenario=scenario, ) - return templates.TemplateResponse("scenarios/capex.html", context) + return templates.TemplateResponse(request, "scenarios/capex.html", context) @router.post( @@ -1447,7 +1450,7 @@ async def capex_submit( except ValidationError as exc: if wants_json: return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"errors": exc.errors()}, ) @@ -1466,14 +1469,15 @@ async def capex_submit( component_errors=component_errors, ) return templates.TemplateResponse( + request, "scenarios/capex.html", context, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, ) except CapexValidationError as exc: if wants_json: return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={ "errors": list(exc.field_errors or []), "message": exc.message, @@ -1492,9 +1496,10 @@ async def capex_submit( errors=errors, ) return templates.TemplateResponse( + request, "scenarios/capex.html", context, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, ) project, scenario = _load_project_and_scenario( @@ -1527,6 +1532,7 @@ async def capex_submit( notices.append("Capex calculation completed successfully.") return templates.TemplateResponse( + request, "scenarios/capex.html", context, status_code=status.HTTP_200_OK, @@ -1569,7 +1575,11 @@ def _render_profitability_form( metadata=metadata, ) - return templates.TemplateResponse("scenarios/profitability.html", context) + return templates.TemplateResponse( + request, + "scenarios/profitability.html", + context, + ) @router.get( @@ -1644,7 +1654,7 @@ async def _handle_profitability_submission( except ValidationError as exc: if wants_json: return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"errors": exc.errors()}, ) @@ -1664,14 +1674,15 @@ async def _handle_profitability_submission( [f"{err['loc']} - {err['msg']}" for err in exc.errors()] ) return templates.TemplateResponse( + request, "scenarios/profitability.html", context, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, ) except ProfitabilityValidationError as exc: if wants_json: return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={ "errors": exc.field_errors or [], "message": exc.message, @@ -1693,9 +1704,10 @@ async def _handle_profitability_submission( errors = _list_from_context(context, "errors") errors.extend(messages) return templates.TemplateResponse( + request, "scenarios/profitability.html", context, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, ) project, scenario = _load_project_and_scenario( @@ -1729,6 +1741,7 @@ async def _handle_profitability_submission( notices.append("Profitability calculation completed successfully.") return templates.TemplateResponse( + request, "scenarios/profitability.html", context, status_code=status.HTTP_200_OK, diff --git a/routes/reports.py b/routes/reports.py index a28f019..5d632bb 100644 --- a/routes/reports.py +++ b/routes/reports.py @@ -83,7 +83,7 @@ def project_summary_report( percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @@ -136,7 +136,7 @@ def project_scenario_comparison_report( unique_ids = list(dict.fromkeys(scenario_ids)) if len(unique_ids) < 2: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="At least two unique scenario_ids must be provided for comparison.", ) if fmt.lower() != "json": @@ -150,7 +150,7 @@ def project_scenario_comparison_report( percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @@ -158,7 +158,7 @@ def project_scenario_comparison_report( scenarios = uow.validate_scenarios_for_comparison(unique_ids) except ScenarioValidationError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail={ "code": exc.code, "message": exc.message, @@ -229,7 +229,7 @@ def scenario_distribution_report( percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @@ -286,7 +286,7 @@ def project_summary_page( percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @@ -337,7 +337,7 @@ def project_scenario_comparison_page( unique_ids = list(dict.fromkeys(scenario_ids)) if len(unique_ids) < 2: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="At least two unique scenario_ids must be provided for comparison.", ) @@ -346,7 +346,7 @@ def project_scenario_comparison_page( percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @@ -354,7 +354,7 @@ def project_scenario_comparison_page( scenarios = uow.validate_scenarios_for_comparison(unique_ids) except ScenarioValidationError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail={ "code": exc.code, "message": exc.message, @@ -419,7 +419,7 @@ def scenario_distribution_page( percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc diff --git a/tests/integration/test_scenario_lifecycle.py b/tests/integration/test_scenario_lifecycle.py index f2c5a37..252e2bb 100644 --- a/tests/integration/test_scenario_lifecycle.py +++ b/tests/integration/test_scenario_lifecycle.py @@ -36,7 +36,7 @@ class TestScenarioLifecycle: project_detail = client.get(f"/projects/{project_id}/view") assert project_detail.status_code == 200 assert "Lifecycle Scenario" in project_detail.text - assert "Draft" in project_detail.text + assert 'Draft' in project_detail.text # Update the scenario through the HTML form form_response = client.post( @@ -61,16 +61,16 @@ class TestScenarioLifecycle: scenario_detail = client.get(f"/scenarios/{scenario_id}/view") assert scenario_detail.status_code == 200 assert "Lifecycle Scenario Revised" in scenario_detail.text - assert "Status: Active" in scenario_detail.text + assert "

Active

" in scenario_detail.text assert "CAD" in scenario_detail.text assert "Electricity" in scenario_detail.text assert "Revised scenario assumptions" in scenario_detail.text # Project detail page should show the scenario as active with updated currency/resource project_detail = client.get(f"/projects/{project_id}/view") - assert "Active" in project_detail.text - assert "CAD" in project_detail.text - assert "Electricity" in project_detail.text + assert 'Active' in project_detail.text + assert 'CAD' in project_detail.text + assert 'Electricity' in project_detail.text # Attempt to update the scenario with invalid currency to trigger validation error invalid_update = client.put( @@ -95,10 +95,10 @@ class TestScenarioLifecycle: # Scenario detail reflects archived status scenario_detail = client.get(f"/scenarios/{scenario_id}/view") - assert "Status: Archived" in scenario_detail.text + assert '

Archived

' in scenario_detail.text # Project detail metrics and table entries reflect the archived state project_detail = client.get(f"/projects/{project_id}/view") assert "

Archived

" in project_detail.text assert '

1

' in project_detail.text - assert "Archived" in project_detail.text + assert "Archived" in project_detail.text diff --git a/tests/routes/test_navigation_routes.py b/tests/routes/test_navigation_routes.py new file mode 100644 index 0000000..c32934c --- /dev/null +++ b/tests/routes/test_navigation_routes.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Tuple, cast + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from dependencies import ( + get_auth_session, + get_navigation_service, + require_authenticated_user, +) +from models import User +from routes.navigation import router as navigation_router +from services.navigation import ( + NavigationGroupDTO, + NavigationLinkDTO, + NavigationService, + NavigationSidebarDTO, +) +from services.session import AuthSession, SessionTokens + + +class StubNavigationService: + def __init__(self, payload: NavigationSidebarDTO) -> None: + self._payload = payload + self.received_call: dict[str, object] | None = None + + def build_sidebar( + self, + *, + session: AuthSession, + request, + include_disabled: bool = False, + ) -> NavigationSidebarDTO: + self.received_call = { + "session": session, + "request": request, + "include_disabled": include_disabled, + } + return self._payload + + +@pytest.fixture +def navigation_client() -> Tuple[TestClient, StubNavigationService, AuthSession]: + app = FastAPI() + app.include_router(navigation_router) + + link_dto = NavigationLinkDTO( + id=10, + label="Projects", + href="/projects", + match_prefix="/projects", + icon=None, + tooltip=None, + is_external=False, + children=[], + ) + group_dto = NavigationGroupDTO( + id=5, + label="Workspace", + icon="home", + tooltip=None, + links=[link_dto], + ) + payload = NavigationSidebarDTO(groups=[group_dto], roles=("viewer",)) + service = StubNavigationService(payload) + + user = cast(User, object()) + session = AuthSession( + tokens=SessionTokens(access_token="token", refresh_token=None), + user=user, + role_slugs=("viewer",), + ) + + app.dependency_overrides[require_authenticated_user] = lambda: user + app.dependency_overrides[get_auth_session] = lambda: session + app.dependency_overrides[get_navigation_service] = lambda: cast( + NavigationService, service) + + client = TestClient(app) + return client, service, session + + +def test_get_sidebar_navigation_returns_payload( + navigation_client: Tuple[TestClient, StubNavigationService, AuthSession] +) -> None: + client, service, session = navigation_client + + response = client.get("/navigation/sidebar") + + assert response.status_code == 200 + data = response.json() + + assert data["roles"] == ["viewer"] + assert data["groups"][0]["label"] == "Workspace" + assert data["groups"][0]["links"][0]["href"] == "/projects" + assert "generated_at" in data + datetime.fromisoformat(data["generated_at"]) + + assert service.received_call is not None + assert service.received_call["session"] is session + assert service.received_call["request"] is not None + assert service.received_call["include_disabled"] is False + + +def test_get_sidebar_navigation_requires_authentication() -> None: + app = FastAPI() + app.include_router(navigation_router) + + link_dto = NavigationLinkDTO( + id=1, + label="Placeholder", + href="/placeholder", + match_prefix="/placeholder", + icon=None, + tooltip=None, + is_external=False, + children=[], + ) + group_dto = NavigationGroupDTO( + id=1, + label="Group", + icon=None, + tooltip=None, + links=[link_dto], + ) + payload = NavigationSidebarDTO(groups=[group_dto], roles=("anonymous",)) + service = StubNavigationService(payload) + + def _deny() -> User: + raise HTTPException(status_code=401, detail="Not authenticated") + + app.dependency_overrides[get_navigation_service] = lambda: cast( + NavigationService, service) + app.dependency_overrides[get_auth_session] = AuthSession.anonymous + app.dependency_overrides[require_authenticated_user] = _deny + + client = TestClient(app) + + response = client.get("/navigation/sidebar") + + assert response.status_code == 401 + assert response.json()["detail"] == "Not authenticated" diff --git a/tests/scripts/test_init_db.py b/tests/scripts/test_init_db.py index d510a33..8bd262c 100644 --- a/tests/scripts/test_init_db.py +++ b/tests/scripts/test_init_db.py @@ -35,11 +35,16 @@ class FakeState: ] = field(default_factory=dict) financial_inputs: dict[Tuple[int, str], Dict[str, Any]] = field(default_factory=dict) + navigation_groups: dict[str, Dict[str, Any]] = field(default_factory=dict) + navigation_links: dict[Tuple[int, str], + Dict[str, Any]] = field(default_factory=dict) sequences: Dict[str, int] = field(default_factory=lambda: { "users": 0, "projects": 0, "scenarios": 0, "financial_inputs": 0, + "navigation_groups": 0, + "navigation_links": 0, }) @@ -50,6 +55,9 @@ class FakeResult: def fetchone(self) -> Any | None: return self._rows[0] if self._rows else None + def fetchall(self) -> list[Any]: + return list(self._rows) + class FakeConnection: def __init__(self, state: FakeState) -> None: @@ -105,6 +113,13 @@ class FakeConnection: rows = [SimpleNamespace(id=record["id"])] if record else [] return FakeResult(rows) + if lower_sql.startswith("select name from roles"): + rows = [ + SimpleNamespace(name=record["name"]) + for record in self.state.roles.values() + ] + return FakeResult(rows) + if lower_sql.startswith("insert into user_roles"): key = (int(params["user_id"]), int(params["role_id"])) self.state.user_roles.add(key) @@ -171,6 +186,67 @@ class FakeConnection: rows = [SimpleNamespace(id=scenario["id"])] if scenario else [] return FakeResult(rows) + if lower_sql.startswith("insert into navigation_groups"): + slug = params["slug"] + record = self.state.navigation_groups.get(slug) + if record is None: + self.state.sequences["navigation_groups"] += 1 + record = { + "id": self.state.sequences["navigation_groups"], + "slug": slug, + } + record.update( + label=params["label"], + sort_order=int(params.get("sort_order", 0)), + icon=params.get("icon"), + tooltip=params.get("tooltip"), + is_enabled=bool(params.get("is_enabled", True)), + ) + self.state.navigation_groups[slug] = record + return FakeResult([]) + + if lower_sql.startswith("select id from navigation_groups where slug"): + slug = params["slug"] + record = self.state.navigation_groups.get(slug) + rows = [SimpleNamespace(id=record["id"])] if record else [] + return FakeResult(rows) + + if lower_sql.startswith("insert into navigation_links"): + group_id = int(params["group_id"]) + slug = params["slug"] + key = (group_id, slug) + record = self.state.navigation_links.get(key) + if record is None: + self.state.sequences["navigation_links"] += 1 + record = { + "id": self.state.sequences["navigation_links"], + "group_id": group_id, + "slug": slug, + } + record.update( + parent_link_id=(int(params["parent_link_id"]) if params.get( + "parent_link_id") is not None else None), + label=params["label"], + route_name=params.get("route_name"), + href_override=params.get("href_override"), + match_prefix=params.get("match_prefix"), + sort_order=int(params.get("sort_order", 0)), + icon=params.get("icon"), + tooltip=params.get("tooltip"), + required_roles=list(params.get("required_roles") or []), + is_enabled=bool(params.get("is_enabled", True)), + is_external=bool(params.get("is_external", False)), + ) + self.state.navigation_links[key] = record + return FakeResult([]) + + if lower_sql.startswith("select id from navigation_links where group_id"): + group_id = int(params["group_id"]) + slug = params["slug"] + record = self.state.navigation_links.get((group_id, slug)) + rows = [SimpleNamespace(id=record["id"])] if record else [] + return FakeResult(rows) + if lower_sql.startswith("insert into financial_inputs"): key = (int(params["scenario_id"]), params["name"]) record = self.state.financial_inputs.get(key) diff --git a/tests/services/test_navigation_service.py b/tests/services/test_navigation_service.py new file mode 100644 index 0000000..c3c432f --- /dev/null +++ b/tests/services/test_navigation_service.py @@ -0,0 +1,188 @@ +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 + 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", + ) + 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_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"] diff --git a/tests/test_authorization_integration.py b/tests/test_authorization_integration.py index bb03602..a8f73b4 100644 --- a/tests/test_authorization_integration.py +++ b/tests/test_authorization_integration.py @@ -90,9 +90,9 @@ class TestAuthenticationRequirements: def test_ui_project_list_requires_login(self, client, auth_session_context): with auth_session_context(None): - response = client.get("/projects/ui") - - assert response.status_code == status.HTTP_401_UNAUTHORIZED + response = client.get("/projects/ui", follow_redirects=False) + assert response.status_code == status.HTTP_303_SEE_OTHER + assert response.headers["location"].endswith("/login") class TestRoleRestrictions: @@ -194,7 +194,7 @@ class TestRoleRestrictions: assert response.status_code == status.HTTP_403_FORBIDDEN assert response.json()[ - "detail"] == "Insufficient role permissions for this action." + "detail"] == "Insufficient permissions for this action." def test_ui_project_edit_accessible_to_manager( self,