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