Files
TimeTracker/tests/test_invoice_expenses.py
Dries Peeters bcab2d764f test changes
2025-10-29 18:42:10 +01:00

255 lines
8.3 KiB
Python

"""
Tests for invoice expense functionality
"""
import pytest
from datetime import datetime, timedelta, date
from decimal import Decimal
from app import db
from app.models import Invoice, InvoiceItem, Expense, User, Project, Client
@pytest.fixture
def test_user(app):
"""Create a test user"""
user = User(username='testuser', email='test@example.com', role='admin')
user.set_password('testpass')
db.session.add(user)
db.session.commit()
yield user
db.session.delete(user)
db.session.commit()
@pytest.fixture
def test_client(app):
"""Create a test client"""
client = Client(name='Test Client', email='client@example.com')
db.session.add(client)
db.session.commit()
yield client
db.session.delete(client)
db.session.commit()
@pytest.fixture
def test_project(app, test_client):
"""Create a test project"""
project = Project(
name='Test Project',
client_id=test_client.id,
billable=True,
hourly_rate=Decimal('100.00')
)
db.session.add(project)
db.session.commit()
yield project
db.session.delete(project)
db.session.commit()
@pytest.fixture
def test_invoice(app, test_user, test_project, test_client):
"""Create a test invoice"""
invoice = Invoice(
invoice_number='INV-TEST-001',
project_id=test_project.id,
client_name=test_client.name,
client_id=test_client.id,
due_date=date.today() + timedelta(days=30),
created_by=test_user.id,
tax_rate=Decimal('10.00')
)
db.session.add(invoice)
db.session.commit()
yield invoice
db.session.delete(invoice)
db.session.commit()
@pytest.fixture
def test_expense(app, test_user, test_project):
"""Create a test expense"""
expense = Expense(
user_id=test_user.id,
project_id=test_project.id,
title='Travel Expense',
description='Client meeting travel',
category='travel',
amount=Decimal('150.00'),
tax_amount=Decimal('15.00'),
expense_date=date.today(),
billable=True,
vendor='Taxi Service',
status='approved'
)
db.session.add(expense)
db.session.commit()
yield expense
db.session.delete(expense)
db.session.commit()
class TestInvoiceExpenseIntegration:
"""Test invoice expense integration"""
def test_link_expense_to_invoice(self, app, test_invoice, test_expense):
"""Test linking an expense to an invoice"""
# Mark expense as invoiced
test_expense.mark_as_invoiced(test_invoice.id)
db.session.commit()
# Verify the expense is linked
assert test_expense.invoiced is True
assert test_expense.invoice_id == test_invoice.id
assert test_expense.is_invoiced is True
# Verify the invoice has the expense
assert test_expense in test_invoice.expenses.all()
def test_unlink_expense_from_invoice(self, app, test_invoice, test_expense):
"""Test unlinking an expense from an invoice"""
# Mark expense as invoiced first
test_expense.mark_as_invoiced(test_invoice.id)
db.session.commit()
# Then unmark it
test_expense.unmark_as_invoiced()
db.session.commit()
# Verify the expense is unlinked
assert test_expense.invoiced is False
assert test_expense.invoice_id is None
assert test_expense.is_invoiced is False
def test_calculate_totals_with_expenses(self, app, test_invoice, test_expense):
"""Test that invoice totals include expenses"""
# Add an invoice item
item = InvoiceItem(
invoice_id=test_invoice.id,
description='Development Work',
quantity=Decimal('10.00'),
unit_price=Decimal('100.00')
)
db.session.add(item)
# Link expense to invoice
test_expense.mark_as_invoiced(test_invoice.id)
db.session.commit()
# Calculate totals
test_invoice.calculate_totals()
db.session.commit()
# Expected: 1000 (item) + 165 (expense with tax) = 1165
# Then apply 10% tax on subtotal: 1165 * 1.10 = 1281.50
expected_subtotal = Decimal('1165.00') # 1000 + 165
expected_tax = Decimal('116.50') # 1165 * 0.10
expected_total = Decimal('1281.50') # 1165 + 116.50
assert test_invoice.subtotal == expected_subtotal
assert test_invoice.tax_amount == expected_tax
assert test_invoice.total_amount == expected_total
def test_uninvoiced_expenses_query(self, app, test_expense, test_project):
"""Test querying for uninvoiced expenses"""
# The expense should be uninvoiced initially
uninvoiced = Expense.get_uninvoiced_expenses(project_id=test_project.id)
assert len(uninvoiced) > 0
assert test_expense in uninvoiced
def test_uninvoiced_expenses_excludes_invoiced(self, app, test_invoice, test_expense, test_project):
"""Test that invoiced expenses are excluded from uninvoiced query"""
# Mark expense as invoiced
test_expense.mark_as_invoiced(test_invoice.id)
db.session.commit()
# Query for uninvoiced expenses
uninvoiced = Expense.get_uninvoiced_expenses(project_id=test_project.id)
# The expense should not be in the list
assert test_expense not in uninvoiced
def test_expense_in_pdf_export(self, app, test_invoice, test_expense):
"""Test that expenses are included in PDF export (template test)"""
# Link expense to invoice
test_expense.mark_as_invoiced(test_invoice.id)
db.session.commit()
# Verify the expense is accessible from the invoice
expenses = test_invoice.expenses.all()
assert len(expenses) == 1
assert expenses[0].id == test_expense.id
def test_multiple_expenses_on_invoice(self, app, test_invoice, test_user, test_project):
"""Test that multiple expenses can be added to an invoice"""
# Create multiple expenses
expense1 = Expense(
user_id=test_user.id,
project_id=test_project.id,
title='Travel Expense 1',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
billable=True
)
expense2 = Expense(
user_id=test_user.id,
project_id=test_project.id,
title='Meals Expense',
category='meals',
amount=Decimal('50.00'),
expense_date=date.today(),
billable=True
)
db.session.add_all([expense1, expense2])
db.session.commit()
# Link both to invoice
expense1.mark_as_invoiced(test_invoice.id)
expense2.mark_as_invoiced(test_invoice.id)
db.session.commit()
# Verify both are linked
expenses = test_invoice.expenses.all()
assert len(expenses) == 2
# Calculate totals
test_invoice.calculate_totals()
db.session.commit()
# Expected: 150 (expenses) * 1.10 (tax) = 165
expected_subtotal = Decimal('150.00')
expected_tax = Decimal('15.00')
expected_total = Decimal('165.00')
assert test_invoice.subtotal == expected_subtotal
assert test_invoice.tax_amount == expected_tax
assert test_invoice.total_amount == expected_total
# Cleanup
db.session.delete(expense1)
db.session.delete(expense2)
db.session.commit()
class TestExpenseProperties:
"""Test expense model properties"""
def test_expense_total_amount_includes_tax(self, app, test_expense):
"""Test that expense total_amount property includes tax"""
# Expense amount is 150, tax is 15
assert test_expense.total_amount == Decimal('165.00')
def test_expense_is_invoiced_property(self, app, test_invoice, test_expense):
"""Test the is_invoiced property"""
# Initially not invoiced
assert test_expense.is_invoiced is False
# After marking as invoiced
test_expense.mark_as_invoiced(test_invoice.id)
db.session.commit()
assert test_expense.is_invoiced is True