mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-04 03:09:50 -05:00
90dde470da
- 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.
708 lines
23 KiB
Python
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"
|