Files
TimeTracker/tests/test_project_costs.py
2025-10-10 14:58:11 +02:00

701 lines
24 KiB
Python

"""
Comprehensive tests for ProjectCost model and related functionality.
This module tests:
- ProjectCost model creation and validation
- Relationships with Project, User, and Invoice models
- Query methods (get_project_costs, get_total_costs, etc.)
- Invoicing workflow
- Data integrity and constraints
"""
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, Invoice, ProjectCost
@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')
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')
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 app.app_context():
project = Project(
name='Test Project',
client_id=test_client,
description='A test project',
billable=True,
hourly_rate=Decimal('100.00')
)
db.session.add(project)
db.session.commit()
return project.id
@pytest.fixture
def test_invoice(app, test_client, test_project, test_user):
"""Create a test invoice."""
with app.app_context():
# Get the client to retrieve client_name
client = db.session.get(Client, test_client)
invoice = Invoice(
invoice_number='INV-TEST-001',
project_id=test_project,
client_name=client.name,
due_date=date.today() + timedelta(days=30),
created_by=test_user,
client_id=test_client,
issue_date=date.today(),
status='draft'
)
db.session.add(invoice)
db.session.commit()
return invoice.id
# Model Tests
class TestProjectCostModel:
"""Test ProjectCost model creation, validation, and basic operations."""
def test_create_project_cost(self, app, test_project, test_user):
"""Test creating a basic project cost."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Office supplies',
category='materials',
amount=Decimal('50.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
assert cost.id is not None
assert cost.description == 'Office supplies'
assert cost.category == 'materials'
assert cost.amount == Decimal('50.00')
assert cost.currency_code == 'EUR'
assert cost.billable is True
assert cost.invoiced is False
assert cost.invoice_id is None
def test_create_project_cost_with_all_fields(self, app, test_project, test_user):
"""Test creating a project cost with all optional fields."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Travel expenses',
category='travel',
amount=Decimal('250.75'),
cost_date=date.today(),
billable=False,
notes='Flight to client meeting',
currency_code='USD',
receipt_path='/receipts/flight_2025.pdf'
)
db.session.add(cost)
db.session.commit()
assert cost.billable is False
assert cost.notes == 'Flight to client meeting'
assert cost.currency_code == 'USD'
assert cost.receipt_path == '/receipts/flight_2025.pdf'
def test_project_cost_str_representation(self, app, test_project, test_user):
"""Test __repr__ method."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Equipment rental',
category='equipment',
amount=Decimal('500.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
assert 'Equipment rental' in str(cost)
assert '500.00' in str(cost) or '500' in str(cost)
assert 'EUR' in str(cost)
def test_project_cost_timestamps(self, app, test_project, test_user):
"""Test automatic timestamp creation."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='other',
amount=Decimal('10.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
assert cost.created_at is not None
assert cost.updated_at is not None
assert isinstance(cost.created_at, datetime)
assert isinstance(cost.updated_at, datetime)
class TestProjectCostRelationships:
"""Test ProjectCost relationships with other models."""
def test_project_relationship(self, app, test_project, test_user):
"""Test relationship with Project model."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='materials',
amount=Decimal('100.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
# Refresh objects to load relationships
cost = db.session.get(ProjectCost, cost.id)
project = db.session.get(Project, test_project)
assert cost.project is not None
assert cost.project.id == test_project
assert cost in project.costs.all()
def test_user_relationship(self, app, test_project, test_user):
"""Test relationship with User model."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='services',
amount=Decimal('200.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
# Refresh objects to load relationships
cost = db.session.get(ProjectCost, cost.id)
user = db.session.get(User, test_user)
assert cost.user is not None
assert cost.user.id == test_user
assert cost in user.project_costs.all()
def test_invoice_relationship(self, app, test_project, test_user, test_invoice):
"""Test relationship with Invoice model."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='materials',
amount=Decimal('150.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
# Mark as invoiced
cost.mark_as_invoiced(test_invoice)
db.session.commit()
# Refresh object
cost = db.session.get(ProjectCost, cost.id)
assert cost.invoice_id == test_invoice
assert cost.invoiced is True
class TestProjectCostMethods:
"""Test ProjectCost instance and class methods."""
def test_is_invoiced_property(self, app, test_project, test_user, test_invoice):
"""Test is_invoiced property."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='materials',
amount=Decimal('50.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
# Initially not invoiced
assert cost.is_invoiced is False
# Mark as invoiced
cost.mark_as_invoiced(test_invoice)
db.session.commit()
assert cost.is_invoiced is True
def test_mark_as_invoiced(self, app, test_project, test_user, test_invoice):
"""Test marking a cost as invoiced."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='materials',
amount=Decimal('75.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
original_updated_at = cost.updated_at
# Small delay to ensure timestamp changes
import time
time.sleep(0.01)
cost.mark_as_invoiced(test_invoice)
db.session.commit()
assert cost.invoiced is True
assert cost.invoice_id == test_invoice
# Note: updated_at might not change in all databases
def test_unmark_as_invoiced(self, app, test_project, test_user, test_invoice):
"""Test unmarking a cost as invoiced."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='materials',
amount=Decimal('60.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
# Mark as invoiced
cost.mark_as_invoiced(test_invoice)
db.session.commit()
assert cost.invoiced is True
# Unmark
cost.unmark_as_invoiced()
db.session.commit()
assert cost.invoiced is False
assert cost.invoice_id is None
def test_to_dict(self, app, test_project, test_user):
"""Test converting cost to dictionary."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Test cost',
category='travel',
amount=Decimal('120.50'),
cost_date=date.today(),
notes='Test notes'
)
db.session.add(cost)
db.session.commit()
# Refresh to load relationships
cost = db.session.get(ProjectCost, cost.id)
cost_dict = cost.to_dict()
assert cost_dict['id'] == cost.id
assert cost_dict['project_id'] == test_project
assert cost_dict['user_id'] == test_user
assert cost_dict['description'] == 'Test cost'
assert cost_dict['category'] == 'travel'
assert cost_dict['amount'] == 120.50
assert cost_dict['currency_code'] == 'EUR'
assert cost_dict['billable'] is True
assert cost_dict['invoiced'] is False
assert cost_dict['notes'] == 'Test notes'
assert 'created_at' in cost_dict
assert 'updated_at' in cost_dict
class TestProjectCostQueries:
"""Test ProjectCost query class methods."""
def test_get_project_costs(self, app, test_project, test_user):
"""Test retrieving project costs."""
with app.app_context():
# Create multiple costs
costs = [
ProjectCost(
project_id=test_project,
user_id=test_user,
description=f'Cost {i}',
category='materials',
amount=Decimal(f'{100 + i * 10}.00'),
cost_date=date.today() - timedelta(days=i)
)
for i in range(5)
]
db.session.add_all(costs)
db.session.commit()
# Get all costs
retrieved = ProjectCost.get_project_costs(test_project)
assert len(retrieved) == 5
# Should be ordered by cost_date desc (newest first)
assert retrieved[0].description == 'Cost 0'
def test_get_project_costs_with_date_filter(self, app, test_project, test_user):
"""Test filtering costs by date range."""
with app.app_context():
# Create costs over different dates
costs = [
ProjectCost(
project_id=test_project,
user_id=test_user,
description=f'Cost {i}',
category='materials',
amount=Decimal('100.00'),
cost_date=date.today() - timedelta(days=i * 10)
)
for i in range(5)
]
db.session.add_all(costs)
db.session.commit()
# Filter by date range
start_date = date.today() - timedelta(days=25)
end_date = date.today() - timedelta(days=5)
filtered = ProjectCost.get_project_costs(
test_project,
start_date=start_date,
end_date=end_date
)
# Should get costs from days 10 and 20
assert len(filtered) == 2
def test_get_project_costs_billable_only(self, app, test_project, test_user):
"""Test filtering for billable costs only."""
with app.app_context():
# Create mix of billable and non-billable
costs = [
ProjectCost(
project_id=test_project,
user_id=test_user,
description=f'Cost {i}',
category='materials',
amount=Decimal('100.00'),
cost_date=date.today(),
billable=(i % 2 == 0)
)
for i in range(6)
]
db.session.add_all(costs)
db.session.commit()
# Get billable only
billable = ProjectCost.get_project_costs(test_project, billable_only=True)
assert len(billable) == 3
assert all(cost.billable for cost in billable)
def test_get_total_costs(self, app, test_project, test_user):
"""Test calculating total costs."""
with app.app_context():
# Create costs
amounts = [Decimal('100.00'), Decimal('250.50'), Decimal('75.25')]
costs = [
ProjectCost(
project_id=test_project,
user_id=test_user,
description=f'Cost {i}',
category='materials',
amount=amount,
cost_date=date.today()
)
for i, amount in enumerate(amounts)
]
db.session.add_all(costs)
db.session.commit()
# Get total
total = ProjectCost.get_total_costs(test_project)
expected = sum(amounts)
assert abs(total - float(expected)) < 0.01
def test_get_uninvoiced_costs(self, app, test_project, test_user, test_invoice):
"""Test retrieving uninvoiced billable costs."""
with app.app_context():
# Create mix of invoiced and uninvoiced costs
cost1 = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Uninvoiced cost',
category='materials',
amount=Decimal('100.00'),
cost_date=date.today(),
billable=True
)
cost2 = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Invoiced cost',
category='materials',
amount=Decimal('200.00'),
cost_date=date.today(),
billable=True
)
cost3 = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Non-billable cost',
category='materials',
amount=Decimal('50.00'),
cost_date=date.today(),
billable=False
)
db.session.add_all([cost1, cost2, cost3])
db.session.commit()
# Mark cost2 as invoiced
cost2.mark_as_invoiced(test_invoice)
db.session.commit()
# Get uninvoiced
uninvoiced = ProjectCost.get_uninvoiced_costs(test_project)
assert len(uninvoiced) == 1
assert uninvoiced[0].description == 'Uninvoiced cost'
def test_get_costs_by_category(self, app, test_project, test_user):
"""Test grouping costs by category."""
with app.app_context():
# Create costs in different categories
categories = ['travel', 'travel', 'materials', 'equipment', 'materials']
amounts = [Decimal('100.00'), Decimal('150.00'), Decimal('50.00'),
Decimal('500.00'), Decimal('75.00')]
costs = [
ProjectCost(
project_id=test_project,
user_id=test_user,
description=f'Cost {i}',
category=category,
amount=amount,
cost_date=date.today()
)
for i, (category, amount) in enumerate(zip(categories, amounts))
]
db.session.add_all(costs)
db.session.commit()
# Get by category
by_category = ProjectCost.get_costs_by_category(test_project)
# Should have 3 categories
assert len(by_category) == 3
# Find travel category
travel = next(c for c in by_category if c['category'] == 'travel')
assert travel['count'] == 2
assert abs(travel['total_amount'] - 250.00) < 0.01
class TestProjectCostConstraints:
"""Test database constraints and data integrity."""
def test_cannot_create_cost_without_project(self, app, test_user):
"""Test that project_id is required."""
with app.app_context():
cost = ProjectCost(
project_id=None,
user_id=test_user,
description='Test cost',
category='materials',
amount=Decimal('100.00'),
cost_date=date.today()
)
db.session.add(cost)
with pytest.raises(Exception): # Should raise IntegrityError
db.session.commit()
db.session.rollback()
def test_cannot_create_cost_without_user(self, app, test_project):
"""Test that user_id is required."""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=None,
description='Test cost',
category='materials',
amount=Decimal('100.00'),
cost_date=date.today()
)
db.session.add(cost)
with pytest.raises(Exception): # Should raise IntegrityError
db.session.commit()
db.session.rollback()
def test_cascade_delete_with_project(self, app, test_client, test_user):
"""Test that costs are deleted when project is deleted."""
with app.app_context():
# Create project and cost
project = Project(
name='Temp Project',
client_id=test_client,
description='Temporary project'
)
db.session.add(project)
db.session.commit()
project_id = project.id
cost = ProjectCost(
project_id=project_id,
user_id=test_user,
description='Test cost',
category='materials',
amount=Decimal('100.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
cost_id = cost.id
# Delete project
db.session.delete(project)
db.session.commit()
# Cost should be deleted
deleted_cost = db.session.get(ProjectCost, cost_id)
assert deleted_cost is None
# Smoke Tests
class TestProjectCostSmokeTests:
"""Basic smoke tests to ensure ProjectCost functionality works."""
def test_project_cost_creation_smoke(self, app, test_project, test_user):
"""Smoke test: Can we create a project cost?"""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Smoke test cost',
category='materials',
amount=Decimal('99.99'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
assert cost.id is not None
def test_project_cost_query_smoke(self, app, test_project, test_user):
"""Smoke test: Can we query project costs?"""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Query smoke test',
category='travel',
amount=Decimal('200.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
costs = ProjectCost.query.filter_by(project_id=test_project).all()
assert len(costs) > 0
def test_project_cost_relationship_smoke(self, app, test_project, test_user):
"""Smoke test: Do relationships work?"""
with app.app_context():
cost = ProjectCost(
project_id=test_project,
user_id=test_user,
description='Relationship smoke test',
category='equipment',
amount=Decimal('500.00'),
cost_date=date.today()
)
db.session.add(cost)
db.session.commit()
# Refresh to load relationships
cost = db.session.get(ProjectCost, cost.id)
project = db.session.get(Project, test_project)
user = db.session.get(User, test_user)
assert cost.project is not None
assert cost.user is not None
assert cost in project.costs.all()
assert cost in user.project_costs.all()