from __future__ import annotations import uuid from datetime import date, timedelta from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import StaticPool from app.database import Base from app.models.subscription import Subscription from app.models.user import User from app.services.email.email_scheduler import EmailScheduler from app.services.email_service import EmailService, EmailMessage from tests.fixtures.auth import _to_uuid TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "app" / "templates" def _make_user( user_id: str | None = None, email: str = "test@example.com", plan: str = "free", ) -> User: uid = user_id or str(uuid.uuid4()) user = User( id=uid, email=email, password="hashed_password", firstName="Test", lastName="User", isActive=True, emailVerified=True, ) user.plan = plan user.max_queries = 50 if plan != "free" else 5 return user @pytest_asyncio.fixture async def async_engine(): engine = create_async_engine( "sqlite+aiosqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield engine await engine.dispose() @pytest_asyncio.fixture async def async_session(async_engine): maker = async_sessionmaker( async_engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, autocommit=False, ) async with maker() as session: yield session @pytest.fixture def mock_email_service(): return EmailService(simulate_mode=True) @pytest.fixture def email_scheduler(mock_email_service): return EmailScheduler(email_service=mock_email_service) class TestEmailSchedulerInstantiation: def test_email_scheduler_can_be_instantiated(self, mock_email_service): scheduler = EmailScheduler(email_service=mock_email_service) assert scheduler is not None assert scheduler.email_service is mock_email_service def test_email_scheduler_default_creates_mock_service(self): scheduler = EmailScheduler() assert scheduler is not None assert scheduler.email_service is not None class TestWeeklyReportEmail: @pytest.mark.asyncio async def test_weekly_report_sends_to_active_users(self, async_session, email_scheduler): user1 = _make_user(email="user1@example.com") user2 = _make_user(email="user2@example.com") async_session.add(user1) async_session.add(user2) await async_session.commit() sent_count = await email_scheduler.send_geo_weekly_report(async_session) assert sent_count == 2 @pytest.mark.asyncio async def test_weekly_report_skips_inactive_users(self, async_session, email_scheduler): active_user = _make_user(email="active@example.com") inactive_user = _make_user(email="inactive@example.com") inactive_user.isActive = False async_session.add(active_user) async_session.add(inactive_user) await async_session.commit() sent_count = await email_scheduler.send_geo_weekly_report(async_session) assert sent_count == 1 class TestRenewalReminder: @pytest.mark.asyncio async def test_renewal_reminder_sends_for_expiring_subscriptions(self, async_session, email_scheduler): user = _make_user(email="renew@example.com", plan="pro") async_session.add(user) await async_session.commit() sub = Subscription( user_id=user.id, plan="pro", status="active", start_date=date.today() - timedelta(days=23), end_date=date.today() + timedelta(days=7), amount=599.0, payment_method="mock", ) async_session.add(sub) await async_session.commit() sent_count = await email_scheduler.send_renewal_reminder(async_session) assert sent_count >= 1 @pytest.mark.asyncio async def test_renewal_reminder_no_reminder_for_non_expiring(self, async_session, email_scheduler): user = _make_user(email="notexpiring@example.com", plan="pro") async_session.add(user) await async_session.commit() sub = Subscription( user_id=user.id, plan="pro", status="active", start_date=date.today(), end_date=date.today() + timedelta(days=30), amount=599.0, payment_method="mock", ) async_session.add(sub) await async_session.commit() sent_count = await email_scheduler.send_renewal_reminder(async_session) assert sent_count == 0 class TestWelcomeEmail: @pytest.mark.asyncio async def test_welcome_email_sends_successfully(self, email_scheduler): result = await email_scheduler.send_welcome_email( "newuser@example.com", "新用户" ) assert result is True @pytest.mark.asyncio async def test_welcome_email_with_empty_name(self, email_scheduler): result = await email_scheduler.send_welcome_email( "newuser@example.com", "" ) assert result is True class TestMockMode: def test_mock_mode_logs_instead_of_sending(self, mock_email_service): msg = EmailMessage( to="test@example.com", subject="Test Subject", body_html="

Test

", body_text="Test", ) result = mock_email_service.send_email(msg) assert result.success is True assert result.message_id is not None assert result.message_id.startswith("sim_") assert result.error is None def test_mock_mode_simulate_flag(self, mock_email_service): assert mock_email_service.simulate_mode is True def test_production_mode_would_use_smtp(self): service = EmailService( simulate_mode=False, smtp_host="localhost", smtp_port=587, smtp_user="test", smtp_password="test", ) assert service.simulate_mode is False class TestEmailTemplates: def test_geo_weekly_report_template_exists(self): template_path = TEMPLATES_DIR / "geo_weekly_report.html" assert template_path.exists() def test_renewal_reminder_template_exists(self): template_path = TEMPLATES_DIR / "renewal_reminder.html" assert template_path.exists() def test_trial_expiring_template_exists(self): template_path = TEMPLATES_DIR / "trial_expiring.html" assert template_path.exists() def test_welcome_template_exists(self): template_path = TEMPLATES_DIR / "welcome.html" assert template_path.exists() def test_geo_weekly_report_renders_with_context(self, email_scheduler): template_html = email_scheduler._load_template("geo_weekly_report.html") context = { "user_name": "测试用户", "score_change": "+5", "current_score": "78", "previous_score": "73", "top_improved": "内容质量 (+12%)", "top_declined": "品牌权威 (-3%)", "suggestions": "建议增加技术白皮书", "report_link": "https://example.com", "year": "2026", } rendered = email_scheduler._render_template(template_html, context) assert "测试用户" in rendered assert "+5" in rendered assert "78" in rendered assert "内容质量 (+12%)" in rendered def test_renewal_reminder_renders_with_context(self, email_scheduler): template_html = email_scheduler._load_template("renewal_reminder.html") context = { "user_name": "续费用户", "plan_name": "专业版", "end_date": "2026-06-30", "days_remaining": "7", "plan_price": "599", "renew_link": "https://example.com", "year": "2026", } rendered = email_scheduler._render_template(template_html, context) assert "续费用户" in rendered assert "专业版" in rendered assert "7" in rendered assert "599" in rendered def test_trial_expiring_renders_with_context(self, email_scheduler): template_html = email_scheduler._load_template("trial_expiring.html") context = { "user_name": "试用用户", "days_remaining": "3", "upgrade_link": "https://example.com", "year": "2026", } rendered = email_scheduler._render_template(template_html, context) assert "试用用户" in rendered assert "3" in rendered def test_welcome_renders_with_context(self, email_scheduler): template_html = email_scheduler._load_template("welcome.html") context = { "user_name": "新用户", "dashboard_link": "https://example.com", "diagnosis_link": "https://example.com/diagnosis", "help_link": "https://example.com/help", "year": "2026", } rendered = email_scheduler._render_template(template_html, context) assert "新用户" in rendered assert "3" in rendered class TestSendTemplateEmail: def test_send_template_email_with_welcome(self, mock_email_service): result = mock_email_service.send_template_email( to="test@example.com", subject="欢迎", template_name="welcome.html", context={ "user_name": "测试", "dashboard_link": "https://example.com", "diagnosis_link": "https://example.com/d", "help_link": "https://example.com/h", "year": "2026", }, ) assert result.success is True def test_send_template_email_invalid_template(self, mock_email_service): with pytest.raises(ValueError, match="模板文件不存在"): mock_email_service.send_template_email( to="test@example.com", subject="Test", template_name="nonexistent.html", context={}, ) def test_send_template_email_renders_context(self, mock_email_service): result = mock_email_service.send_template_email( to="test@example.com", subject="周报", template_name="geo_weekly_report.html", context={ "user_name": "渲染测试", "score_change": "+10", "current_score": "85", "previous_score": "75", "top_improved": "测试提升", "top_declined": "测试下降", "suggestions": "测试建议", "report_link": "https://example.com", "year": "2026", }, ) assert result.success is True