feat: add integration tests for project and scenario lifecycles, update templates to new Starlette signature, and optimize project retrieval logic

This commit is contained in:
2025-11-09 19:47:35 +01:00
parent dad862e48e
commit 2d848c2e09
10 changed files with 292 additions and 72 deletions

72
tests/conftest.py Normal file
View File

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

View File

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

View File

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

View File

@@ -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 "<td>Draft</td>" in project_detail.text
# Update the scenario through the HTML form
form_response = client.post(
f"/scenarios/{scenario_id}/edit",
data={
"name": "Lifecycle Scenario Revised",
"description": "Revised scenario assumptions",
"status_value": "active",
"start_date": "2025-01-01",
"end_date": "2025-12-31",
"discount_rate": "5.5",
"currency": "cad",
"primary_resource": "electricity",
},
follow_redirects=False,
)
assert form_response.status_code == 303
assert form_response.headers["Location"].endswith(
f"/scenarios/{scenario_id}/view")
# Scenario detail page should reflect the updated information
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 "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
# Attempt to update the scenario with invalid currency to trigger validation error
invalid_update = client.put(
f"/scenarios/{scenario_id}",
json={"currency": "ca"},
)
assert invalid_update.status_code == 422
assert (
invalid_update.json()["detail"][0]["msg"]
== "Value error, Currency code must be a 3-letter ISO value"
)
# Scenario detail should still show the previous (valid) currency
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
assert "CAD" in scenario_detail.text
# Archive the scenario through the API
archive_response = client.put(
f"/scenarios/{scenario_id}",
json={"status": "archived"},
)
assert archive_response.status_code == 200
assert archive_response.json()["status"] == "archived"
# Scenario detail reflects archived status
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
assert "Status: Archived" 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

View File

@@ -1,64 +1,6 @@
from __future__ import annotations
from collections.abc import 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 client(session_factory: sessionmaker) -> Iterator[TestClient]:
app = FastAPI()
app.include_router(dashboard_router)
app.include_router(projects_router)
app.include_router(scenarios_router)
def _override_uow() -> Iterator[UnitOfWork]:
with UnitOfWork(session_factory=session_factory) as uow:
yield uow
app.dependency_overrides[get_unit_of_work] = _override_uow
test_client = TestClient(app)
try:
yield test_client
finally:
test_client.close()
class TestDashboardRoute: