Files
TimeTracker/tests/test_payment_model.py
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

399 lines
13 KiB
Python

"""Tests for Payment model"""
import pytest
from datetime import datetime, date, timedelta
from decimal import Decimal
from app import db, create_app
from sqlalchemy.pool import StaticPool
from app.models import Payment, Invoice, User, Project, Client
from factories import UserFactory, ClientFactory, ProjectFactory, InvoiceFactory, PaymentFactory
@pytest.fixture
def app():
"""Isolated app for payment model tests using in-memory SQLite to avoid file locks on Windows."""
app = create_app(
{
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite://",
"WTF_CSRF_ENABLED": False,
"SQLALCHEMY_ENGINE_OPTIONS": {
"connect_args": {"check_same_thread": False, "timeout": 30},
"poolclass": StaticPool,
},
"SQLALCHEMY_SESSION_OPTIONS": {"expire_on_commit": False},
}
)
with app.app_context():
db.create_all()
try:
# Improve SQLite concurrency behavior
db.session.execute("PRAGMA journal_mode=WAL;")
db.session.execute("PRAGMA synchronous=NORMAL;")
db.session.execute("PRAGMA busy_timeout=30000;")
db.session.commit()
except Exception:
db.session.rollback()
try:
yield app
finally:
db.session.remove()
db.drop_all()
try:
db.engine.dispose()
except Exception:
pass
@pytest.fixture
def test_user(app):
"""Create a test user"""
with app.app_context():
user = UserFactory()
yield user
@pytest.fixture
def test_client(app):
"""Create a test client"""
with app.app_context():
client = ClientFactory()
yield client
@pytest.fixture
def test_project(app, test_client, test_user):
"""Create a test project"""
with app.app_context():
project = ProjectFactory(client_id=test_client.id, billable=True, hourly_rate=Decimal("100.00"))
yield project
@pytest.fixture
def test_invoice(app, test_project, test_user, test_client):
"""Create a test invoice"""
with app.app_context():
invoice = InvoiceFactory(
project_id=test_project.id,
client_id=test_client.id,
created_by=test_user.id,
client_name="Test Client",
due_date=(date.today() + timedelta(days=30)),
)
# Ensure non-zero totals for payment-related assertions
invoice.subtotal = Decimal("1000.00")
invoice.tax_rate = Decimal("21.00")
invoice.tax_amount = Decimal("210.00")
invoice.total_amount = Decimal("1210.00")
db.session.add(invoice)
db.session.commit()
yield invoice
class TestPaymentModel:
"""Test Payment model functionality"""
def test_create_payment(self, app, test_invoice, test_user):
"""Test creating a payment"""
with app.app_context():
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("500.00"),
currency="EUR",
payment_date=date.today(),
method="bank_transfer",
reference="REF-12345",
notes="Test payment",
status="completed",
received_by=test_user.id,
)
db.session.add(payment)
db.session.commit()
# Verify payment was created
assert payment.id is not None
assert payment.amount == Decimal("500.00")
assert payment.currency == "EUR"
assert payment.method == "bank_transfer"
assert payment.status == "completed"
# Cleanup
db.session.delete(payment)
db.session.commit()
def test_payment_calculate_net_amount_without_fee(self, app, test_invoice):
"""Test calculating net amount without gateway fee"""
with app.app_context():
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("500.00"),
currency="EUR",
payment_date=date.today(),
status="completed",
)
payment.calculate_net_amount()
assert payment.net_amount == Decimal("500.00")
# Cleanup (not in DB yet, so no cleanup needed)
def test_payment_calculate_net_amount_with_fee(self, app, test_invoice):
"""Test calculating net amount with gateway fee"""
with app.app_context():
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("500.00"),
currency="EUR",
payment_date=date.today(),
gateway_fee=Decimal("15.00"),
status="completed",
)
payment.calculate_net_amount()
assert payment.net_amount == Decimal("485.00")
def test_payment_to_dict(self, app, test_invoice, test_user):
"""Test converting payment to dictionary"""
with app.app_context():
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("500.00"),
currency="EUR",
payment_date=date.today(),
method="bank_transfer",
reference="REF-12345",
notes="Test payment",
status="completed",
received_by=test_user.id,
gateway_fee=Decimal("15.00"),
# created_at/updated_at set by defaults; no need to override
)
payment.calculate_net_amount()
db.session.add(payment)
db.session.commit()
payment_dict = payment.to_dict()
assert payment_dict["invoice_id"] == test_invoice.id
assert payment_dict["amount"] == 500.0
assert payment_dict["currency"] == "EUR"
assert payment_dict["method"] == "bank_transfer"
assert payment_dict["reference"] == "REF-12345"
assert payment_dict["status"] == "completed"
assert payment_dict["gateway_fee"] == 15.0
assert payment_dict["net_amount"] == 485.0
# Cleanup
db.session.delete(payment)
db.session.commit()
def test_payment_relationship_with_invoice(self, app, test_invoice):
"""Test payment relationship with invoice"""
with app.app_context():
# Re-query invoice to attach to current session
from app.models.invoice import Invoice
invoice_in_session = Invoice.query.get(test_invoice.id)
payment = PaymentFactory(
invoice_id=invoice_in_session.id,
amount=Decimal("500.00"),
currency="EUR",
payment_date=date.today(),
status="completed",
)
db.session.add(payment)
db.session.commit()
# Refresh invoice to get updated relationships
db.session.refresh(invoice_in_session)
# Verify relationship
assert payment.invoice == invoice_in_session
assert payment in invoice_in_session.payments
# Cleanup
db.session.delete(payment)
db.session.commit()
def test_payment_relationship_with_user(self, app, test_invoice, test_user):
"""Test payment relationship with user (receiver)"""
with app.app_context():
# Re-query user to attach to current session
from app.models.user import User
user_in_session = User.query.get(test_user.id)
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("500.00"),
currency="EUR",
payment_date=date.today(),
status="completed",
received_by=user_in_session.id,
)
db.session.add(payment)
db.session.commit()
# Refresh user to get updated relationships
db.session.refresh(user_in_session)
# Verify relationship
assert payment.receiver == user_in_session
assert payment in user_in_session.received_payments
# Cleanup
db.session.delete(payment)
db.session.commit()
def test_payment_repr(self, app, test_invoice):
"""Test payment string representation"""
with app.app_context():
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("500.00"),
currency="EUR",
payment_date=date.today(),
status="completed",
)
repr_str = repr(payment)
assert "Payment" in repr_str
assert "500.00" in repr_str
assert "EUR" in repr_str
def test_multiple_payments_for_invoice(self, app, test_invoice):
"""Test multiple payments for a single invoice"""
with app.app_context():
# Re-query invoice to attach to current session
from app.models.invoice import Invoice
invoice_in_session = Invoice.query.get(test_invoice.id)
payment1 = PaymentFactory(
invoice_id=invoice_in_session.id,
amount=Decimal("300.00"),
currency="EUR",
payment_date=date.today(),
status="completed",
)
payment2 = PaymentFactory(
invoice_id=invoice_in_session.id,
amount=Decimal("200.00"),
currency="EUR",
payment_date=date.today() + timedelta(days=1),
status="completed",
)
db.session.add_all([payment1, payment2])
db.session.commit()
# Refresh invoice to get updated relationships
db.session.refresh(invoice_in_session)
# Verify both payments are associated with invoice
assert invoice_in_session.payments.count() == 2
# Cleanup
db.session.delete(payment1)
db.session.delete(payment2)
db.session.commit()
def test_payment_status_values(self, app, test_invoice):
"""Test different payment status values"""
with app.app_context():
statuses = ["completed", "pending", "failed", "refunded"]
for status in statuses:
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("100.00"),
currency="EUR",
payment_date=date.today(),
status=status,
)
db.session.add(payment)
db.session.commit()
assert payment.status == status
# Cleanup
db.session.delete(payment)
db.session.commit()
class TestPaymentIntegration:
"""Test Payment model integration with Invoice"""
def test_invoice_updates_with_payment(self, app, test_invoice):
"""Test that invoice updates correctly when payment is added"""
with app.app_context():
# Initial state
assert test_invoice.amount_paid == Decimal("0")
assert test_invoice.payment_status == "unpaid"
# Add payment
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=Decimal("605.00"), # Half of total
currency="EUR",
payment_date=date.today(),
status="completed",
)
db.session.add(payment)
# Update invoice manually (this would be done by route logic)
test_invoice.amount_paid = (test_invoice.amount_paid or Decimal("0")) + payment.amount
test_invoice.update_payment_status()
db.session.commit()
# Verify invoice was updated
assert test_invoice.amount_paid == Decimal("605.00")
assert test_invoice.payment_status == "partially_paid"
# Cleanup
db.session.delete(payment)
test_invoice.amount_paid = Decimal("0")
test_invoice.update_payment_status()
db.session.commit()
def test_invoice_fully_paid_with_payments(self, app, test_invoice):
"""Test that invoice becomes fully paid when total payments equal total amount"""
with app.app_context():
# Add payments that equal total amount
payment = PaymentFactory(
invoice_id=test_invoice.id,
amount=test_invoice.total_amount,
currency="EUR",
payment_date=date.today(),
status="completed",
)
db.session.add(payment)
# Update invoice manually (this would be done by route logic)
test_invoice.amount_paid = payment.amount
test_invoice.update_payment_status()
db.session.commit()
# Verify invoice is fully paid
assert test_invoice.payment_status == "fully_paid"
assert test_invoice.is_paid is True
# Cleanup
db.session.delete(payment)
test_invoice.amount_paid = Decimal("0")
test_invoice.update_payment_status()
db.session.commit()