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

449 lines
17 KiB
Python

"""
Tests for invoice email sending functionality
"""
import pytest
from unittest.mock import patch, MagicMock
from datetime import datetime, date, timedelta
from decimal import Decimal
from flask import current_app
from app import db
from app.models import Invoice, InvoiceEmail, User, Settings, Client, Project
from app.utils.email import send_invoice_email
from factories import UserFactory, ClientFactory, ProjectFactory, InvoiceFactory, InvoiceItemFactory
@pytest.fixture
def test_user(app):
"""Create a test user"""
user = UserFactory(username="testuser", role="user")
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def test_client(app):
"""Create a test client"""
client = ClientFactory(name="Test Client", email="client@test.com")
db.session.commit()
return client
@pytest.fixture
def test_project(app, test_client):
"""Create a test project"""
project = ProjectFactory(
name="Test Project", client_id=test_client.id, billable=True, hourly_rate=Decimal("100.00")
)
db.session.commit()
return project
@pytest.fixture
def test_invoice(app, test_user, test_project, test_client):
"""Create a test invoice with items"""
invoice = InvoiceFactory(
invoice_number="INV-2024-001",
project_id=test_project.id,
client_id=test_client.id,
client_name=test_client.name,
client_email=test_client.email,
due_date=date.today() + timedelta(days=30),
created_by=test_user.id,
status="draft",
subtotal=Decimal("1000.00"),
tax_rate=Decimal("20.00"),
tax_amount=Decimal("200.00"),
total_amount=Decimal("1200.00"),
currency_code="EUR",
)
db.session.commit()
# Add invoice item
item = InvoiceItemFactory(
invoice_id=invoice.id,
description="Test Service",
quantity=Decimal("10.00"),
unit_price=Decimal("100.00"),
total_amount=Decimal("1000.00"),
)
db.session.commit()
return invoice
@pytest.fixture
def mock_pdf_generator():
"""Mock PDF generator"""
with patch("app.utils.email.InvoicePDFGenerator") as mock_gen:
mock_instance = MagicMock()
mock_instance.generate_pdf.return_value = b"fake_pdf_bytes"
mock_gen.return_value = mock_instance
yield mock_instance
@pytest.fixture
def mock_mail_send():
"""Mock mail.send"""
with patch("app.utils.email.mail.send") as mock_send:
yield mock_send
class TestSendInvoiceEmail:
"""Tests for send_invoice_email function"""
def test_send_invoice_email_success(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
"""Test successfully sending an invoice email"""
with app.app_context():
# Configure mail server
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
success, invoice_email, message = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
assert success is True
assert invoice_email is not None
assert invoice_email.recipient_email == "client@test.com"
assert invoice_email.invoice_id == test_invoice.id
assert invoice_email.sent_by == test_user.id
assert invoice_email.status == "sent"
assert "successfully" in message.lower()
assert mock_mail_send.called
# Verify invoice status was updated
db.session.refresh(test_invoice)
assert test_invoice.status == "sent"
def test_send_invoice_email_with_custom_message(
self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send
):
"""Test sending invoice email with custom message"""
with app.app_context():
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
custom_message = "Thank you for your business!"
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email="client@test.com",
sender_user=test_user,
custom_message=custom_message,
)
assert success is True
assert invoice_email is not None
# Verify the message was sent (check mail.send was called with message containing custom text)
assert mock_mail_send.called
def test_send_invoice_email_pdf_generation_failure(self, app, test_invoice, test_user, mock_mail_send):
"""Test handling PDF generation failure"""
with app.app_context():
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
# Mock PDF generator to fail
with patch("app.utils.email.InvoicePDFGenerator") as mock_gen:
mock_instance = MagicMock()
mock_instance.generate_pdf.side_effect = Exception("PDF generation failed")
mock_gen.return_value = mock_instance
# Mock fallback generator to also fail
with patch("app.utils.email.InvoicePDFGeneratorFallback") as mock_fallback:
mock_fallback_instance = MagicMock()
mock_fallback_instance.generate_pdf.side_effect = Exception("Fallback failed")
mock_fallback.return_value = mock_fallback_instance
success, invoice_email, message = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
assert success is False
assert invoice_email is None
assert "pdf generation failed" in message.lower() or "failed" in message.lower()
def test_send_invoice_email_no_mail_server(self, app, test_invoice, test_user, mock_pdf_generator):
"""Test sending email when mail server is not configured"""
with app.app_context():
current_app.config["MAIL_SERVER"] = None
success, invoice_email, message = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
# Should still attempt to send but may fail gracefully
# The function should handle this case
assert invoice_email is not None or success is False
def test_send_invoice_email_creates_tracking_record(
self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send
):
"""Test that email tracking record is created"""
with app.app_context():
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
# Count existing records
initial_count = InvoiceEmail.query.filter_by(invoice_id=test_invoice.id).count()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
assert success is True
# Verify record was created
final_count = InvoiceEmail.query.filter_by(invoice_id=test_invoice.id).count()
assert final_count == initial_count + 1
# Verify record details
assert invoice_email.recipient_email == "client@test.com"
assert invoice_email.invoice_id == test_invoice.id
assert invoice_email.sent_by == test_user.id
def test_send_invoice_email_updates_draft_status(
self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send
):
"""Test that draft invoice status is updated to 'sent'"""
with app.app_context():
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
# Ensure invoice is in draft status
test_invoice.status = "draft"
db.session.commit()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
assert success is True
# Verify status was updated
db.session.refresh(test_invoice)
assert test_invoice.status == "sent"
def test_send_invoice_email_does_not_update_non_draft_status(
self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send
):
"""Test that non-draft invoice status is not changed"""
with app.app_context():
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
# Set invoice to 'sent' status
test_invoice.status = "sent"
db.session.commit()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
assert success is True
# Verify status remained 'sent'
db.session.refresh(test_invoice)
assert test_invoice.status == "sent"
def test_send_invoice_email_with_email_template(
self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send
):
"""Test sending invoice email with custom email template"""
with app.app_context():
from app.models import InvoiceTemplate
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
# Create an email template
template = InvoiceTemplate(
name="Test Template",
html="<html><body><h1>Invoice {{ invoice.invoice_number }}</h1></body></html>",
css="body { color: black; }",
)
db.session.add(template)
db.session.commit()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email="client@test.com",
sender_user=test_user,
email_template_id=template.id,
)
assert success is True
assert invoice_email is not None
assert mock_mail_send.called
def test_send_invoice_email_failure_creates_failed_record(self, app, test_invoice, test_user, mock_pdf_generator):
"""Test that failed email sends create a failed tracking record"""
with app.app_context():
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
# Mock mail.send to raise an exception
with patch("app.utils.email.mail.send") as mock_send:
mock_send.side_effect = Exception("SMTP connection failed")
success, invoice_email, message = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
assert success is False
# Should create a failed record
failed_record = InvoiceEmail.query.filter_by(invoice_id=test_invoice.id, status="failed").first()
assert failed_record is not None
assert failed_record.error_message is not None
class TestInvoiceEmailRoutes:
"""Tests for invoice email routes"""
def test_send_invoice_email_route_success(
self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send
):
"""Test the send invoice email route"""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
sess["_fresh"] = True
response = client.post(
f"/invoices/{test_invoice.id}/send-email",
data={"recipient_email": "client@test.com", "csrf_token": "test_token"},
)
# Should return success (may need to handle CSRF token properly in test)
assert response.status_code in [200, 400, 403] # 400/403 if CSRF fails
def test_get_invoice_email_history(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
"""Test getting invoice email history"""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
sess["_fresh"] = True
# First send an email
with client.application.app_context():
from app.utils.email import send_invoice_email
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
send_invoice_email(invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user)
# Then get history
response = client.get(f"/invoices/{test_invoice.id}/email-history")
# Should return success (may need to handle authentication properly)
assert response.status_code in [200, 401, 403]
def test_resend_invoice_email_route(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
"""Test the resend invoice email route"""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
sess["_fresh"] = True
# First send an email to create a record
with client.application.app_context():
from app.utils.email import send_invoice_email
current_app.config["MAIL_SERVER"] = "smtp.test.com"
current_app.config["MAIL_DEFAULT_SENDER"] = "noreply@test.com"
success, invoice_email, _ = send_invoice_email(
invoice=test_invoice, recipient_email="client@test.com", sender_user=test_user
)
if success and invoice_email:
# Then resend it
response = client.post(
f"/invoices/{test_invoice.id}/resend-email/{invoice_email.id}",
data={"recipient_email": "client@test.com", "csrf_token": "test_token"},
)
# Should return success (may need to handle CSRF token properly)
assert response.status_code in [200, 400, 403]
class TestInvoiceEmailModel:
"""Tests for InvoiceEmail model"""
def test_invoice_email_creation(self, app, test_invoice, test_user):
"""Test creating an InvoiceEmail record"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email="client@test.com",
subject="Test Invoice",
sent_by=test_user.id,
)
db.session.add(invoice_email)
db.session.commit()
assert invoice_email.id is not None
assert invoice_email.invoice_id == test_invoice.id
assert invoice_email.recipient_email == "client@test.com"
assert invoice_email.status == "sent"
assert invoice_email.sent_at is not None
def test_invoice_email_mark_opened(self, app, test_invoice, test_user):
"""Test marking email as opened"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email="client@test.com",
subject="Test Invoice",
sent_by=test_user.id,
)
db.session.add(invoice_email)
db.session.commit()
invoice_email.mark_opened()
db.session.commit()
assert invoice_email.status == "opened"
assert invoice_email.opened_at is not None
assert invoice_email.opened_count == 1
def test_invoice_email_mark_failed(self, app, test_invoice, test_user):
"""Test marking email as failed"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email="client@test.com",
subject="Test Invoice",
sent_by=test_user.id,
)
db.session.add(invoice_email)
db.session.commit()
error_message = "SMTP connection failed"
invoice_email.mark_failed(error_message)
db.session.commit()
assert invoice_email.status == "failed"
assert invoice_email.error_message == error_message
def test_invoice_email_to_dict(self, app, test_invoice, test_user):
"""Test converting InvoiceEmail to dictionary"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email="client@test.com",
subject="Test Invoice",
sent_by=test_user.id,
)
db.session.add(invoice_email)
db.session.commit()
email_dict = invoice_email.to_dict()
assert isinstance(email_dict, dict)
assert email_dict["invoice_id"] == test_invoice.id
assert email_dict["recipient_email"] == "client@test.com"
assert email_dict["subject"] == "Test Invoice"
assert email_dict["status"] == "sent"
assert "sent_at" in email_dict
assert "created_at" in email_dict