diff --git a/changelog.md b/changelog.md index 6feea6d..49e0146 100644 --- a/changelog.md +++ b/changelog.md @@ -14,3 +14,5 @@ - Extended repositories with count/recency utilities and added pytest coverage, including a dashboard rendering smoke test validating empty-state messaging. - Brought project and scenario detail pages plus their forms in line with the dashboard visuals, adding metric cards, layout grids, and refreshed CTA styles. - Reordered project route registration to prioritize static UI paths, eliminating 422 errors on `/projects/ui` and `/projects/create`, and added pytest smoke coverage for the navigation endpoints. +- Added end-to-end integration tests for project and scenario lifecycles, validating HTML redirects, template rendering, and API interactions, and updated `ProjectRepository.get` to deduplicate joined loads for detail views. +- Updated all Jinja2 template responses to the new Starlette signature to eliminate deprecation warnings while keeping request-aware context available to the templates. diff --git a/routes/dashboard.py b/routes/dashboard.py index 94e3eea..a8f32cf 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -105,10 +105,9 @@ def dashboard_home( uow: UnitOfWork = Depends(get_unit_of_work), ) -> HTMLResponse: context = { - "request": request, "metrics": _load_metrics(uow), "recent_projects": _load_recent_projects(uow), "simulation_updates": _load_simulation_updates(uow), "scenario_alerts": _load_scenario_alerts(request, uow), } - return templates.TemplateResponse("dashboard.html", context) + return templates.TemplateResponse(request, "dashboard.html", context) diff --git a/routes/projects.py b/routes/projects.py index 2511620..a449e02 100644 --- a/routes/projects.py +++ b/routes/projects.py @@ -59,9 +59,9 @@ def project_list_page( for project in projects: setattr(project, "scenario_count", len(project.scenarios)) return templates.TemplateResponse( + request, "projects/list.html", { - "request": request, "projects": projects, }, ) @@ -75,9 +75,9 @@ def project_list_page( ) def create_project_form(request: Request) -> HTMLResponse: return templates.TemplateResponse( + request, "projects/form.html", { - "request": request, "project": None, "operation_types": _operation_type_choices(), "form_action": request.url_for("projects.create_project_submit"), @@ -109,9 +109,9 @@ def create_project_submit( op_type = MiningOperationType(operation_type) except ValueError as exc: return templates.TemplateResponse( + request, "projects/form.html", { - "request": request, "project": None, "operation_types": _operation_type_choices(), "form_action": request.url_for("projects.create_project_submit"), @@ -131,9 +131,9 @@ def create_project_submit( uow.projects.create(project) except EntityConflictError as exc: return templates.TemplateResponse( + request, "projects/form.html", { - "request": request, "project": project, "operation_types": _operation_type_choices(), "form_action": request.url_for("projects.create_project_submit"), @@ -219,9 +219,9 @@ def view_project( ), } return templates.TemplateResponse( + request, "projects/detail.html", { - "request": request, "project": project, "scenarios": scenarios, "scenario_stats": scenario_stats, @@ -246,9 +246,9 @@ def edit_project_form( ) from exc return templates.TemplateResponse( + request, "projects/form.html", { - "request": request, "project": project, "operation_types": _operation_type_choices(), "form_action": request.url_for( @@ -295,9 +295,9 @@ def edit_project_submit( project.operation_type = MiningOperationType(operation_type) except ValueError as exc: return templates.TemplateResponse( + request, "projects/form.html", { - "request": request, "project": project, "operation_types": _operation_type_choices(), "form_action": request.url_for( diff --git a/routes/scenarios.py b/routes/scenarios.py index 565cb64..aa4803d 100644 --- a/routes/scenarios.py +++ b/routes/scenarios.py @@ -218,9 +218,9 @@ def create_scenario_form( ) from exc return templates.TemplateResponse( + request, "scenarios/form.html", { - "request": request, "project": project, "scenario": None, "scenario_statuses": _scenario_status_choices(), @@ -291,9 +291,9 @@ def create_scenario_submit( uow.scenarios.create(scenario) except EntityConflictError as exc: return templates.TemplateResponse( + request, "scenarios/form.html", { - "request": request, "project": project, "scenario": scenario, "scenario_statuses": _scenario_status_choices(), @@ -347,9 +347,9 @@ def view_scenario( } return templates.TemplateResponse( + request, "scenarios/detail.html", { - "request": request, "project": project, "scenario": scenario, "scenario_metrics": scenario_metrics, @@ -378,9 +378,9 @@ def edit_scenario_form( project = uow.projects.get(scenario.project_id) return templates.TemplateResponse( + request, "scenarios/form.html", { - "request": request, "project": project, "scenario": scenario, "scenario_statuses": _scenario_status_choices(), diff --git a/services/repositories.py b/services/repositories.py index 9defd8b..5556638 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -40,7 +40,10 @@ class ProjectRepository: stmt = select(Project).where(Project.id == project_id) if with_children: stmt = stmt.options(joinedload(Project.scenarios)) - project = self.session.execute(stmt).scalar_one_or_none() + result = self.session.execute(stmt) + if with_children: + result = result.unique() + project = result.scalar_one_or_none() if project is None: raise EntityNotFoundError(f"Project {project_id} not found") return project diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5ad55cc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from config.database import Base +from dependencies import get_unit_of_work +from routes.dashboard import router as dashboard_router +from routes.projects import router as projects_router +from routes.scenarios import router as scenarios_router +from services.unit_of_work import UnitOfWork + + +@pytest.fixture() +def engine() -> Iterator[Engine]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + future=True, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + try: + yield engine + finally: + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +@pytest.fixture() +def session_factory(engine: Engine) -> Iterator[sessionmaker]: + testing_session = sessionmaker(bind=engine, expire_on_commit=False, future=True) + yield testing_session + + +@pytest.fixture() +def app(session_factory: sessionmaker) -> FastAPI: + application = FastAPI() + application.include_router(dashboard_router) + application.include_router(projects_router) + application.include_router(scenarios_router) + + def _override_uow() -> Iterator[UnitOfWork]: + with UnitOfWork(session_factory=session_factory) as uow: + yield uow + + application.dependency_overrides[get_unit_of_work] = _override_uow + return application + + +@pytest.fixture() +def client(app: FastAPI) -> Iterator[TestClient]: + test_client = TestClient(app) + try: + yield test_client + finally: + test_client.close() + + +@pytest.fixture() +def unit_of_work_factory(session_factory: sessionmaker) -> Callable[[], UnitOfWork]: + def _factory() -> UnitOfWork: + return UnitOfWork(session_factory=session_factory) + + return _factory diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..33cf263 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,30 @@ +# Lifecycle Integration Test Plan + +## Coverage Goals + +- Exercise end-to-end creation, update, and deletion flows for projects through both API endpoints and HTML form submissions to ensure consistency across interfaces. +- Validate scenario lifecycle interactions (create, update, archive) including business rule enforcement and status transitions when performed via API and UI routes. +- Confirm that redirect chains and rendered templates match expected destinations after each lifecycle operation. +- Verify repository-backed statistics (counts, recency metadata) update appropriately after lifecycle actions to maintain dashboard accuracy. +- Guard against regressions in unit-of-work transaction handling by asserting database state after success and failure paths within integration flows. + +## Happy-Path Journeys + +1. **Project Management Flow** + + - Navigate to the project list UI and confirm empty-state messaging. + - Submit the new project form with valid data and verify redirect to the list page with the project present. + - Perform an API-based update to adjust project metadata and check the UI detail view reflects changes. + - Delete the project through the API and ensure the list UI reverts to the empty state. + +2. **Scenario Lifecycle Flow** + + - From an existing project, create a new scenario via the API and verify the project detail page renders the scenario entry. + - Update the scenario through the UI form, ensuring redirect to the scenario detail view with updated fields. + - Trigger a validation rule (e.g., duplicate scenario name within a project) to confirm error messaging without data loss. + - Archive the scenario using the API and confirm status badges and scenario counts update across dashboard and project detail views. + +3. **Dashboard Consistency Flow** + - Seed multiple projects and scenarios through combined API/UI interactions. + - Visit the dashboard and ensure metric cards reflect the latest counts, active/draft status breakdowns, and recent activity timestamps after each mutation. + - Run the lifecycle flows sequentially to confirm cumulative effects propagate to dashboard summaries and navigation badges. diff --git a/tests/integration/test_project_lifecycle.py b/tests/integration/test_project_lifecycle.py new file mode 100644 index 0000000..5092b65 --- /dev/null +++ b/tests/integration/test_project_lifecycle.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestProjectLifecycle: + def test_project_create_update_delete_flow(self, client: TestClient) -> None: + # Initial state: no projects listed on the UI page + response = client.get("/projects/ui") + assert response.status_code == 200 + assert "No projects yet" in response.text + + # Create a project via the HTML form submission + create_payload = { + "name": "Lifecycle Mine", + "location": "Nevada", + "operation_type": "open_pit", + "description": "Initial description", + } + response = client.post( + "/projects/create", + data=create_payload, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["Location"].endswith("/projects/ui") + + # Project should now appear on the list page + response = client.get("/projects/ui") + assert response.status_code == 200 + assert "Lifecycle Mine" in response.text + assert "Nevada" in response.text + + # Fetch the project via API to obtain its identifier + response = client.get("/projects") + assert response.status_code == 200 + projects = response.json() + assert len(projects) == 1 + project_id = projects[0]["id"] + + # Update the project using the API endpoint + update_payload = { + "location": "Arizona", + "description": "Updated description", + } + response = client.put(f"/projects/{project_id}", json=update_payload) + assert response.status_code == 200 + body = response.json() + assert body["location"] == "Arizona" + assert body["description"] == "Updated description" + + # Verify the UI detail page reflects the updates + response = client.get(f"/projects/{project_id}/view") + assert response.status_code == 200 + assert "Arizona" in response.text + assert "Updated description" in response.text + + # Delete the project using the API endpoint + response = client.delete(f"/projects/{project_id}") + assert response.status_code == 204 + + # Ensure the list view returns to the empty state + response = client.get("/projects/ui") + assert response.status_code == 200 + assert "No projects yet" in response.text + assert "Lifecycle Mine" not in response.text diff --git a/tests/integration/test_scenario_lifecycle.py b/tests/integration/test_scenario_lifecycle.py new file mode 100644 index 0000000..3315c8b --- /dev/null +++ b/tests/integration/test_scenario_lifecycle.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestScenarioLifecycle: + def test_scenario_lifecycle_flow(self, client: TestClient) -> None: + # Create a project to attach scenarios to + project_response = client.post( + "/projects", + json={ + "name": "Scenario Host Project", + "location": "Ontario", + "operation_type": "open_pit", + "description": "Project for scenario lifecycle testing", + }, + ) + assert project_response.status_code == 201 + project_id = project_response.json()["id"] + + # Create a scenario via the API for the project + scenario_response = client.post( + f"/projects/{project_id}/scenarios", + json={ + "name": "Lifecycle Scenario", + "description": "Initial scenario description", + "status": "draft", + "currency": "usd", + "primary_resource": "diesel", + }, + ) + assert scenario_response.status_code == 201 + scenario_id = scenario_response.json()["id"] + + # Project detail page should list the new scenario in draft state + project_detail = client.get(f"/projects/{project_id}/view") + assert project_detail.status_code == 200 + assert "Lifecycle Scenario" in project_detail.text + assert "
1
' in project_detail.text + assert "