"""Notification email rendering and enqueue trigger tests.""" from __future__ import annotations import asyncio import unittest from contextlib import contextmanager from typing import Any from unittest.mock import AsyncMock, patch from .config import settings from . import session_evaluation_timeout from .routes import sessions from .services import notifications, state_machine from .services.persona import P1 from .store import InProcSession, TurnRecord @contextmanager def patched_settings(**values: Any): previous = {key: getattr(settings, key) for key in values} for key, value in values.items(): setattr(settings, key, value) try: yield finally: for key, value in previous.items(): setattr(settings, key, value) def _ended_session() -> InProcSession: state = state_machine.init_state(params=P1.openness_params()) state.stage = state_machine.Stage.EXPLORE return InProcSession( session_id="00000000-0000-0000-0000-00000000e111", case_id="00000000-0000-0000-0000-00000000e111", learner_id="00000000-0000-0000-0000-000000000111", persona_code=P1.code, theory_mode="humanistic", persona=P1, state=state, session_no=1, created_at=1_000.0, ended_at=1_600.0, ended=True, turns=[ TurnRecord( turn_seq=1, speaker="counselor", stage=state.stage.value, text="상담자 발화", text_masked="상담자 발화", ), TurnRecord( turn_seq=2, speaker="client", stage=state.stage.value, text="내담자 응답", text_masked="내담자 응답", ), ], ) class NotificationEmailTemplateTest(unittest.TestCase): def test_session_review_email_uses_polite_copy_and_direct_review_link(self) -> None: rendered = notifications.render_email( "session_review_ready", { "learner_label": "테스트 학습자", "persona_name": "서연", "session_no": 2, "ended_at": "2026-06-29T09:00:00+09:00", "review_url": "https://vignette.chanpaca.net/teach/session/s1/review", "transcript": "메일에 들어가면 안 되는 축어록", }, ) self.assertEqual(rendered.subject, "[Vignette] 검토할 회기가 있습니다") self.assertIn("검토할 회기가 있습니다.", rendered.html) self.assertIn("회기 검토하기", rendered.html) self.assertIn("https://vignette.chanpaca.net/teach/session/s1/review", rendered.html) self.assertIn("민감한 회기 내용은 메일에 포함하지 않았습니다", rendered.html) self.assertNotIn("메일에 들어가면 안 되는 축어록", rendered.html) def test_account_approval_email_links_admin_users(self) -> None: rendered = notifications.render_email( "account_pending_approval", { "display_name": "신규 사용자", "email": "new@hs.ac.kr", "role": "learner", "approval_url": "https://vignette.chanpaca.net/admin/users", }, ) self.assertEqual(rendered.subject, "[Vignette] 새 가입 승인 요청이 있습니다") self.assertIn("새 가입 승인 요청이 있습니다.", rendered.html) self.assertIn("가입 승인 확인하기", rendered.html) self.assertIn("https://vignette.chanpaca.net/admin/users", rendered.html) def test_admin_test_email_uses_distinct_test_copy(self) -> None: rendered = notifications.render_email( "admin_test_email", { "requested_by": "admin@hs.ac.kr", "notifications_url": "https://vignette.chanpaca.net/admin", }, ) self.assertEqual(rendered.subject, "[Vignette] 메일 알림 테스트입니다") self.assertIn("메일 알림 테스트입니다.", rendered.html) self.assertIn("발송 경로 확인용 테스트", rendered.html) self.assertIn("알림 상태 확인하기", rendered.html) self.assertIn("https://vignette.chanpaca.net/admin", rendered.html) def test_smtp_sender_uses_html_text_parts_and_starttls(self) -> None: rendered = notifications.render_email( "session_review_ready", { "learner_label": "테스트 학습자", "persona_name": "서연", "session_no": 1, "review_url": "https://vignette.chanpaca.net/teach/session/s1/review", }, ) with ( patched_settings( notification_email_provider="smtp", smtp_host="smtp.example.test", smtp_port=587, smtp_username="mailer", smtp_password="secret", smtp_from_email="no-reply@example.test", smtp_from_name="Vignette", smtp_starttls=True, smtp_ssl=False, ), patch.object(notifications.smtplib, "SMTP") as smtp_class, ): smtp = smtp_class.return_value.__enter__.return_value provider_id = notifications._send_rendered_email( recipient_email="teacher@hs.ac.kr", recipient_name="교수자", rendered=rendered, ) self.assertIn("@example.test>", provider_id) smtp_class.assert_called_once_with("smtp.example.test", 587, timeout=15) smtp.starttls.assert_called_once() smtp.login.assert_called_once_with("mailer", "secret") smtp.send_message.assert_called_once() message = smtp.send_message.call_args.args[0] self.assertEqual(message["Subject"], "[Vignette] 검토할 회기가 있습니다") self.assertIn("teacher@hs.ac.kr", message["To"]) self.assertTrue(message.is_multipart()) class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase): async def test_failed_session_evaluation_still_enqueues_teacher_review_notification(self) -> None: sess = _ended_session() with ( patch.object( sessions.evaluator, "evaluate_session", AsyncMock(side_effect=RuntimeError("engine offline")), ), patch.object( sessions.session_evaluation_repository, "save_session_evaluation", AsyncMock(return_value=True), ) as save_evaluation, patch.object( sessions.notifications, "enqueue_session_review_ready", AsyncMock(), ) as enqueue_review, ): await sessions._generate_and_save_session_evaluation(sess) save_evaluation.assert_awaited_once() enqueue_review.assert_awaited_once_with(session_id=sess.session_id) async def test_session_evaluation_timeout_records_actionable_error(self) -> None: sess = _ended_session() with ( patched_settings(session_evaluation_timeout=1.0), patch.object( sessions.evaluator, "evaluate_session", AsyncMock(side_effect=asyncio.TimeoutError()), ), patch.object( sessions.session_evaluation_repository, "save_session_evaluation", AsyncMock(return_value=True), ) as save_evaluation, patch.object( sessions.notifications, "enqueue_session_review_ready", AsyncMock(), ), ): await sessions._generate_and_save_session_evaluation(sess) saved_write = save_evaluation.await_args.args[0] self.assertEqual(saved_write.status, "error") self.assertEqual(saved_write.error, "session evaluation timeout after 1s") async def test_session_evaluation_forwards_transport_and_audit_budget_after_deep_deadline(self) -> None: sess = _ended_session() result = sessions.evaluator.SessionEvaluation( session_id=sess.session_id, stage=sess.state.stage.value, scope="session_end", turns_evaluated=len(sess.turns), ) with ( patched_settings(session_evaluation_timeout=300.0, db_command_timeout=30.0), patch.object( sessions.evaluator, "evaluate_session", AsyncMock(return_value=result), ) as evaluate_session, patch.object( sessions.session_evaluation_repository, "save_session_evaluation", AsyncMock(return_value=True), ), patch.object( sessions.notifications, "enqueue_session_review_ready", AsyncMock(), ), patch.object(sessions.asyncio, "wait_for", wraps=asyncio.wait_for) as wait_for, ): await sessions._generate_and_save_session_evaluation(sess) self.assertEqual(evaluate_session.await_args.kwargs["timeout"], 330.0) self.assertEqual(wait_for.await_args.kwargs["timeout"], 365.0) async def test_scheduled_session_evaluation_task_crash_is_observed(self) -> None: sess = _ended_session() async def boom(_sess: InProcSession) -> None: raise RuntimeError("background crash") with patch.object(sessions, "_generate_and_save_session_evaluation", boom): with self.assertLogs(sessions.logger, level="ERROR") as logs: task = sessions._schedule_session_evaluation(sess) self.assertIsNotNone(task) assert task is not None with self.assertRaises(RuntimeError): await task self.assertTrue( any("session evaluation background task crashed" in line for line in logs.output), logs.output, ) async def test_missing_session_evaluation_recovery_schedules_stale_sessions(self) -> None: sess = _ended_session() with ( patched_settings(session_evaluation_timeout=10.0), patch.object( sessions.session_persistence, "list_sessions_missing_session_evaluation", AsyncMock(return_value=([sess], True)), ) as list_missing, patch.object( sessions, "_schedule_session_evaluation", return_value=object(), ) as schedule_evaluation, ): scheduled = await sessions.recover_missing_session_evaluations(limit=2) expected_stale_after = session_evaluation_timeout.session_evaluation_stale_after_seconds() self.assertEqual(scheduled, 1) list_missing.assert_awaited_once_with( older_than_seconds=expected_stale_after, limit=2, ) schedule_evaluation.assert_called_once_with(sess) def test_stale_deadline_follows_outer_completion_and_db_save_budget(self) -> None: with patched_settings(session_evaluation_timeout=300.0, db_command_timeout=30.0): self.assertEqual( session_evaluation_timeout.session_evaluation_transport_timeout_seconds(), 330.0, ) self.assertEqual( session_evaluation_timeout.session_evaluation_outer_timeout_seconds(), 365.0, ) self.assertEqual( session_evaluation_timeout.session_evaluation_stale_after_seconds(), 400.0, ) async def test_missing_session_evaluation_recovery_requires_durable_store(self) -> None: sess = _ended_session() with ( patch.object( sessions.session_persistence, "list_sessions_missing_session_evaluation", AsyncMock(return_value=([sess], False)), ), patch.object(sessions, "_schedule_session_evaluation") as schedule_evaluation, ): scheduled = await sessions.recover_missing_session_evaluations(limit=2) self.assertEqual(scheduled, 0) schedule_evaluation.assert_not_called() async def test_session_evaluation_scheduler_deduplicates_in_flight_session(self) -> None: sess = _ended_session() gate = asyncio.Event() sessions._SESSION_EVALUATION_IN_FLIGHT.clear() async def wait_until_released(_sess: InProcSession) -> None: await gate.wait() try: with patch.object( sessions, "_generate_and_save_session_evaluation", wait_until_released, ): task = sessions._schedule_session_evaluation(sess) self.assertIsNotNone(task) self.assertIsNone(sessions._schedule_session_evaluation(sess)) self.assertIn(sess.session_id, sessions._SESSION_EVALUATION_IN_FLIGHT) gate.set() assert task is not None await task await asyncio.sleep(0) finally: sessions._SESSION_EVALUATION_IN_FLIGHT.clear() self.assertNotIn(sess.session_id, sessions._SESSION_EVALUATION_IN_FLIGHT) if __name__ == "__main__": unittest.main()