mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-06 03:30:25 -06:00
- 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.
449 lines
17 KiB
Python
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
|