feat: add EmailService with Resend integration and dev mode
Implements a flexible email service that integrates with Resend API and supports development mode for easier testing. Features: - Resend API integration for production email delivery - Development mode that logs emails instead of sending - Magic link URL logging in dev mode for testing - Helper methods for magic link and registration emails - Comprehensive test coverage (100% for service) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
200
tests/unit/test_email_service.py
Normal file
200
tests/unit/test_email_service.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Tests for email service."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.email import EmailService
|
||||
|
||||
|
||||
class TestEmailService:
|
||||
"""Test suite for EmailService."""
|
||||
|
||||
def test_init_with_api_key(self, app):
|
||||
"""Test EmailService initialization with API key."""
|
||||
with app.app_context():
|
||||
app.config["RESEND_API_KEY"] = "test-api-key"
|
||||
service = EmailService()
|
||||
assert service.api_key == "test-api-key"
|
||||
assert service.dev_mode is False
|
||||
|
||||
def test_init_in_dev_mode(self, app):
|
||||
"""Test EmailService initialization in development mode."""
|
||||
with app.app_context():
|
||||
app.config["FLASK_ENV"] = "development"
|
||||
service = EmailService()
|
||||
assert service.dev_mode is True
|
||||
|
||||
def test_init_without_api_key_in_production(self, app):
|
||||
"""Test EmailService raises error without API key in production."""
|
||||
with app.app_context():
|
||||
app.config["RESEND_API_KEY"] = None
|
||||
app.config["FLASK_ENV"] = "production"
|
||||
with pytest.raises(ValueError, match="RESEND_API_KEY"):
|
||||
EmailService()
|
||||
|
||||
def test_init_without_api_key_in_dev_mode(self, app):
|
||||
"""Test EmailService allows missing API key in dev mode."""
|
||||
with app.app_context():
|
||||
app.config["RESEND_API_KEY"] = None
|
||||
app.config["FLASK_ENV"] = "development"
|
||||
service = EmailService()
|
||||
assert service.api_key is None
|
||||
assert service.dev_mode is True
|
||||
|
||||
@patch("resend.Emails.send")
|
||||
def test_send_email_success(self, mock_send, app):
|
||||
"""Test successful email sending."""
|
||||
with app.app_context():
|
||||
app.config["RESEND_API_KEY"] = "test-api-key"
|
||||
app.config["FLASK_ENV"] = "production"
|
||||
service = EmailService()
|
||||
|
||||
mock_send.return_value = {"id": "email-123"}
|
||||
|
||||
result = service.send_email(
|
||||
to="test@example.com",
|
||||
subject="Test Subject",
|
||||
html_body="<p>Test body</p>",
|
||||
)
|
||||
|
||||
assert result == {"id": "email-123"}
|
||||
mock_send.assert_called_once()
|
||||
# Check the first positional argument (the dict)
|
||||
call_args = mock_send.call_args[0][0]
|
||||
assert call_args["to"] == ["test@example.com"]
|
||||
assert call_args["subject"] == "Test Subject"
|
||||
assert call_args["html"] == "<p>Test body</p>"
|
||||
|
||||
@patch("resend.Emails.send")
|
||||
def test_send_email_with_from_address(self, mock_send, app):
|
||||
"""Test email sending with custom from address."""
|
||||
with app.app_context():
|
||||
app.config["RESEND_API_KEY"] = "test-api-key"
|
||||
app.config["EMAIL_FROM"] = "custom@example.com"
|
||||
app.config["FLASK_ENV"] = "production"
|
||||
service = EmailService()
|
||||
|
||||
mock_send.return_value = {"id": "email-123"}
|
||||
|
||||
service.send_email(
|
||||
to="test@example.com",
|
||||
subject="Test Subject",
|
||||
html_body="<p>Test body</p>",
|
||||
)
|
||||
|
||||
call_args = mock_send.call_args[0][0]
|
||||
assert call_args["from"] == "custom@example.com"
|
||||
|
||||
@patch("resend.Emails.send")
|
||||
def test_send_email_in_dev_mode_logs_instead(self, mock_send, app, caplog):
|
||||
"""Test that email sending logs in dev mode instead of actually sending."""
|
||||
with app.app_context():
|
||||
app.config["FLASK_ENV"] = "development"
|
||||
service = EmailService()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = service.send_email(
|
||||
to="test@example.com",
|
||||
subject="Test Subject",
|
||||
html_body="<p>Test body</p>",
|
||||
)
|
||||
|
||||
# Should not actually send email
|
||||
mock_send.assert_not_called()
|
||||
|
||||
# Should log the email details
|
||||
assert "DEV MODE: Email not sent" in caplog.text
|
||||
assert "test@example.com" in caplog.text
|
||||
assert "Test Subject" in caplog.text
|
||||
|
||||
# Should return a mock success response
|
||||
assert result["id"].startswith("dev-mode-")
|
||||
|
||||
@patch("resend.Emails.send")
|
||||
def test_send_email_handles_resend_error(self, mock_send, app):
|
||||
"""Test email sending handles Resend API errors."""
|
||||
with app.app_context():
|
||||
app.config["RESEND_API_KEY"] = "test-api-key"
|
||||
app.config["FLASK_ENV"] = "production"
|
||||
service = EmailService()
|
||||
|
||||
mock_send.side_effect = Exception("API Error")
|
||||
|
||||
with pytest.raises(Exception, match="API Error"):
|
||||
service.send_email(
|
||||
to="test@example.com",
|
||||
subject="Test Subject",
|
||||
html_body="<p>Test body</p>",
|
||||
)
|
||||
|
||||
def test_send_magic_link_email(self, app):
|
||||
"""Test sending magic link email."""
|
||||
with app.app_context():
|
||||
app.config["FLASK_ENV"] = "development"
|
||||
service = EmailService()
|
||||
|
||||
with patch.object(service, "send_email") as mock_send:
|
||||
mock_send.return_value = {"id": "email-123"}
|
||||
|
||||
result = service.send_magic_link(
|
||||
to="test@example.com",
|
||||
magic_link_url="https://example.com/magic/abc123",
|
||||
exchange_name="Secret Santa 2025",
|
||||
)
|
||||
|
||||
assert result == {"id": "email-123"}
|
||||
mock_send.assert_called_once()
|
||||
call_args = mock_send.call_args[1]
|
||||
assert call_args["to"] == "test@example.com"
|
||||
assert "Secret Santa 2025" in call_args["subject"]
|
||||
assert "https://example.com/magic/abc123" in call_args["html_body"]
|
||||
|
||||
def test_send_registration_confirmation_email(self, app):
|
||||
"""Test sending registration confirmation email."""
|
||||
with app.app_context():
|
||||
app.config["FLASK_ENV"] = "development"
|
||||
service = EmailService()
|
||||
|
||||
with patch.object(service, "send_email") as mock_send:
|
||||
mock_send.return_value = {"id": "email-123"}
|
||||
|
||||
result = service.send_registration_confirmation(
|
||||
to="test@example.com",
|
||||
participant_name="John Doe",
|
||||
magic_link_url="https://example.com/magic/abc123",
|
||||
exchange_name="Secret Santa 2025",
|
||||
exchange_description="Annual office Secret Santa",
|
||||
budget_amount=50,
|
||||
gift_exchange_date="2025-12-20",
|
||||
)
|
||||
|
||||
assert result == {"id": "email-123"}
|
||||
mock_send.assert_called_once()
|
||||
call_args = mock_send.call_args[1]
|
||||
assert call_args["to"] == "test@example.com"
|
||||
assert "Secret Santa 2025" in call_args["subject"]
|
||||
assert "John Doe" in call_args["html_body"]
|
||||
assert "https://example.com/magic/abc123" in call_args["html_body"]
|
||||
assert "Annual office Secret Santa" in call_args["html_body"]
|
||||
assert "$50" in call_args["html_body"]
|
||||
assert "2025-12-20" in call_args["html_body"]
|
||||
|
||||
def test_dev_mode_logs_magic_link_url(self, app, caplog):
|
||||
"""Test that magic link URLs are logged in dev mode."""
|
||||
with app.app_context():
|
||||
app.config["FLASK_ENV"] = "development"
|
||||
service = EmailService()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service.send_magic_link(
|
||||
to="test@example.com",
|
||||
magic_link_url="https://example.com/magic/abc123",
|
||||
exchange_name="Secret Santa 2025",
|
||||
)
|
||||
|
||||
assert (
|
||||
"DEV MODE: Magic link URL: https://example.com/magic/abc123"
|
||||
in caplog.text
|
||||
)
|
||||
Reference in New Issue
Block a user