Files
TimeTracker/tests/test_project_dashboard.py
Dries Peeters 90dde470da style: standardize code formatting and normalize line endings
- Normalize line endings from CRLF to LF across all files to match .editorconfig
- Standardize quote style from single quotes to double quotes
- Normalize whitespace and formatting throughout codebase
- Apply consistent code style across 372 files including:
  * Application code (models, routes, services, utils)
  * Test files
  * Configuration files
  * CI/CD workflows

This ensures consistency with the project's .editorconfig settings and
improves code maintainability.
2025-11-28 20:05:37 +01:00

494 lines
18 KiB
Python

"""
Comprehensive tests for Project Dashboard functionality.
This module tests:
- Project dashboard route and access
- Budget vs actual data calculations
- Task statistics aggregation
- Team member contributions
- Recent activity tracking
- Timeline data generation
- Period filtering
"""
import pytest
from datetime import date, datetime, timedelta
from decimal import Decimal
from app import create_app, db
from app.models import User, Project, Client, Task, TimeEntry, Activity, ProjectCost
# Skip all tests in this module due to pre-existing model initialization issues
pytestmark = pytest.mark.skip(reason="Pre-existing issues with Task model initialization - needs refactoring")
@pytest.fixture
def app():
"""Create and configure a test application instance."""
app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", "WTF_CSRF_ENABLED": False})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client_fixture(app):
"""Create a test Flask client."""
return app.test_client()
@pytest.fixture
def test_user(app):
"""Create a test user."""
with app.app_context():
user = User(username="testuser", role="user", email="test@example.com")
user.set_password("testpass123")
db.session.add(user)
db.session.commit()
return user.id
@pytest.fixture
def test_user2(app):
"""Create a second test user."""
with app.app_context():
user = User(username="testuser2", role="user", email="test2@example.com", full_name="Test User 2")
user.set_password("testpass123")
db.session.add(user)
db.session.commit()
return user.id
@pytest.fixture
def test_admin(app):
"""Create a test admin user."""
with app.app_context():
admin = User(username="admin", role="admin", email="admin@example.com")
admin.set_password("adminpass123")
db.session.add(admin)
db.session.commit()
return admin.id
@pytest.fixture
def test_client(app):
"""Create a test client."""
with app.app_context():
client = Client(name="Test Client", description="A test client")
db.session.add(client)
db.session.commit()
return client.id
@pytest.fixture
def test_project(app, test_client):
"""Create a test project with budget."""
with app.app_context():
project = Project(
name="Dashboard Test Project",
client_id=test_client,
description="A test project for dashboard",
billable=True,
hourly_rate=Decimal("100.00"),
budget_amount=Decimal("5000.00"),
)
project.estimated_hours = 50.0
db.session.add(project)
db.session.commit()
return project.id
@pytest.fixture
def test_project_with_data(app, test_project, test_user, test_user2):
"""Create a test project with tasks and time entries."""
with app.app_context():
project = db.session.get(Project, test_project)
# Create tasks with different statuses
task1 = Task(
project_id=project.id,
name="Task 1 - Todo",
status="todo",
priority="high",
created_by=test_user,
assigned_to=test_user,
)
task2 = Task(
project_id=project.id,
name="Task 2 - In Progress",
status="in_progress",
priority="medium",
created_by=test_user,
assigned_to=test_user2,
)
task3 = Task(
project_id=project.id,
name="Task 3 - Done",
status="done",
priority="low",
created_by=test_user,
assigned_to=test_user,
completed_at=datetime.now(),
)
task4 = Task(
project_id=project.id,
name="Task 4 - Overdue",
status="todo",
priority="urgent",
due_date=date.today() - timedelta(days=5),
created_by=test_user,
assigned_to=test_user,
)
db.session.add_all([task1, task2, task3, task4])
# Create time entries for both users
now = datetime.now()
# User 1: 10 hours across 3 entries
entry1 = TimeEntry(
user_id=test_user,
project_id=project.id,
task_id=task1.id,
start_time=now - timedelta(days=2, hours=4),
end_time=now - timedelta(days=2),
duration_seconds=14400, # 4 hours
billable=True,
)
entry2 = TimeEntry(
user_id=test_user,
project_id=project.id,
task_id=task3.id,
start_time=now - timedelta(days=1, hours=3),
end_time=now - timedelta(days=1),
duration_seconds=10800, # 3 hours
billable=True,
)
entry3 = TimeEntry(
user_id=test_user,
project_id=project.id,
start_time=now - timedelta(hours=3),
end_time=now,
duration_seconds=10800, # 3 hours
billable=True,
)
# User 2: 5 hours across 2 entries
entry4 = TimeEntry(
user_id=test_user2,
project_id=project.id,
task_id=task2.id,
start_time=now - timedelta(days=1, hours=3),
end_time=now - timedelta(days=1),
duration_seconds=10800, # 3 hours
billable=True,
)
entry5 = TimeEntry(
user_id=test_user2,
project_id=project.id,
start_time=now - timedelta(hours=2),
end_time=now,
duration_seconds=7200, # 2 hours
billable=True,
)
db.session.add_all([entry1, entry2, entry3, entry4, entry5])
# Create some activities
Activity.log(
user_id=test_user,
action="created",
entity_type="project",
entity_id=project.id,
entity_name=project.name,
description=f'Created project "{project.name}"',
)
Activity.log(
user_id=test_user,
action="created",
entity_type="task",
entity_id=task1.id,
entity_name=task1.name,
description=f'Created task "{task1.name}"',
)
Activity.log(
user_id=test_user,
action="completed",
entity_type="task",
entity_id=task3.id,
entity_name=task3.name,
description=f'Completed task "{task3.name}"',
)
db.session.commit()
return project.id
def login(client, username="testuser", password="testpass123"):
"""Helper function to log in a user."""
return client.post("/auth/login", data={"username": username, "password": password}, follow_redirects=True)
class TestProjectDashboardAccess:
"""Tests for dashboard access and permissions."""
def test_dashboard_requires_login(self, app, client_fixture, test_project):
"""Test that dashboard requires authentication."""
with app.app_context():
response = client_fixture.get(f"/projects/{test_project}/dashboard")
assert response.status_code == 302 # Redirect to login
def test_dashboard_accessible_when_logged_in(self, app, client_fixture, test_project, test_user):
"""Test that dashboard is accessible when logged in."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project}/dashboard")
assert response.status_code == 200
def test_dashboard_404_for_nonexistent_project(self, app, client_fixture, test_user):
"""Test that dashboard returns 404 for non-existent project."""
with app.app_context():
login(client_fixture)
response = client_fixture.get("/projects/99999/dashboard")
assert response.status_code == 404
class TestDashboardData:
"""Tests for dashboard data calculations and aggregations."""
def test_budget_data_calculation(self, app, client_fixture, test_project_with_data, test_user):
"""Test that budget data is calculated correctly."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard")
assert response.status_code == 200
# Check that budget-related content is in response
assert b"Budget vs. Actual" in response.data
# Get project and verify calculations
project = db.session.get(Project, test_project_with_data)
assert project.budget_amount is not None
assert project.total_hours > 0
def test_task_statistics(self, app, client_fixture, test_project_with_data, test_user):
"""Test that task statistics are calculated correctly."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard")
assert response.status_code == 200
# Verify task statistics in response
assert b"Task Status Distribution" in response.data
assert b"Tasks Complete" in response.data
# Verify task counts
project = db.session.get(Project, test_project_with_data)
tasks = project.tasks.all()
assert len(tasks) == 4 # We created 4 tasks
# Check task statuses
statuses = [task.status for task in tasks]
assert "todo" in statuses
assert "in_progress" in statuses
assert "done" in statuses
def test_team_contributions(self, app, client_fixture, test_project_with_data, test_user):
"""Test that team member contributions are calculated correctly."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard")
assert response.status_code == 200
# Verify team contributions section exists
assert b"Team Member Contributions" in response.data
assert b"Team Members" in response.data
# Get project and verify user totals
project = db.session.get(Project, test_project_with_data)
user_totals = project.get_user_totals()
assert len(user_totals) == 2 # Two users contributed
# Verify hours distribution
total_hours = sum([ut["total_hours"] for ut in user_totals])
assert total_hours == 15.0 # 10 + 5 hours
def test_recent_activity(self, app, client_fixture, test_project_with_data, test_user):
"""Test that recent activity is displayed correctly."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard")
assert response.status_code == 200
# Verify recent activity section exists
assert b"Recent Activity" in response.data
# Verify activities exist in database
project = db.session.get(Project, test_project_with_data)
activities = Activity.query.filter_by(entity_type="project", entity_id=project.id).all()
assert len(activities) >= 1
def test_overdue_tasks_warning(self, app, client_fixture, test_project_with_data, test_user):
"""Test that overdue tasks trigger a warning."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard")
assert response.status_code == 200
# Verify overdue warning is shown
assert b"Attention Required" in response.data or b"overdue" in response.data.lower()
class TestDashboardPeriodFiltering:
"""Tests for dashboard time period filtering."""
def test_period_filter_all_time(self, app, client_fixture, test_project_with_data, test_user):
"""Test dashboard with 'all time' filter."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard?period=all")
assert response.status_code == 200
assert b"All Time" in response.data
def test_period_filter_week(self, app, client_fixture, test_project_with_data, test_user):
"""Test dashboard with 'last week' filter."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard?period=week")
assert response.status_code == 200
def test_period_filter_month(self, app, client_fixture, test_project_with_data, test_user):
"""Test dashboard with 'last month' filter."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard?period=month")
assert response.status_code == 200
def test_period_filter_three_months(self, app, client_fixture, test_project_with_data, test_user):
"""Test dashboard with '3 months' filter."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard?period=3months")
assert response.status_code == 200
def test_period_filter_year(self, app, client_fixture, test_project_with_data, test_user):
"""Test dashboard with 'year' filter."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project_with_data}/dashboard?period=year")
assert response.status_code == 200
class TestDashboardWithNoData:
"""Tests for dashboard behavior with minimal or no data."""
def test_dashboard_with_no_budget(self, app, client_fixture, test_client, test_user):
"""Test dashboard for project without budget."""
with app.app_context():
# Create project without budget
project = Project(name="No Budget Project", client_id=test_client, billable=False)
db.session.add(project)
db.session.commit()
login(client_fixture)
response = client_fixture.get(f"/projects/{project.id}/dashboard")
assert response.status_code == 200
assert b"No budget set" in response.data
def test_dashboard_with_no_tasks(self, app, client_fixture, test_project, test_user):
"""Test dashboard for project without tasks."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project}/dashboard")
assert response.status_code == 200
assert b"No tasks" in response.data or b"0/0" in response.data
def test_dashboard_with_no_time_entries(self, app, client_fixture, test_project, test_user):
"""Test dashboard for project without time entries."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project}/dashboard")
assert response.status_code == 200
# Should show zero hours
project = db.session.get(Project, test_project)
assert project.total_hours == 0
def test_dashboard_with_no_activity(self, app, client_fixture, test_project, test_user):
"""Test dashboard for project without activity."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project}/dashboard")
assert response.status_code == 200
assert b"No recent activity" in response.data or b"Recent Activity" in response.data
class TestDashboardBudgetThreshold:
"""Tests for budget threshold warnings."""
def test_budget_threshold_exceeded_warning(self, app, client_fixture, test_client, test_user):
"""Test that budget threshold exceeded triggers warning."""
with app.app_context():
# Create project with budget
project = Project(
name="Budget Test Project",
client_id=test_client,
billable=True,
hourly_rate=Decimal("100.00"),
budget_amount=Decimal("500.00"), # Small budget
budget_threshold_percent=80,
)
project.estimated_hours = 10.0
db.session.add(project)
db.session.commit()
# Add time entries to exceed threshold
now = datetime.now()
entry = TimeEntry(
user_id=test_user,
project_id=project.id,
start_time=now - timedelta(hours=6),
end_time=now,
duration_seconds=21600, # 6 hours = $600, exceeds $500 budget
billable=True,
)
db.session.add(entry)
db.session.commit()
login(client_fixture)
response = client_fixture.get(f"/projects/{project.id}/dashboard")
assert response.status_code == 200
# Check that budget warning appears
project = db.session.get(Project, project.id)
assert project.budget_threshold_exceeded
class TestDashboardNavigation:
"""Tests for dashboard navigation and links."""
def test_back_to_project_link(self, app, client_fixture, test_project, test_user):
"""Test that dashboard has link back to project view."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project}/dashboard")
assert response.status_code == 200
assert b"Back to Project" in response.data
assert f"/projects/{test_project}".encode() in response.data
def test_dashboard_link_in_project_view(self, app, client_fixture, test_project, test_user):
"""Test that project view has link to dashboard."""
with app.app_context():
login(client_fixture)
response = client_fixture.get(f"/projects/{test_project}")
assert response.status_code == 200
assert b"Dashboard" in response.data
assert f"/projects/{test_project}/dashboard".encode() in response.data
if __name__ == "__main__":
pytest.main([__file__, "-v"])