Files
TimeTracker/tests/test_budget_forecasting.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

432 lines
14 KiB
Python

"""Unit tests for budget forecasting utilities"""
import pytest
from datetime import datetime, timedelta, date
from decimal import Decimal
from app import db
from app.models import Project, TimeEntry, User, ProjectCost, Client
from factories import UserFactory, ClientFactory, ProjectFactory, TimeEntryFactory
from app.utils.budget_forecasting import (
calculate_burn_rate,
estimate_completion_date,
analyze_resource_allocation,
analyze_cost_trends,
get_budget_status,
check_budget_alerts,
)
# Skip all tests in this module due to pre-existing model initialization issues
pytestmark = pytest.mark.skip(reason="Pre-existing issues with model initialization - needs refactoring")
@pytest.fixture
def client_obj(app):
"""Create a test client"""
client = ClientFactory(name="Test Client")
client.status = "active"
db.session.add(client)
db.session.commit()
return client
@pytest.fixture
def project_with_budget(app, client_obj):
"""Create a test project with budget"""
project = ProjectFactory(
client_id=client_obj.id,
name="Test Project",
billable=True,
hourly_rate=Decimal("100.00"),
)
project.budget_amount = Decimal("10000.00")
project.budget_threshold_percent = 80
project.status = "active"
db.session.add(project)
db.session.commit()
return project
@pytest.fixture
def test_user(app):
"""Create a test user"""
user = UserFactory(username="testuser")
user.is_active = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def time_entries_last_30_days(app, project_with_budget, test_user):
"""Create time entries for the last 30 days"""
entries = []
now = datetime.now()
for i in range(30):
entry_date = now - timedelta(days=i)
entry = TimeEntryFactory(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=entry_date,
end_time=entry_date + timedelta(hours=4),
billable=True,
)
entry.calculate_duration()
db.session.add(entry)
entries.append(entry)
db.session.commit()
return entries
def test_calculate_burn_rate_no_data(app, project_with_budget):
"""Test burn rate calculation with no time entries"""
burn_rate = calculate_burn_rate(project_with_budget.id, days=30)
assert burn_rate is not None
assert burn_rate["daily_burn_rate"] == 0
assert burn_rate["weekly_burn_rate"] == 0
assert burn_rate["monthly_burn_rate"] == 0
assert burn_rate["period_total"] == 0
assert burn_rate["period_days"] == 30
def test_calculate_burn_rate_with_data(app, project_with_budget, time_entries_last_30_days):
"""Test burn rate calculation with time entries"""
burn_rate = calculate_burn_rate(project_with_budget.id, days=30)
assert burn_rate is not None
assert burn_rate["daily_burn_rate"] > 0
assert burn_rate["weekly_burn_rate"] > 0
assert burn_rate["monthly_burn_rate"] > 0
assert burn_rate["period_total"] > 0
# Each day has 4 hours at $100/hr = $400/day
expected_daily = 400.0
assert abs(burn_rate["daily_burn_rate"] - expected_daily) < 1.0 # Allow small rounding difference
def test_calculate_burn_rate_invalid_project(app):
"""Test burn rate calculation with invalid project ID"""
burn_rate = calculate_burn_rate(99999, days=30)
assert burn_rate is None
def test_estimate_completion_date_no_budget(app, client_obj):
"""Test completion estimate for project without budget"""
project = ProjectFactory(
name="No Budget Project",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
)
project.status = "active"
db.session.add(project)
db.session.commit()
estimate = estimate_completion_date(project.id)
assert estimate is None
def test_estimate_completion_date_no_activity(app, project_with_budget):
"""Test completion estimate with no recent activity"""
estimate = estimate_completion_date(project_with_budget.id, analysis_days=30)
assert estimate is not None
assert estimate["estimated_completion_date"] is None
assert estimate["days_remaining"] is None
assert estimate["confidence"] == "low"
assert "No recent activity" in estimate["message"]
def test_estimate_completion_date_with_activity(app, project_with_budget, time_entries_last_30_days):
"""Test completion estimate with activity"""
estimate = estimate_completion_date(project_with_budget.id, analysis_days=30)
assert estimate is not None
assert estimate["estimated_completion_date"] is not None
assert estimate["days_remaining"] is not None
assert estimate["daily_burn_rate"] > 0
assert estimate["budget_amount"] == 10000.0
assert estimate["confidence"] in ["high", "medium", "low"]
def test_analyze_resource_allocation_no_data(app, project_with_budget):
"""Test resource allocation analysis with no data"""
allocation = analyze_resource_allocation(project_with_budget.id, days=30)
assert allocation is not None
assert allocation["users"] == []
assert allocation["total_hours"] == 0
assert allocation["total_cost"] == 0
def test_analyze_resource_allocation_with_data(app, project_with_budget, time_entries_last_30_days):
"""Test resource allocation analysis with data"""
allocation = analyze_resource_allocation(project_with_budget.id, days=30)
assert allocation is not None
assert len(allocation["users"]) > 0
assert allocation["total_hours"] > 0
assert allocation["total_cost"] > 0
assert allocation["hourly_rate"] == 100.0
# Check user data structure
user_data = allocation["users"][0]
assert "user_id" in user_data
assert "username" in user_data
assert "hours" in user_data
assert "cost" in user_data
assert "cost_percentage" in user_data
assert "hours_percentage" in user_data
def test_analyze_cost_trends_no_data(app, project_with_budget):
"""Test cost trend analysis with no data"""
trends = analyze_cost_trends(project_with_budget.id, days=90, granularity="week")
assert trends is not None
assert trends["periods"] == []
assert trends["trend_direction"] == "insufficient_data"
assert trends["average_cost_per_period"] == 0
def test_analyze_cost_trends_with_data(app, project_with_budget, time_entries_last_30_days):
"""Test cost trend analysis with data"""
trends = analyze_cost_trends(project_with_budget.id, days=30, granularity="week")
assert trends is not None
assert len(trends["periods"]) > 0
assert trends["trend_direction"] in ["increasing", "decreasing", "stable", "insufficient_data"]
assert trends["average_cost_per_period"] >= 0
assert trends["granularity"] == "week"
def test_analyze_cost_trends_different_granularities(app, project_with_budget, time_entries_last_30_days):
"""Test cost trend analysis with different granularities"""
# Daily granularity
daily_trends = analyze_cost_trends(project_with_budget.id, days=30, granularity="day")
assert daily_trends is not None
# Weekly granularity
weekly_trends = analyze_cost_trends(project_with_budget.id, days=30, granularity="week")
assert weekly_trends is not None
# Monthly granularity
monthly_trends = analyze_cost_trends(project_with_budget.id, days=90, granularity="month")
assert monthly_trends is not None
def test_get_budget_status_no_budget(app, client_obj):
"""Test budget status for project without budget"""
project = Project(
name="No Budget Project", client_id=client_obj.id, billable=True, hourly_rate=Decimal("100.00"), status="active"
)
db.session.add(project)
db.session.commit()
status = get_budget_status(project.id)
assert status is None
def test_get_budget_status_healthy(app, project_with_budget):
"""Test budget status for healthy project"""
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status["budget_amount"] == 10000.0
assert status["consumed_amount"] == 0.0
assert status["remaining_amount"] == 10000.0
assert status["consumed_percentage"] == 0.0
assert status["status"] == "healthy"
assert status["threshold_percent"] == 80
def test_get_budget_status_warning(app, project_with_budget, test_user):
"""Test budget status for project in warning state"""
# Create entries that consume 70% of budget
# Budget is $10,000, hourly rate is $100
# 70% = $7,000 = 70 hours
now = datetime.now()
for i in range(70):
entry = TimeEntryFactory(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i + 1),
end_time=now - timedelta(hours=i),
billable=True,
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status["status"] == "warning"
assert status["consumed_percentage"] >= 60 # At least 60%
assert status["consumed_percentage"] < 80 # Less than 80%
def test_get_budget_status_critical(app, project_with_budget, test_user):
"""Test budget status for project in critical state"""
# Create entries that consume 85% of budget
# Budget is $10,000, hourly rate is $100
# 85% = $8,500 = 85 hours
now = datetime.now()
for i in range(85):
entry = TimeEntryFactory(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i + 1),
end_time=now - timedelta(hours=i),
billable=True,
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status["status"] == "critical"
assert status["consumed_percentage"] >= 80 # At least 80%
assert status["consumed_percentage"] < 100 # Less than 100%
def test_get_budget_status_over_budget(app, project_with_budget, test_user):
"""Test budget status for over budget project"""
# Create entries that consume 110% of budget
# Budget is $10,000, hourly rate is $100
# 110% = $11,000 = 110 hours
now = datetime.now()
for i in range(110):
entry = TimeEntryFactory(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i + 1),
end_time=now - timedelta(hours=i),
billable=True,
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status["status"] == "over_budget"
assert status["consumed_percentage"] >= 100
def test_check_budget_alerts_no_alerts_needed(app, project_with_budget):
"""Test budget alert checking when no alerts are needed"""
alerts = check_budget_alerts(project_with_budget.id)
assert isinstance(alerts, list)
assert len(alerts) == 0
def test_check_budget_alerts_warning_alert(app, project_with_budget, test_user):
"""Test budget alert checking for warning threshold"""
# Create entries that consume 82% of budget
now = datetime.now()
for i in range(82):
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i + 1),
end_time=now - timedelta(hours=i),
billable=True,
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
alerts = check_budget_alerts(project_with_budget.id)
assert isinstance(alerts, list)
assert len(alerts) > 0
assert any(alert["type"] == "warning_80" for alert in alerts)
def test_check_budget_alerts_over_budget(app, project_with_budget, test_user):
"""Test budget alert checking for over budget"""
# Create entries that consume 110% of budget
now = datetime.now()
for i in range(110):
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i + 1),
end_time=now - timedelta(hours=i),
billable=True,
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
alerts = check_budget_alerts(project_with_budget.id)
assert isinstance(alerts, list)
# Should have over_budget alert
assert any(alert["type"] == "over_budget" for alert in alerts)
def test_check_budget_alerts_invalid_project(app):
"""Test budget alert checking with invalid project"""
alerts = check_budget_alerts(99999)
assert isinstance(alerts, list)
assert len(alerts) == 0
def test_resource_allocation_multiple_users(app, project_with_budget, client_obj):
"""Test resource allocation with multiple users"""
# Create additional users
user1 = UserFactory(username="user1")
user1.is_active = True
user2 = UserFactory(username="user2")
user2.is_active = True
db.session.add(user1)
db.session.add(user2)
db.session.commit()
# Create time entries for multiple users
now = datetime.now()
for i in range(10):
# User 1: 10 entries of 2 hours each
entry1 = TimeEntryFactory(
user_id=user1.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=2),
billable=True,
)
entry1.calculate_duration()
db.session.add(entry1)
# User 2: 10 entries of 3 hours each
entry2 = TimeEntryFactory(
user_id=user2.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=3),
billable=True,
)
entry2.calculate_duration()
db.session.add(entry2)
db.session.commit()
allocation = analyze_resource_allocation(project_with_budget.id, days=30)
assert allocation is not None
assert len(allocation["users"]) == 2
# Check that costs are sorted (highest first)
assert allocation["users"][0]["cost"] >= allocation["users"][1]["cost"]
# Check that percentages add up to 100%
total_cost_percentage = sum(u["cost_percentage"] for u in allocation["users"])
assert abs(total_cost_percentage - 100.0) < 0.1 # Allow small rounding difference