mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-06 03:30:25 -06:00
- 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.
432 lines
14 KiB
Python
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
|