feat(navigation): Enhance navigation links and add legacy route redirects
- 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:
141
tests/integration/test_calculations_legacy_routes.py
Normal file
141
tests/integration/test_calculations_legacy_routes.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str) -> int:
|
||||
response = client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"name": name,
|
||||
"location": "Western Australia",
|
||||
"operation_type": "open_pit",
|
||||
"description": "Legacy calculations redirect test project",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
|
||||
response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": name,
|
||||
"description": "Scenario for legacy calculations redirect tests",
|
||||
"status": "draft",
|
||||
"currency": "usd",
|
||||
"primary_resource": "diesel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_legacy_opex_redirects_to_scenario_route(
|
||||
client: TestClient,
|
||||
scenario_calculation_url: Callable[[str, int, int], str],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Opex Legacy Redirect Project")
|
||||
scenario_id = _create_scenario(
|
||||
client, project_id, "Opex Legacy Redirect Scenario")
|
||||
|
||||
response = client.get(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 308
|
||||
assert response.headers["location"] == scenario_calculation_url(
|
||||
"calculations.scenario_opex_form",
|
||||
project_id,
|
||||
scenario_id,
|
||||
)
|
||||
|
||||
post_response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert post_response.status_code == 308
|
||||
assert post_response.headers["location"] == scenario_calculation_url(
|
||||
"calculations.scenario_opex_submit",
|
||||
project_id,
|
||||
scenario_id,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_capex_redirects_to_scenario_route(
|
||||
client: TestClient,
|
||||
scenario_calculation_url: Callable[[str, int, int], str],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Capex Legacy Redirect Project")
|
||||
scenario_id = _create_scenario(
|
||||
client, project_id, "Capex Legacy Redirect Scenario")
|
||||
|
||||
response = client.get(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 308
|
||||
assert response.headers["location"] == scenario_calculation_url(
|
||||
"calculations.scenario_capex_form",
|
||||
project_id,
|
||||
scenario_id,
|
||||
)
|
||||
|
||||
post_response = client.post(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert post_response.status_code == 308
|
||||
assert post_response.headers["location"] == scenario_calculation_url(
|
||||
"calculations.scenario_capex_submit",
|
||||
project_id,
|
||||
scenario_id,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_opex_redirects_to_project_scenarios_when_only_project(
|
||||
client: TestClient,
|
||||
app_url_for: Callable[..., str],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Opex Legacy Project Only")
|
||||
|
||||
response = client.get(
|
||||
f"/calculations/opex?project_id={project_id}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == app_url_for(
|
||||
"scenarios.project_scenario_list", project_id=project_id
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_capex_rejects_invalid_identifiers(client: TestClient) -> None:
|
||||
response = client.get(
|
||||
"/calculations/capex?project_id=abc&scenario_id=-10",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "project_id" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_legacy_opex_returns_not_found_for_missing_entities(client: TestClient) -> None:
|
||||
project_id = _create_project(client, "Opex Legacy Missing Scenario")
|
||||
|
||||
response = client.get(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id=999999",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Scenario not found"
|
||||
146
tests/integration/test_navigation_sidebar.py
Normal file
146
tests/integration/test_navigation_sidebar.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Integration coverage for the /navigation/sidebar endpoint.
|
||||
|
||||
These tests validate role-based filtering, ordering, and disabled-link handling
|
||||
through the full FastAPI stack so future changes keep navigation behaviour under
|
||||
explicit test coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Mapping
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models.navigation import NavigationGroup, NavigationLink
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seed_navigation(unit_of_work_factory: Callable[[], UnitOfWork]) -> Callable[[], None]:
|
||||
def _seed() -> None:
|
||||
with unit_of_work_factory() as uow:
|
||||
repo = uow.navigation
|
||||
assert repo is not None
|
||||
|
||||
workspace = repo.add_group(
|
||||
NavigationGroup(
|
||||
slug="workspace",
|
||||
label="Workspace",
|
||||
sort_order=10,
|
||||
)
|
||||
)
|
||||
insights = repo.add_group(
|
||||
NavigationGroup(
|
||||
slug="insights",
|
||||
label="Insights",
|
||||
sort_order=20,
|
||||
)
|
||||
)
|
||||
|
||||
repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=workspace.id,
|
||||
slug="projects",
|
||||
label="Projects",
|
||||
href_override="/projects",
|
||||
sort_order=5,
|
||||
required_roles=[],
|
||||
)
|
||||
)
|
||||
repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=workspace.id,
|
||||
slug="admin-tools",
|
||||
label="Admin Tools",
|
||||
href_override="/admin/tools",
|
||||
sort_order=10,
|
||||
required_roles=["admin"],
|
||||
)
|
||||
)
|
||||
repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=workspace.id,
|
||||
slug="disabled-link",
|
||||
label="Hidden",
|
||||
href_override="/hidden",
|
||||
sort_order=15,
|
||||
required_roles=[],
|
||||
is_enabled=False,
|
||||
)
|
||||
)
|
||||
repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=insights.id,
|
||||
slug="reports",
|
||||
label="Reports",
|
||||
href_override="/reports",
|
||||
sort_order=1,
|
||||
required_roles=[],
|
||||
)
|
||||
)
|
||||
|
||||
return _seed
|
||||
|
||||
|
||||
def _link_labels(group_json: Mapping[str, Any]) -> list[str]:
|
||||
return [link["label"] for link in group_json["links"]]
|
||||
|
||||
|
||||
def test_admin_session_receives_all_enabled_links(
|
||||
client: TestClient,
|
||||
seed_navigation: Callable[[], None],
|
||||
) -> None:
|
||||
seed_navigation()
|
||||
|
||||
response = client.get("/navigation/sidebar")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
|
||||
assert [group["label"] for group in payload["groups"]] == [
|
||||
"Workspace",
|
||||
"Insights",
|
||||
]
|
||||
workspace, insights = payload["groups"]
|
||||
assert _link_labels(workspace) == ["Projects", "Admin Tools"]
|
||||
assert _link_labels(insights) == ["Reports"]
|
||||
assert payload["roles"] == ["admin"]
|
||||
|
||||
|
||||
def test_viewer_session_filters_admin_only_links(
|
||||
client: TestClient,
|
||||
seed_navigation: Callable[[], None],
|
||||
test_user_headers: Callable[[str | None], dict[str, str]],
|
||||
) -> None:
|
||||
seed_navigation()
|
||||
|
||||
response = client.get(
|
||||
"/navigation/sidebar",
|
||||
headers=test_user_headers("viewer"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
|
||||
assert [group["label"] for group in payload["groups"]] == [
|
||||
"Workspace",
|
||||
"Insights",
|
||||
]
|
||||
workspace, insights = payload["groups"]
|
||||
assert _link_labels(workspace) == ["Projects"]
|
||||
assert _link_labels(insights) == ["Reports"]
|
||||
assert payload["roles"] == ["viewer"]
|
||||
|
||||
|
||||
def test_anonymous_access_is_rejected(
|
||||
client: TestClient,
|
||||
seed_navigation: Callable[[], None],
|
||||
test_user_headers: Callable[[str | None], dict[str, str]],
|
||||
) -> None:
|
||||
seed_navigation()
|
||||
|
||||
response = client.get(
|
||||
"/navigation/sidebar",
|
||||
headers=test_user_headers("anonymous"),
|
||||
)
|
||||
assert response.status_code == 401
|
||||
170
tests/integration/test_navigation_sidebar_calculations.py
Normal file
170
tests/integration/test_navigation_sidebar_calculations.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models.navigation import NavigationGroup, NavigationLink
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seed_calculation_navigation(
|
||||
unit_of_work_factory: Callable[[], UnitOfWork]
|
||||
) -> Callable[[], None]:
|
||||
def _seed() -> None:
|
||||
with unit_of_work_factory() as uow:
|
||||
repo = uow.navigation
|
||||
assert repo is not None
|
||||
|
||||
workspace = repo.add_group(
|
||||
NavigationGroup(
|
||||
slug="workspace",
|
||||
label="Workspace",
|
||||
sort_order=10,
|
||||
)
|
||||
)
|
||||
|
||||
projects_link = repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=workspace.id,
|
||||
slug="projects",
|
||||
label="Projects",
|
||||
href_override="/projects",
|
||||
sort_order=5,
|
||||
required_roles=[],
|
||||
)
|
||||
)
|
||||
repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=workspace.id,
|
||||
parent_link_id=projects_link.id,
|
||||
slug="profitability",
|
||||
label="Profitability Calculator",
|
||||
route_name="calculations.profitability_form",
|
||||
href_override="/calculations/profitability",
|
||||
match_prefix="/calculations/profitability",
|
||||
sort_order=8,
|
||||
required_roles=["analyst", "admin"],
|
||||
)
|
||||
)
|
||||
repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=workspace.id,
|
||||
parent_link_id=projects_link.id,
|
||||
slug="opex",
|
||||
label="Opex Planner",
|
||||
route_name="calculations.opex_form",
|
||||
href_override="/calculations/opex",
|
||||
match_prefix="/calculations/opex",
|
||||
sort_order=10,
|
||||
required_roles=["analyst", "admin"],
|
||||
)
|
||||
)
|
||||
repo.add_link(
|
||||
NavigationLink(
|
||||
group_id=workspace.id,
|
||||
parent_link_id=projects_link.id,
|
||||
slug="capex",
|
||||
label="Capex Planner",
|
||||
route_name="calculations.capex_form",
|
||||
href_override="/calculations/capex",
|
||||
match_prefix="/calculations/capex",
|
||||
sort_order=15,
|
||||
required_roles=["analyst", "admin"],
|
||||
)
|
||||
)
|
||||
|
||||
return _seed
|
||||
|
||||
|
||||
def test_navigation_sidebar_includes_calculation_links_for_admin(
|
||||
client: TestClient,
|
||||
seed_calculation_navigation: Callable[[], None],
|
||||
) -> None:
|
||||
seed_calculation_navigation()
|
||||
|
||||
response = client.get("/navigation/sidebar")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
|
||||
groups = payload["groups"]
|
||||
assert groups
|
||||
workspace = next(
|
||||
group for group in groups if group["label"] == "Workspace")
|
||||
workspace_links = workspace["links"]
|
||||
assert [link["label"] for link in workspace_links] == ["Projects"]
|
||||
|
||||
projects_children = workspace_links[0]["children"]
|
||||
child_labels = [link["label"] for link in projects_children]
|
||||
assert child_labels == [
|
||||
"Profitability Calculator",
|
||||
"Opex Planner",
|
||||
"Capex Planner",
|
||||
]
|
||||
|
||||
profitability_link = next(
|
||||
link for link in projects_children if link["label"] == "Profitability Calculator")
|
||||
assert profitability_link["href"] == "/calculations/profitability"
|
||||
assert profitability_link["match_prefix"] == "/calculations/profitability"
|
||||
|
||||
opex_link = next(
|
||||
link for link in projects_children if link["label"] == "Opex Planner")
|
||||
assert opex_link["href"] == "/calculations/opex"
|
||||
assert opex_link["match_prefix"] == "/calculations/opex"
|
||||
|
||||
capex_link = next(
|
||||
link for link in projects_children if link["label"] == "Capex Planner")
|
||||
assert capex_link["href"] == "/calculations/capex"
|
||||
assert capex_link["match_prefix"] == "/calculations/capex"
|
||||
assert payload["roles"] == ["admin"]
|
||||
|
||||
|
||||
def test_navigation_sidebar_hides_calculation_links_for_viewer_without_role(
|
||||
client: TestClient,
|
||||
seed_calculation_navigation: Callable[[], None],
|
||||
test_user_headers: Callable[[str | None], dict[str, str]],
|
||||
) -> None:
|
||||
seed_calculation_navigation()
|
||||
|
||||
response = client.get(
|
||||
"/navigation/sidebar",
|
||||
headers=test_user_headers("viewer"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
|
||||
groups = payload["groups"]
|
||||
assert groups
|
||||
workspace = next(
|
||||
group for group in groups if group["label"] == "Workspace")
|
||||
workspace_links = workspace["links"]
|
||||
assert [link["label"] for link in workspace_links] == ["Projects"]
|
||||
assert workspace_links[0]["children"] == []
|
||||
assert payload["roles"] == ["viewer"]
|
||||
|
||||
|
||||
def test_navigation_sidebar_includes_contextual_urls_when_ids_provided(
|
||||
client: TestClient,
|
||||
seed_calculation_navigation: Callable[[], None],
|
||||
) -> None:
|
||||
seed_calculation_navigation()
|
||||
|
||||
response = client.get(
|
||||
"/navigation/sidebar",
|
||||
params={"project_id": "5", "scenario_id": "11"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
|
||||
workspace = next(
|
||||
group for group in payload["groups"] if group["label"] == "Workspace")
|
||||
projects = workspace["links"][0]
|
||||
capex_link = next(
|
||||
link for link in projects["children"] if link["label"] == "Capex Planner")
|
||||
|
||||
assert capex_link["href"].endswith(
|
||||
"/calculations/projects/5/scenarios/11/calculations/capex"
|
||||
)
|
||||
assert capex_link["match_prefix"] == "/calculations/capex"
|
||||
@@ -1,10 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestScenarioLifecycle:
|
||||
def test_scenario_lifecycle_flow(self, client: TestClient) -> None:
|
||||
def test_scenario_lifecycle_flow(
|
||||
self,
|
||||
client: TestClient,
|
||||
scenario_calculation_url: Callable[[str, int, int], str],
|
||||
) -> None:
|
||||
# Create a project to attach scenarios to
|
||||
project_response = client.post(
|
||||
"/projects",
|
||||
@@ -65,6 +71,18 @@ class TestScenarioLifecycle:
|
||||
assert "CAD" in scenario_detail.text
|
||||
assert "Electricity" in scenario_detail.text
|
||||
assert "Revised scenario assumptions" in scenario_detail.text
|
||||
scenario_opex_url = scenario_calculation_url(
|
||||
"calculations.scenario_opex_form",
|
||||
project_id,
|
||||
scenario_id,
|
||||
)
|
||||
scenario_capex_url = scenario_calculation_url(
|
||||
"calculations.scenario_capex_form",
|
||||
project_id,
|
||||
scenario_id,
|
||||
)
|
||||
assert f'href="{scenario_opex_url}"' in scenario_detail.text
|
||||
assert f'href="{scenario_capex_url}"' 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")
|
||||
@@ -84,6 +102,8 @@ class TestScenarioLifecycle:
|
||||
# Scenario detail should still show the previous (valid) currency
|
||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||
assert "CAD" in scenario_detail.text
|
||||
assert f'href="{scenario_opex_url}"' in scenario_detail.text
|
||||
assert f'href="{scenario_capex_url}"' in scenario_detail.text
|
||||
|
||||
# Archive the scenario through the API
|
||||
archive_response = client.put(
|
||||
@@ -96,9 +116,17 @@ class TestScenarioLifecycle:
|
||||
# Scenario detail reflects archived status
|
||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||
assert '<p class="metric-value status-pill status-pill--archived">Archived</p>' in scenario_detail.text
|
||||
assert f'href="{scenario_opex_url}"' in scenario_detail.text
|
||||
assert f'href="{scenario_capex_url}"' 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 "Archived" in project_detail.text
|
||||
|
||||
# Scenario portfolio view includes calculator links for each scenario entry
|
||||
scenario_portfolio = client.get(f"/projects/{project_id}/scenarios/ui")
|
||||
assert scenario_portfolio.status_code == 200
|
||||
assert f'href="{scenario_opex_url}"' in scenario_portfolio.text
|
||||
assert f'href="{scenario_capex_url}"' in scenario_portfolio.text
|
||||
|
||||
Reference in New Issue
Block a user