Files
TimeTracker/tests/test_expenses.py
Dries Peeters a02fec04c8 feat: Add comprehensive expense tracking system
Implement a complete expense tracking feature that allows users to record,
manage, approve, and track business expenses with full integration into
existing project management and invoicing systems.

Features:
- Create and manage expenses with detailed information (amount, category,
  vendor, receipts, tax tracking)
- Multi-currency support (EUR, USD, GBP, CHF)
- Approval workflow with admin oversight (pending → approved → rejected)
- Reimbursement tracking and status management
- Billable expense flagging for client invoicing
- Receipt file upload and attachment
- Project and client association with auto-client selection
- Tag-based organization and advanced filtering
- CSV export functionality
- Analytics dashboard with category breakdowns
- API endpoints for programmatic access

Database Changes:
- Add expenses table with comprehensive schema
- Create Alembic migration (029_add_expenses_table.py)
- Add composite indexes for query performance
- Implement proper foreign key constraints and cascading

Routes & Templates:
- Add expenses blueprint with 14 endpoints (CRUD, approval, export, API)
- Create 4 responsive templates (list, form, view, dashboard)
- Implement advanced filtering (status, category, project, client, date range)
- Add permission-based access control (user vs admin)
- Integrate receipt file upload handling

User Experience:
- Add "Expenses" to Insights navigation menu
- Auto-populate client when project is selected
- Provide visual feedback for auto-selections
- Display summary statistics and analytics
- Implement pagination and search functionality

Testing & Documentation:
- Add 40+ comprehensive tests covering models, methods, and workflows
- Create complete user documentation (docs/EXPENSE_TRACKING.md)
- Add API documentation and examples
- Include troubleshooting guide and best practices

Integration:
- Link expenses to projects for cost tracking
- Associate with clients for billing purposes
- Connect billable expenses to invoicing system
- Add PostHog event tracking for analytics
- Implement structured logging for audit trail

Security:
- Role-based access control (users see only their expenses)
- Admin-only approval and reimbursement actions
- CSRF protection and file upload validation
- Proper permission checks on all operations

This implementation follows existing codebase patterns and includes full
test coverage, documentation, and database migrations per project standards.
2025-10-24 10:42:51 +02:00

717 lines
24 KiB
Python

"""
Comprehensive tests for Expense model and related functionality.
This module tests:
- Expense model creation and validation
- Relationships with User, Project, Client, and Invoice models
- Query methods (get_expenses, get_total_expenses, etc.)
- Approval and reimbursement workflows
- 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, Expense
@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():
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 TestExpenseModel:
"""Test Expense model creation, validation, and basic operations."""
def test_create_expense(self, app, test_user):
"""Test creating a basic expense."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Travel Expense',
category='travel',
amount=Decimal('150.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.id is not None
assert expense.title == 'Travel Expense'
assert expense.category == 'travel'
assert expense.amount == Decimal('150.00')
assert expense.currency_code == 'EUR'
assert expense.status == 'pending'
assert expense.billable is False
assert expense.reimbursable is True
def test_create_expense_with_all_fields(self, app, test_user, test_project, test_client):
"""Test creating an expense with all optional fields."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Conference Travel',
category='travel',
amount=Decimal('500.00'),
expense_date=date.today(),
description='Flight and hotel for conference',
project_id=test_project,
client_id=test_client,
currency_code='USD',
tax_amount=Decimal('50.00'),
payment_method='credit_card',
payment_date=date.today(),
vendor='Airline Inc',
receipt_number='REC-2024-001',
notes='Business class flight',
tags='conference,travel,urgent',
billable=True,
reimbursable=True
)
db.session.add(expense)
db.session.commit()
assert expense.description == 'Flight and hotel for conference'
assert expense.project_id == test_project
assert expense.client_id == test_client
assert expense.currency_code == 'USD'
assert expense.tax_amount == Decimal('50.00')
assert expense.vendor == 'Airline Inc'
assert expense.billable is True
def test_expense_str_representation(self, app, test_user):
"""Test __repr__ method."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Office Supplies',
category='supplies',
amount=Decimal('75.50'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert 'Office Supplies' in str(expense)
assert 'EUR' in str(expense)
def test_expense_timestamps(self, app, test_user):
"""Test automatic timestamp creation."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='other',
amount=Decimal('10.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.created_at is not None
assert expense.updated_at is not None
assert isinstance(expense.created_at, datetime)
assert isinstance(expense.updated_at, datetime)
class TestExpenseProperties:
"""Test Expense computed properties."""
def test_total_amount_property(self, app, test_user):
"""Test total_amount property including tax."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
tax_amount=Decimal('10.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.total_amount == Decimal('110.00')
def test_tag_list_property(self, app, test_user):
"""Test tag_list property parsing."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
tags='urgent, client-meeting, conference'
)
db.session.add(expense)
db.session.commit()
tags = expense.tag_list
assert len(tags) == 3
assert 'urgent' in tags
assert 'client-meeting' in tags
assert 'conference' in tags
def test_is_approved_property(self, app, test_user, test_admin):
"""Test is_approved property."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
# Initially not approved
assert expense.is_approved is False
# Approve
expense.approve(test_admin)
db.session.commit()
assert expense.is_approved is True
def test_is_reimbursed_property(self, app, test_user):
"""Test is_reimbursed property."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.is_reimbursed is False
expense.mark_as_reimbursed()
db.session.commit()
assert expense.is_reimbursed is True
class TestExpenseRelationships:
"""Test Expense relationships with other models."""
def test_user_relationship(self, app, test_user):
"""Test relationship with User model."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
user = db.session.get(User, test_user)
assert expense.user is not None
assert expense.user.id == test_user
assert expense in user.expenses.all()
def test_project_relationship(self, app, test_user, test_project):
"""Test relationship with Project model."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
project_id=test_project
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
project = db.session.get(Project, test_project)
assert expense.project is not None
assert expense.project.id == test_project
assert expense in project.expenses.all()
def test_client_relationship(self, app, test_user, test_client):
"""Test relationship with Client model."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
client_id=test_client
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
client = db.session.get(Client, test_client)
assert expense.client is not None
assert expense.client.id == test_client
assert expense in client.expenses.all()
class TestExpenseMethods:
"""Test Expense instance and class methods."""
def test_approve_method(self, app, test_user, test_admin):
"""Test approving an expense."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expense.approve(test_admin, notes='Approved for reimbursement')
db.session.commit()
assert expense.status == 'approved'
assert expense.approved_by == test_admin
assert expense.approved_at is not None
def test_reject_method(self, app, test_user, test_admin):
"""Test rejecting an expense."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expense.reject(test_admin, 'Receipt not provided')
db.session.commit()
assert expense.status == 'rejected'
assert expense.approved_by == test_admin
assert expense.rejection_reason == 'Receipt not provided'
def test_mark_as_reimbursed(self, app, test_user, test_admin):
"""Test marking expense as reimbursed."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
# Approve first
expense.approve(test_admin)
db.session.commit()
# Mark as reimbursed
expense.mark_as_reimbursed()
db.session.commit()
assert expense.reimbursed is True
assert expense.reimbursed_at is not None
assert expense.status == 'reimbursed'
def test_mark_as_invoiced(self, app, test_user, test_invoice):
"""Test marking expense as invoiced."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
billable=True
)
db.session.add(expense)
db.session.commit()
expense.mark_as_invoiced(test_invoice)
db.session.commit()
assert expense.invoiced is True
assert expense.invoice_id == test_invoice
def test_to_dict(self, app, test_user):
"""Test converting expense to dictionary."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
tax_amount=Decimal('10.00'),
expense_date=date.today(),
description='Test description'
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
expense_dict = expense.to_dict()
assert expense_dict['id'] == expense.id
assert expense_dict['user_id'] == test_user
assert expense_dict['title'] == 'Test Expense'
assert expense_dict['category'] == 'travel'
assert expense_dict['amount'] == 100.00
assert expense_dict['tax_amount'] == 10.00
assert expense_dict['total_amount'] == 110.00
assert 'created_at' in expense_dict
class TestExpenseQueries:
"""Test Expense query class methods."""
def test_get_expenses(self, app, test_user):
"""Test retrieving expenses."""
with app.app_context():
expenses = [
Expense(
user_id=test_user,
title=f'Expense {i}',
category='travel',
amount=Decimal(f'{100 + i * 10}.00'),
expense_date=date.today() - timedelta(days=i)
)
for i in range(5)
]
db.session.add_all(expenses)
db.session.commit()
retrieved = Expense.get_expenses(user_id=test_user)
assert len(retrieved) == 5
# Should be ordered by expense_date desc
assert retrieved[0].title == 'Expense 0'
def test_get_expenses_by_status(self, app, test_user, test_admin):
"""Test filtering expenses by status."""
with app.app_context():
# Create expenses with different statuses
exp1 = Expense(
user_id=test_user,
title='Pending Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
exp2 = Expense(
user_id=test_user,
title='Approved Expense',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today()
)
db.session.add_all([exp1, exp2])
db.session.commit()
exp2.approve(test_admin)
db.session.commit()
pending = Expense.get_expenses(user_id=test_user, status='pending')
assert len(pending) == 1
assert pending[0].title == 'Pending Expense'
approved = Expense.get_expenses(user_id=test_user, status='approved')
assert len(approved) == 1
assert approved[0].title == 'Approved Expense'
def test_get_total_expenses(self, app, test_user):
"""Test calculating total expenses."""
with app.app_context():
amounts = [Decimal('100.00'), Decimal('250.50'), Decimal('75.25')]
taxes = [Decimal('10.00'), Decimal('25.00'), Decimal('7.50')]
expenses = [
Expense(
user_id=test_user,
title=f'Expense {i}',
category='travel',
amount=amount,
tax_amount=tax,
expense_date=date.today()
)
for i, (amount, tax) in enumerate(zip(amounts, taxes))
]
db.session.add_all(expenses)
db.session.commit()
total = Expense.get_total_expenses(user_id=test_user, include_tax=True)
expected = sum(amounts) + sum(taxes)
assert abs(total - float(expected)) < 0.01
def test_get_expenses_by_category(self, app, test_user):
"""Test grouping expenses by category."""
with app.app_context():
categories = ['travel', 'travel', 'meals', 'supplies', 'meals']
amounts = [Decimal('100.00'), Decimal('150.00'), Decimal('50.00'),
Decimal('75.00'), Decimal('60.00')]
expenses = [
Expense(
user_id=test_user,
title=f'Expense {i}',
category=category,
amount=amount,
expense_date=date.today()
)
for i, (category, amount) in enumerate(zip(categories, amounts))
]
db.session.add_all(expenses)
db.session.commit()
by_category = Expense.get_expenses_by_category(user_id=test_user)
assert len(by_category) == 3
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
def test_get_pending_approvals(self, app, test_user):
"""Test retrieving pending expenses."""
with app.app_context():
exp1 = Expense(
user_id=test_user,
title='Pending 1',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
status='pending'
)
exp2 = Expense(
user_id=test_user,
title='Pending 2',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today(),
status='pending'
)
db.session.add_all([exp1, exp2])
db.session.commit()
pending = Expense.get_pending_approvals(user_id=test_user)
assert len(pending) == 2
def test_get_uninvoiced_expenses(self, app, test_user, test_admin, test_project):
"""Test retrieving uninvoiced billable expenses."""
with app.app_context():
exp1 = Expense(
user_id=test_user,
title='Billable Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
billable=True,
project_id=test_project
)
exp2 = Expense(
user_id=test_user,
title='Non-billable Expense',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today(),
billable=False,
project_id=test_project
)
db.session.add_all([exp1, exp2])
db.session.commit()
# Approve both
exp1.approve(test_admin)
exp2.approve(test_admin)
db.session.commit()
uninvoiced = Expense.get_uninvoiced_expenses(project_id=test_project)
assert len(uninvoiced) == 1
assert uninvoiced[0].title == 'Billable Expense'
class TestExpenseConstraints:
"""Test database constraints and data integrity."""
def test_cannot_create_expense_without_user(self, app):
"""Test that user_id is required."""
with app.app_context():
expense = Expense(
user_id=None,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
with pytest.raises(Exception):
db.session.commit()
db.session.rollback()
# Smoke Tests
class TestExpenseSmokeTests:
"""Basic smoke tests to ensure Expense functionality works."""
def test_expense_creation_smoke(self, app, test_user):
"""Smoke test: Can we create an expense?"""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Smoke Test Expense',
category='travel',
amount=Decimal('99.99'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.id is not None
def test_expense_query_smoke(self, app, test_user):
"""Smoke test: Can we query expenses?"""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Query Smoke Test',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expenses = Expense.query.filter_by(user_id=test_user).all()
assert len(expenses) > 0
def test_expense_workflow_smoke(self, app, test_user, test_admin):
"""Smoke test: Does the full approval workflow work?"""
with app.app_context():
# Create expense
expense = Expense(
user_id=test_user,
title='Workflow Test',
category='travel',
amount=Decimal('500.00'),
expense_date=date.today(),
reimbursable=True
)
db.session.add(expense)
db.session.commit()
# Approve
expense.approve(test_admin)
db.session.commit()
assert expense.status == 'approved'
# Reimburse
expense.mark_as_reimbursed()
db.session.commit()
assert expense.status == 'reimbursed'