Files
TimeTracker/tests/test_expenses.py
T
Dries Peeters 90dde470da style: standardize code formatting and normalize line endings
- 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.
2025-11-28 20:05:37 +01:00

708 lines
23 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
from factories import InvoiceFactory
from factories import ExpenseFactory
@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 = InvoiceFactory(
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.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 = ExpenseFactory(
user_id=test_user,
title="Travel Expense",
category="travel",
amount=Decimal("150.00"),
expense_date=date.today(),
billable=False,
reimbursable=True,
)
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 = ExpenseFactory(
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,
)
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"