- Introduced a new template for listing scenarios associated with a project. - Added metrics for total, active, draft, and archived scenarios. - Implemented quick actions for creating new scenarios and reviewing project overview. - Enhanced navigation with breadcrumbs for better user experience. refactor: update Opex and Profitability templates for consistency - Changed titles and button labels for clarity in Opex and Profitability templates. - Updated form IDs and action URLs for better alignment with new naming conventions. - Improved navigation links to include scenario and project overviews. test: add integration tests for Opex calculations - Created new tests for Opex calculation HTML and JSON flows. - Validated successful calculations and ensured correct data persistence. - Implemented tests for currency mismatch and unsupported frequency scenarios. test: enhance project and scenario route tests - Added tests to verify scenario list rendering and calculator shortcuts. - Ensured scenario detail pages link back to the portfolio correctly. - Validated project detail pages show associated scenarios accurately.
338 lines
11 KiB
Python
338 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import html
|
|
|
|
from fastapi import status
|
|
from fastapi.testclient import TestClient
|
|
|
|
from models import MiningOperationType, ResourceType, ScenarioStatus
|
|
|
|
|
|
def _create_project(client: TestClient, name: str = "Alpha Project") -> dict:
|
|
payload = {
|
|
"name": name,
|
|
"location": "Chile",
|
|
"operation_type": MiningOperationType.OPEN_PIT.value,
|
|
"description": "Initial feasibility study",
|
|
}
|
|
response = client.post("/projects", json=payload)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
def _create_scenario(
|
|
client: TestClient,
|
|
project_id: int,
|
|
*,
|
|
name: str = "Scenario A",
|
|
status: ScenarioStatus = ScenarioStatus.DRAFT,
|
|
currency: str | None = "USD",
|
|
primary_resource: ResourceType | None = ResourceType.DIESEL,
|
|
) -> dict:
|
|
payload = {
|
|
"name": name,
|
|
"status": status.value,
|
|
}
|
|
if currency:
|
|
payload["currency"] = currency
|
|
if primary_resource:
|
|
payload["primary_resource"] = primary_resource.value
|
|
|
|
response = client.post(
|
|
f"/projects/{project_id}/scenarios",
|
|
json=payload,
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
def test_project_crud_cycle(client: TestClient) -> None:
|
|
project = _create_project(client)
|
|
project_id = project["id"]
|
|
|
|
fetch_response = client.get(f"/projects/{project_id}")
|
|
assert fetch_response.status_code == 200
|
|
assert fetch_response.json()["name"] == "Alpha Project"
|
|
|
|
list_response = client.get("/projects")
|
|
project_ids = {item["id"] for item in list_response.json()}
|
|
assert project_id in project_ids
|
|
|
|
update_payload = {
|
|
"description": "Updated project description", "location": "Peru"}
|
|
update_response = client.put(
|
|
f"/projects/{project_id}", json=update_payload)
|
|
assert update_response.status_code == 200
|
|
assert update_response.json(
|
|
)["description"] == "Updated project description"
|
|
assert update_response.json()["location"] == "Peru"
|
|
|
|
delete_response = client.delete(f"/projects/{project_id}")
|
|
assert delete_response.status_code == 204
|
|
|
|
missing_response = client.get(f"/projects/{project_id}")
|
|
assert missing_response.status_code == 404
|
|
|
|
|
|
def test_project_creation_conflict_returns_409(client: TestClient) -> None:
|
|
_create_project(client, name="Conflict Project")
|
|
conflict_payload = {
|
|
"name": "Conflict Project",
|
|
"location": "Canada",
|
|
"operation_type": MiningOperationType.OTHER.value,
|
|
"description": "Duplicate entry",
|
|
}
|
|
response = client.post("/projects", json=conflict_payload)
|
|
assert response.status_code == 409
|
|
assert "violates" in response.json()["detail"].lower()
|
|
|
|
|
|
def test_create_project_requires_valid_operation_type(client: TestClient) -> None:
|
|
invalid_payload = {
|
|
"name": "Invalid Operation",
|
|
"location": "Australia",
|
|
"operation_type": "INVALID",
|
|
"description": "Bad op type",
|
|
}
|
|
response = client.post("/projects", json=invalid_payload)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["detail"][0]["loc"][-1] == "operation_type"
|
|
|
|
|
|
def test_scenario_crud_cycle(client: TestClient) -> None:
|
|
project = _create_project(client)
|
|
project_id = project["id"]
|
|
|
|
creation_payload = {
|
|
"name": "Scenario A",
|
|
"description": "Base case assumptions",
|
|
"status": ScenarioStatus.DRAFT.value,
|
|
"start_date": "2025-01-01",
|
|
"end_date": "2026-01-01",
|
|
"discount_rate": 8.5,
|
|
"currency": "usd",
|
|
"primary_resource": ResourceType.DIESEL.value,
|
|
}
|
|
create_response = client.post(
|
|
f"/projects/{project_id}/scenarios",
|
|
json=creation_payload,
|
|
)
|
|
assert create_response.status_code == 201, create_response.text
|
|
scenario = create_response.json()
|
|
scenario_id = scenario["id"]
|
|
assert scenario["currency"] == "USD"
|
|
assert scenario["project_id"] == project_id
|
|
|
|
list_response = client.get(f"/projects/{project_id}/scenarios")
|
|
assert list_response.status_code == 200
|
|
listed_ids = {item["id"] for item in list_response.json()}
|
|
assert scenario_id in listed_ids
|
|
|
|
update_payload = {"description": "Revised assumptions",
|
|
"status": ScenarioStatus.ACTIVE.value}
|
|
update_response = client.put(
|
|
f"/scenarios/{scenario_id}", json=update_payload)
|
|
assert update_response.status_code == 200
|
|
updated = update_response.json()
|
|
assert updated["description"] == "Revised assumptions"
|
|
assert updated["status"] == ScenarioStatus.ACTIVE.value
|
|
|
|
delete_response = client.delete(f"/scenarios/{scenario_id}")
|
|
assert delete_response.status_code == 204
|
|
|
|
missing_response = client.get(f"/scenarios/{scenario_id}")
|
|
assert missing_response.status_code == 404
|
|
|
|
|
|
def test_create_scenario_requires_existing_project(client: TestClient) -> None:
|
|
payload = {
|
|
"name": "Orphan Scenario",
|
|
"status": ScenarioStatus.DRAFT.value,
|
|
}
|
|
response = client.post("/projects/999/scenarios", json=payload)
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_create_scenario_conflict_returns_409(client: TestClient) -> None:
|
|
project = _create_project(client, name="Scenario Container")
|
|
project_id = project["id"]
|
|
|
|
payload = {"name": "Duplicate Scenario"}
|
|
first_response = client.post(
|
|
f"/projects/{project_id}/scenarios", json=payload)
|
|
assert first_response.status_code == 201
|
|
|
|
conflict_response = client.post(
|
|
f"/projects/{project_id}/scenarios", json=payload)
|
|
assert conflict_response.status_code == 409
|
|
assert "constraints" in conflict_response.json()["detail"].lower()
|
|
|
|
|
|
def test_create_scenario_invalid_currency_returns_422(client: TestClient) -> None:
|
|
project = _create_project(client, name="Currency Project")
|
|
project_id = project["id"]
|
|
payload = {
|
|
"name": "Bad Currency",
|
|
"currency": "zz",
|
|
}
|
|
response = client.post(f"/projects/{project_id}/scenarios", json=payload)
|
|
assert response.status_code == 422
|
|
detail = response.json()["detail"][0]
|
|
assert detail["loc"][-1] == "currency"
|
|
assert "invalid currency code" in detail["msg"].lower()
|
|
|
|
|
|
def test_list_scenarios_missing_project_returns_404(client: TestClient) -> None:
|
|
response = client.get("/projects/424242/scenarios")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_project_detail_page_renders_scenario_list(client: TestClient) -> None:
|
|
project = _create_project(client, name="UI Detail Project")
|
|
project_id = project["id"]
|
|
|
|
_create_scenario(
|
|
client,
|
|
project_id,
|
|
name="Scenario UI",
|
|
status=ScenarioStatus.ACTIVE,
|
|
currency="USD",
|
|
)
|
|
|
|
response = client.get(f"/projects/{project_id}/view")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
assert "scenario-list" in body
|
|
assert "status-pill--active" in body
|
|
|
|
|
|
def test_scenario_list_page_shows_calculator_shortcuts(client: TestClient) -> None:
|
|
project = _create_project(client, name="Portfolio Project")
|
|
project_id = project["id"]
|
|
scenario = _create_scenario(
|
|
client,
|
|
project_id,
|
|
name="Portfolio Scenario",
|
|
status=ScenarioStatus.ACTIVE,
|
|
currency="USD",
|
|
)
|
|
|
|
response = client.get(f"/projects/{project_id}/scenarios/ui")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
unescaped = html.unescape(body)
|
|
|
|
assert "Scenario Portfolio" in body
|
|
assert project["name"] in body
|
|
assert scenario["name"] in body
|
|
assert f"projects/{project_id}/view" in unescaped
|
|
assert f"scenarios/{scenario['id']}/view" in unescaped
|
|
expected_calc_fragment = (
|
|
f"calculations/projects/{project_id}/scenarios/{scenario['id']}/profitability"
|
|
)
|
|
assert expected_calc_fragment in unescaped
|
|
|
|
|
|
def test_scenario_detail_page_links_back_to_portfolio(client: TestClient) -> None:
|
|
project = _create_project(client, name="Detail Project")
|
|
scenario = _create_scenario(
|
|
client,
|
|
project["id"],
|
|
name="Detail Scenario",
|
|
status=ScenarioStatus.ACTIVE,
|
|
currency="USD",
|
|
primary_resource=ResourceType.ELECTRICITY,
|
|
)
|
|
|
|
response = client.get(f"/scenarios/{scenario['id']}/view")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
unescaped = html.unescape(body)
|
|
|
|
assert project["name"] in body
|
|
assert scenario["name"] in body
|
|
assert "Scenario Overview" in body
|
|
assert f"projects/{project['id']}/scenarios/ui" in unescaped
|
|
assert (
|
|
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
|
in unescaped
|
|
)
|
|
|
|
|
|
def test_scenario_form_includes_project_context_guidance(client: TestClient) -> None:
|
|
project = _create_project(client, name="Form Project")
|
|
|
|
response = client.get(f"/projects/{project['id']}/scenarios/new")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
|
|
assert "Project Context" in body
|
|
assert project["name"] in body
|
|
assert "Status Guidance" in body
|
|
assert "Baseline Reminder" in body
|
|
assert "Defaults to" in body
|
|
|
|
|
|
def test_calculator_headers_surface_scenario_navigation(client: TestClient) -> None:
|
|
project = _create_project(client, name="Calc Project")
|
|
scenario = _create_scenario(
|
|
client,
|
|
project["id"],
|
|
name="Calc Scenario",
|
|
status=ScenarioStatus.DRAFT,
|
|
currency="USD",
|
|
)
|
|
|
|
profitability = client.get(
|
|
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
|
)
|
|
assert profitability.status_code == 200
|
|
profitability_body = html.unescape(profitability.text)
|
|
assert f"scenarios/{scenario['id']}/view" in profitability_body
|
|
assert f"projects/{project['id']}/scenarios/ui" in profitability_body
|
|
assert (
|
|
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
|
in profitability_body
|
|
)
|
|
|
|
capex = client.get(
|
|
f"/calculations/capex?project_id={project['id']}&scenario_id={scenario['id']}"
|
|
)
|
|
assert capex.status_code == 200
|
|
capex_body = html.unescape(capex.text)
|
|
assert f"scenarios/{scenario['id']}/view" in capex_body
|
|
assert f"projects/{project['id']}/scenarios/ui" in capex_body
|
|
|
|
opex = client.get(
|
|
f"/calculations/opex?project_id={project['id']}&scenario_id={scenario['id']}"
|
|
)
|
|
assert opex.status_code == 200
|
|
opex_body = html.unescape(opex.text)
|
|
assert f"scenarios/{scenario['id']}/view" in opex_body
|
|
assert f"projects/{project['id']}/scenarios/ui" in opex_body
|
|
|
|
|
|
def test_profitability_legacy_endpoint_redirects_to_scenario_path(client: TestClient) -> None:
|
|
project = _create_project(client, name="Redirect Project")
|
|
scenario = _create_scenario(
|
|
client,
|
|
project["id"],
|
|
name="Redirect Scenario",
|
|
status=ScenarioStatus.ACTIVE,
|
|
currency="USD",
|
|
)
|
|
|
|
response = client.get(
|
|
f"/calculations/profitability?project_id={project['id']}&scenario_id={scenario['id']}",
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
|
|
location = response.headers.get("location")
|
|
assert location is not None
|
|
expected_path = (
|
|
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
|
)
|
|
assert expected_path in location
|