feat: update status codes and navigation structure in calculations and reports routes

This commit is contained in:
2025-11-13 17:14:17 +01:00
parent 522b1e4105
commit ed8e05147c
8 changed files with 462 additions and 36 deletions

View File

@@ -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 "<td>Draft</td>" in project_detail.text
assert '<span class="status-pill status-pill--draft">Draft</span>' 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 "<p class=\"metric-value status-pill status-pill--active\">Active</p>" 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 "<td>Active</td>" in project_detail.text
assert "<td>CAD</td>" in project_detail.text
assert "<td>Electricity</td>" in project_detail.text
assert '<span class="status-pill status-pill--active">Active</span>' 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 '<p class="metric-value status-pill status-pill--archived">Archived</p>' in scenario_detail.text
# Project detail metrics and table entries reflect the archived state
project_detail = client.get(f"/projects/{project_id}/view")
assert "<h2>Archived</h2>" in project_detail.text
assert '<p class="metric-value">1</p>' in project_detail.text
assert "<td>Archived</td>" in project_detail.text
assert "Archived" in project_detail.text

View File

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

View File

@@ -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)

View File

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

View File

@@ -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,