vignette/apps/api/app/test_notifications.py
Yun Chan a0311c5957
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
2026-09-08 23:28:06 +09:00

351 lines
13 KiB
Python

"""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()