- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등 - 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리 - 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치) - 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
298 lines
11 KiB
Python
298 lines
11 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 .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_persistence,
|
|
"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_persistence,
|
|
"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_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)
|
|
|
|
self.assertEqual(scheduled, 1)
|
|
list_missing.assert_awaited_once_with(older_than_seconds=40.0, limit=2)
|
|
schedule_evaluation.assert_called_once_with(sess)
|
|
|
|
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()
|