browsertrix/backend/test/test_emails.py
Emma Segal-Grossman 8db0e44843
Feat: New email templating system & service (#2712)
Co-authored-by: Ilya Kreymer <ikreymer@gmail.com>
2025-08-01 17:00:24 -04:00

302 lines
9.2 KiB
Python

"""Tests for the email sending functionality in emailsender.py & email templating microservice"""
import asyncio
import uuid
import os
from datetime import datetime
from typing import cast
import aiohttp
import pytest
from btrixcloud.emailsender import EmailSender
from btrixcloud.models import Organization, InvitePending, EmailStr, StorageRef
EMAILS_HOST_PREFIX = (
os.environ.get("EMAIL_TEMPLATE_ENDPOINT") or "http://127.0.0.1:30872"
)
@pytest.fixture(scope="class")
def email_service_available():
"""Check if email service is available, skip tests if not"""
endpoint = EMAILS_HOST_PREFIX
if not endpoint:
pytest.skip(
"Email template service not configured - set EMAIL_TEMPLATE_ENDPOINT"
)
async def check_service():
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=5)
) as session:
health_url = endpoint.rstrip("/") + "/health"
async with session.get(health_url) as resp:
return resp.status == 200
except Exception:
return False
try:
return asyncio.run(check_service())
except Exception:
return False
@pytest.fixture
def mock_env_vars(monkeypatch):
"""Set up mock environment variables for testing"""
# Test with valid SMTP configuration
monkeypatch.setenv("EMAIL_SENDER", "test@browsertrix.com")
monkeypatch.setenv("EMAIL_PASSWORD", "testpassword")
monkeypatch.setenv("EMAIL_REPLY_TO", "noreply@browsertrix.com")
monkeypatch.setenv("EMAIL_SUPPORT", "support@browsertrix.com")
monkeypatch.setenv("USER_SURVEY_URL", "https://survey.browsertrix.com")
# Enable email logging but disable actual SMTP
monkeypatch.setenv("LOG_SENT_EMAILS", "true")
monkeypatch.setenv("EMAIL_SMTP_HOST", "") # Skip SMTP for tests
# Point to the test email template service
monkeypatch.setenv(
"EMAIL_TEMPLATE_ENDPOINT",
f"{EMAILS_HOST_PREFIX}/api/emails",
)
@pytest.fixture
def email_sender(mock_env_vars):
"""Create an EmailSender instance for testing"""
return EmailSender()
@pytest.fixture
def sample_org():
"""Create a sample organization for testing"""
return Organization(
id=uuid.uuid4(),
name="Test Organization",
slug="test-org",
storage=StorageRef(name="test-storage"),
)
@pytest.fixture
def sample_invite():
"""Create a sample invite for testing"""
return InvitePending(
id=uuid.uuid4(),
created=datetime.now(),
tokenHash="hashed_token_example",
inviterEmail=cast(EmailStr, "inviter@example.com"),
fromSuperuser=False,
email=cast(EmailStr, "test@example.com"),
)
def test_email_sender_initialization(email_sender):
"""Test that EmailSender correctly initializes from environment variables"""
assert email_sender.sender == "test@browsertrix.com"
assert email_sender.password == "testpassword"
assert email_sender.reply_to == "noreply@browsertrix.com"
assert email_sender.support_email == "support@browsertrix.com"
assert email_sender.survey_url == "https://survey.browsertrix.com"
assert email_sender.log_sent_emails is True
@pytest.mark.asyncio
async def test_send_user_validation(email_sender, capsys):
"""Test sending user validation email"""
test_email = "newuser@example.com"
test_token = "abc123def456"
test_headers = {"Host": "app.browsertrix.com", "X-Forwarded-Proto": "https"}
await email_sender.send_user_validation(
receiver_email=test_email, token=test_token, headers=test_headers
)
# Check log output
captured = capsys.readouterr()
assert "Email: created" in captured.out
assert "verifyEmail" in captured.out
assert test_email in captured.out
assert test_token in captured.out
@pytest.mark.asyncio
async def test_send_user_invite_new_user(
email_sender, sample_invite, sample_org, capsys
):
"""Test sending user invite for new user"""
test_token = uuid.uuid4()
await email_sender.send_user_invite(
invite=sample_invite,
token=test_token,
org_name=sample_org.name,
is_new=True,
headers={"Host": "app.browsertrix.com", "X-Forwarded-Proto": "https"},
)
# Check log output
captured = capsys.readouterr()
assert "Email: created" in captured.out
assert "invite" in captured.out
assert sample_invite.email in captured.out
assert str(test_token) in captured.out
@pytest.mark.asyncio
async def test_send_user_invite_existing_user(
email_sender, sample_invite, sample_org, capsys
):
"""Test sending user invite for existing user"""
test_token = uuid.uuid4()
await email_sender.send_user_invite(
invite=sample_invite,
token=test_token,
org_name=sample_org.name,
is_new=False,
headers={"Host": "app.browsertrix.com", "X-Forwarded-Proto": "https"},
)
# Check log output
captured = capsys.readouterr()
assert "Email: created" in captured.out
assert "invite" in captured.out
assert sample_invite.email in captured.out
assert str(test_token) in captured.out
@pytest.mark.asyncio
async def test_send_password_reset(email_sender, capsys):
"""Test sending password reset email"""
test_email = "existinguser@example.com"
test_token = uuid.uuid4()
await email_sender.send_user_forgot_password(
receiver_email=test_email,
token=str(test_token),
headers={"Host": "app.browsertrix.com", "X-Forwarded-Proto": "https"},
)
# Check log output
captured = capsys.readouterr()
assert "Email: created" in captured.out
assert "passwordReset" in captured.out
assert test_email in captured.out
assert str(test_token) in captured.out
@pytest.mark.asyncio
async def test_send_background_job_failed(email_sender, sample_org, capsys):
"""Test sending background job failure notification"""
# Create a mock job
job = {
"id": str(uuid.uuid4()),
"type": "create_replica",
"crawl_id": "test_crawl_123",
"started": datetime.now().isoformat(),
}
finished = datetime.now()
await email_sender.send_background_job_failed(
job=job, finished=finished, receiver_email="admin@example.com", org=sample_org
)
# Check log output
captured = capsys.readouterr()
assert "Email: created" in captured.out
assert "failedBgJob" in captured.out
assert str(sample_org.id) in captured.out
@pytest.mark.asyncio
async def test_send_subscription_cancellation(email_sender, sample_org, capsys):
"""Test sending subscription cancellation notification"""
cancel_date = datetime.now()
await email_sender.send_subscription_will_be_canceled(
cancel_date=cancel_date,
user_name="Test User",
receiver_email="admin@example.com",
org=sample_org,
headers={"Host": "app.browsertrix.com", "X-Forwarded-Proto": "https"},
)
# Check log output
captured = capsys.readouterr()
assert "Email: created" in captured.out
assert "subscriptionCancel" in captured.out
assert sample_org.name in captured.out
assert "Test User" in captured.out
@pytest.mark.asyncio
async def test_email_sender_no_smtp_configured(monkeypatch, capsys):
"""Test graceful handling when no SMTP server is configured"""
# Mock environment with LOG_SENT_EMAILS set to True
monkeypatch.setenv("LOG_SENT_EMAILS", "true")
monkeypatch.setenv("EMAIL_SMTP_HOST", "")
# Point to the test email template service
monkeypatch.setenv(
"EMAIL_TEMPLATE_ENDPOINT",
f"{EMAILS_HOST_PREFIX}/api/emails",
)
test_headers = {"Host": "app.browsertrix.com", "X-Forwarded-Proto": "https"}
sender = EmailSender()
await sender.send_user_validation(
receiver_email="test@example.com", token="test_token", headers=test_headers
)
captured = capsys.readouterr()
assert "but not sent (no SMTP server set)" in captured.out
@pytest.mark.asyncio
async def test_email_sender_error_handling(monkeypatch, email_sender):
"""Test error handling when template service is unavailable"""
# Point to invalid template service
monkeypatch.setattr(
email_sender,
"email_template_endpoint",
"http://invalid-url-that-does-not-exist",
)
with pytest.raises(Exception):
await email_sender.send_user_validation(
receiver_email="test@example.com", token="test_token"
)
@pytest.mark.asyncio
async def test_invite_with_superuser_flag(email_sender, sample_org, capsys):
"""Test invite sent from superuser"""
# Create invite from superuser (should not show inviter email)
invite = InvitePending(
id=uuid.uuid4(),
email=cast(EmailStr, "newuser@example.com"),
inviterEmail=cast(EmailStr, "inviter@example.com"),
fromSuperuser=True,
created=datetime.utcnow(),
tokenHash="test-hash",
)
await email_sender.send_user_invite(
invite=invite,
token=uuid.uuid4(),
org_name=sample_org.name,
is_new=True,
headers={"Host": "app.browsertrix.com", "X-Forwarded-Proto": "https"},
)
captured = capsys.readouterr()
assert "Email: created" in captured.out
assert "invite" in captured.out