mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 20:20:30 -06:00
701 lines
24 KiB
Python
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()
|
|
|