Files
TimeTracker/tests/test_invoice_email.py
T
evilguy4000 1b5d019560 feat(admin): persist test recipient and send invoice email template tests
Add Settings.mail_test_recipient (migration 142) and wire Admin → Email Configuration to save, load, and prefill the SMTP test recipient field.

Extract invoice email PDF/HTML building into _build_invoice_email_payload for reuse. New send_invoice_template_test_email sends a real message with subject "Invoice template test: …", reusing production rendering and PDF attachment, without updating invoice status or creating InvoiceEmail records.

Add POST /admin/email-templates/<id>/send-test (optional recipient override, optional invoice_id) and Send test email UI on template view and edit pages. Extend smoke and unit tests.
2026-03-27 06:39:16 +01:00

503 lines
19 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, send_invoice_template_test_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
class TestSendInvoiceTemplateTestEmail:
"""Tests for send_invoice_template_test_email"""
@patch("app.utils.pdf_generator.InvoicePDFGenerator")
def test_send_invoice_template_test_email_success(
self, mock_pdf_class, app, test_invoice, mock_mail_send
):
mock_instance = MagicMock()
mock_instance.generate_pdf.return_value = b"fake_pdf_bytes"
mock_pdf_class.return_value = mock_instance
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"
tmpl = InvoiceTemplate(name="Tpl", html="<p>{{ invoice.invoice_number }}</p>", css="")
db.session.add(tmpl)
db.session.commit()
success, message = send_invoice_template_test_email(
tmpl.id, "preview@example.com", invoice_id=test_invoice.id
)
assert success is True
assert "successfully" in message.lower()
mock_mail_send.assert_called_once()
def test_send_invoice_template_test_email_no_mail_server(self, app, test_invoice):
with app.app_context():
from app.models import InvoiceTemplate
current_app.config["MAIL_SERVER"] = "localhost"
tmpl = InvoiceTemplate(name="Tpl2", html="<p>x</p>")
db.session.add(tmpl)
db.session.commit()
success, message = send_invoice_template_test_email(
tmpl.id, "preview@example.com", invoice_id=test_invoice.id
)
assert success is False
assert "not configured" in message.lower()
def test_send_invoice_template_test_email_template_missing(self, app, test_invoice):
with app.app_context():
current_app.config["MAIL_SERVER"] = "smtp.test.com"
success, message = send_invoice_template_test_email(
999999, "preview@example.com", invoice_id=test_invoice.id
)
assert success is False
assert "not found" in message.lower()