Add pytest test suite (92 tests, all passing)

Covers every module in src/ with unit tests:

- tests/conftest.py        shared fixtures (complete_registration, fresh_state, …)
- tests/test_models.py     RegistrationData.is_complete(), to_dict/from_dict round-trips
- tests/test_storage.py    normalize_email, _diff_registrations, ConversationStore CRUD,
                           registration versioning
- tests/test_llm.py        litellm wrapper — message construction, model passthrough,
                           error propagation
- tests/test_agent.py      EmailAgent — new/existing conversations, registration
                           completion, admin notification, fallback on LLM error,
                           JSON parsing, _apply_updates
- tests/test_notifier.py   AdminNotifier routing, fee calculation, SMTP dispatch
- tests/test_knowledge_base.py  KnowledgeBase loading and reload

All external I/O (litellm, SMTP, filesystem) is mocked. Tests run fast (~6s)
with no network access required.

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
This commit is contained in:
Claude
2026-02-21 08:07:31 +00:00
parent 431847a8b7
commit 7f55cdd204
8 changed files with 917 additions and 0 deletions
View File
+61
View File
@@ -0,0 +1,61 @@
"""Shared pytest fixtures."""
import pytest
from src.models.conversation import ConversationState, ChatMessage
from src.models.registration import (
RegistrationData,
ChildInfo,
ParentGuardian,
EmergencyContact,
Booking,
BookingDay,
)
@pytest.fixture
def complete_registration() -> RegistrationData:
"""A fully populated RegistrationData that passes is_complete()."""
return RegistrationData(
child=ChildInfo(
full_name="Lena Muster",
date_of_birth="2022-03-15",
special_needs="None",
),
parent_guardian=ParentGuardian(
full_name="Anna Muster",
street_address="Hauptstrasse 1",
postal_code="8117",
city="Fällanden",
phone="044 123 45 67",
email="anna.muster@example.com",
),
emergency_contact=EmergencyContact(
full_name="Hans Muster",
phone="079 123 45 67",
),
booking=Booking(
playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")],
),
)
@pytest.fixture
def fresh_state() -> ConversationState:
"""A brand-new ConversationState for a parent email."""
return ConversationState(
conversation_id="anna.muster@example.com",
parent_email="anna.muster@example.com",
)
@pytest.fixture
def state_with_messages(fresh_state) -> ConversationState:
"""A ConversationState with a couple of chat turns."""
fresh_state.messages = [
ChatMessage(role="user", content="Hallo, ich möchte mein Kind anmelden."),
ChatMessage(role="assistant", content="Hallo! Wie heisst dein Kind?"),
ChatMessage(role="user", content="Lena Muster"),
]
return fresh_state
+271
View File
@@ -0,0 +1,271 @@
"""Tests for EmailAgent — the conversation orchestrator."""
import json
import pytest
from unittest.mock import MagicMock, patch
from src.agent.core import EmailAgent
from src.models.conversation import ConversationState
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
VALID_LLM_REPLY = json.dumps({
"reply": "Wie heisst dein Kind?",
"updates": {},
"next_step": "child_name",
"registration_complete": False,
"language": "de",
})
COMPLETION_LLM_REPLY = json.dumps({
"reply": "Vielen Dank, dein Kind ist angemeldet!",
"updates": {},
"next_step": "done",
"registration_complete": True,
"language": "de",
})
@pytest.fixture
def mock_kb():
kb = MagicMock()
kb.get_all.return_value = "# FAQ\nSome knowledge base content."
return kb
@pytest.fixture
def mock_store():
store = MagicMock()
store.load.return_value = None # no prior conversation by default
store.save_registration.return_value = ("anna.muster@example.com", 1)
return store
@pytest.fixture
def mock_notifier():
return MagicMock()
@pytest.fixture
def agent(mock_kb, mock_store, mock_notifier):
return EmailAgent(
model="anthropic/claude-opus-4-6",
kb=mock_kb,
store=mock_store,
notifier=mock_notifier,
)
# ---------------------------------------------------------------------------
# process_message — new conversation
# ---------------------------------------------------------------------------
class TestProcessMessageNewConversation:
def test_creates_new_state_when_none_exists(self, agent, mock_store):
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("anna.muster@example.com", "Hallo")
saved_state = mock_store.save.call_args[0][0]
assert saved_state.conversation_id == "anna.muster@example.com"
def test_returns_llm_reply_text(self, agent):
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
reply = agent.process_message("anna.muster@example.com", "Hallo")
assert reply == "Wie heisst dein Kind?"
def test_user_message_added_to_history(self, agent, mock_store):
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("anna.muster@example.com", "Hallo, ich möchte anmelden")
state = mock_store.save.call_args[0][0]
assert any(m.role == "user" and "anmelden" in m.content for m in state.messages)
def test_assistant_reply_added_to_history(self, agent, mock_store):
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("anna.muster@example.com", "Hallo")
state = mock_store.save.call_args[0][0]
assert any(m.role == "assistant" for m in state.messages)
def test_normalizes_email_key(self, agent, mock_store):
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("Anna.Muster@EXAMPLE.COM", "Hallo")
state = mock_store.save.call_args[0][0]
assert state.conversation_id == "anna.muster@example.com"
# ---------------------------------------------------------------------------
# process_message — existing conversation
# ---------------------------------------------------------------------------
class TestProcessMessageExistingConversation:
def test_loads_existing_state(self, agent, mock_store, fresh_state):
mock_store.load.return_value = fresh_state
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("anna.muster@example.com", "Lena")
mock_store.load.assert_called_once()
def test_flow_step_updated(self, agent, mock_store, fresh_state):
mock_store.load.return_value = fresh_state
reply_with_step = json.dumps({
"reply": "Wann ist Lena geboren?",
"updates": {"child.fullName": "Lena"},
"next_step": "child_dob",
"registration_complete": False,
"language": "de",
})
with patch("src.llm.complete", return_value=reply_with_step):
agent.process_message("anna.muster@example.com", "Lena")
state = mock_store.save.call_args[0][0]
assert state.flow_step == "child_dob"
# ---------------------------------------------------------------------------
# process_message — registration completion
# ---------------------------------------------------------------------------
class TestRegistrationCompletion:
def test_notifier_called_on_completion(self, agent, mock_store, mock_notifier, complete_registration):
state = ConversationState(
conversation_id="anna.muster@example.com",
parent_email="anna.muster@example.com",
)
state.registration = complete_registration
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
agent.process_message("anna.muster@example.com", "Ja, alles korrekt")
mock_notifier.notify_admin.assert_called_once()
def test_state_marked_completed(self, agent, mock_store, complete_registration):
state = ConversationState(
conversation_id="anna.muster@example.com",
parent_email="anna.muster@example.com",
)
state.registration = complete_registration
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
agent.process_message("anna.muster@example.com", "Ja")
saved = mock_store.save.call_args[0][0]
assert saved.completed is True
def test_notifier_not_called_when_already_completed(self, agent, mock_store, mock_notifier, complete_registration):
state = ConversationState(
conversation_id="anna.muster@example.com",
parent_email="anna.muster@example.com",
)
state.registration = complete_registration
state.completed = True # already done
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
agent.process_message("anna.muster@example.com", "Noch eine Frage")
mock_notifier.notify_admin.assert_not_called()
# ---------------------------------------------------------------------------
# Fallback on LLM error
# ---------------------------------------------------------------------------
class TestFallbackOnLLMError:
def test_returns_german_fallback_by_default(self, agent):
with patch("src.llm.complete", side_effect=RuntimeError("API down")):
reply = agent.process_message("anna.muster@example.com", "Hallo")
assert "technisches Problem" in reply or "Entschuldigung" in reply
def test_returns_english_fallback_when_language_is_en(self, agent, mock_store, fresh_state):
fresh_state.language = "en"
mock_store.load.return_value = fresh_state
with patch("src.llm.complete", side_effect=RuntimeError("API down")):
reply = agent.process_message("anna.muster@example.com", "Hello")
assert "technical issue" in reply.lower() or "sorry" in reply.lower()
# ---------------------------------------------------------------------------
# _parse_llm_response
# ---------------------------------------------------------------------------
class TestParseLlmResponse:
def test_parses_plain_json(self, agent):
payload = '{"reply": "Hi", "updates": {}, "next_step": "greeting", "registration_complete": false, "language": "de"}'
result = agent._parse_llm_response(payload)
assert result["reply"] == "Hi"
def test_parses_fenced_json(self, agent):
payload = '```json\n{"reply": "Hi", "updates": {}}\n```'
result = agent._parse_llm_response(payload)
assert result["reply"] == "Hi"
def test_parses_json_embedded_in_text(self, agent):
payload = 'Sure, here is the response: {"reply": "Hi", "updates": {}}'
result = agent._parse_llm_response(payload)
assert result["reply"] == "Hi"
def test_falls_back_to_raw_text_when_no_json(self, agent):
result = agent._parse_llm_response("Ich bin ein Hilfsroboter")
assert result["reply"] == "Ich bin ein Hilfsroboter"
def test_fallback_has_safe_defaults(self, agent):
result = agent._parse_llm_response("plain text")
assert result["registration_complete"] is False
assert result["updates"] == {}
# ---------------------------------------------------------------------------
# _apply_updates
# ---------------------------------------------------------------------------
class TestApplyUpdates:
def test_sets_child_name(self, agent, fresh_state):
agent._apply_updates(fresh_state, {"child.fullName": "Lena Muster"})
assert fresh_state.registration.child.full_name == "Lena Muster"
def test_sets_child_dob(self, agent, fresh_state):
agent._apply_updates(fresh_state, {"child.dateOfBirth": "2022-03-15"})
assert fresh_state.registration.child.date_of_birth == "2022-03-15"
def test_sets_parent_email(self, agent, fresh_state):
agent._apply_updates(fresh_state, {"parentGuardian.email": "test@example.com"})
assert fresh_state.registration.parent_guardian.email == "test@example.com"
def test_sets_emergency_contact(self, agent, fresh_state):
agent._apply_updates(fresh_state, {"emergencyContact.phone": "079 111 22 33"})
assert fresh_state.registration.emergency_contact.phone == "079 111 22 33"
def test_sets_booking_days(self, agent, fresh_state):
agent._apply_updates(fresh_state, {
"booking.selectedDays": [{"day": "wednesday", "type": "indoor"}]
})
assert fresh_state.registration.booking.selected_days[0].day == "wednesday"
def test_ignores_none_values(self, agent, fresh_state):
fresh_state.registration.child.full_name = "Lena"
agent._apply_updates(fresh_state, {"child.fullName": None})
assert fresh_state.registration.child.full_name == "Lena"
def test_ignores_unknown_keys(self, agent, fresh_state):
agent._apply_updates(fresh_state, {"unknown.key": "value"}) # should not raise
+52
View File
@@ -0,0 +1,52 @@
"""Tests for KnowledgeBase loader."""
import pytest
from pathlib import Path
from src.knowledge_base.loader import KnowledgeBase
@pytest.fixture
def kb_dir(tmp_path) -> Path:
"""A temporary knowledge-base directory with a couple of markdown files."""
(tmp_path / "faq.md").write_text("# FAQ\nWann beginnt die Spielgruppe?\nIm August.")
(tmp_path / "fees.md").write_text("# Fees\nCHF 130 per month.")
return tmp_path
@pytest.fixture
def kb(kb_dir) -> KnowledgeBase:
return KnowledgeBase(kb_dir)
class TestKnowledgeBaseLoading:
def test_get_all_includes_file_content(self, kb):
content = kb.get_all()
assert "FAQ" in content
assert "Fees" in content
def test_get_all_concatenates_multiple_files(self, kb):
content = kb.get_all()
assert "CHF 130" in content
assert "Spielgruppe" in content
def test_reload_picks_up_new_file(self, kb, kb_dir):
(kb_dir / "schedule.md").write_text("# Schedule\nMonday 9:00")
kb.reload()
assert "Schedule" in kb.get_all()
def test_empty_directory_returns_empty_string(self, tmp_path):
kb = KnowledgeBase(tmp_path)
assert kb.get_all() == "" or isinstance(kb.get_all(), str)
def test_nonexistent_directory_does_not_raise_on_init(self, tmp_path):
# Should either handle gracefully or raise — just must not crash silently
missing = tmp_path / "does_not_exist"
try:
kb = KnowledgeBase(missing)
kb.get_all()
except (FileNotFoundError, OSError):
pass # Acceptable to raise on missing dir
def test_get_all_returns_string(self, kb):
assert isinstance(kb.get_all(), str)
+67
View File
@@ -0,0 +1,67 @@
"""Tests for the litellm wrapper in src/llm.py."""
import pytest
from src import llm
from src.models.conversation import ChatMessage
class TestLlmComplete:
def test_returns_model_reply(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "Hallo! Wie heisst dein Kind?"
mocker.patch("litellm.completion", return_value=mock_response)
result = llm.complete("anthropic/claude-opus-4-6", "system prompt", [])
assert result == "Hallo! Wie heisst dein Kind?"
def test_passes_model_to_litellm(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
llm.complete("openai/gpt-4o", "system", [])
call_kwargs = mock_completion.call_args.kwargs
assert call_kwargs["model"] == "openai/gpt-4o"
def test_system_prompt_prepended_as_system_message(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
llm.complete("anthropic/claude-opus-4-6", "You are helpful.", [])
messages = mock_completion.call_args.kwargs["messages"]
assert messages[0] == {"role": "system", "content": "You are helpful."}
def test_chat_messages_appended_after_system(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
chat = [
ChatMessage(role="user", content="Hallo"),
ChatMessage(role="assistant", content="Guten Tag"),
]
llm.complete("anthropic/claude-opus-4-6", "system", chat)
messages = mock_completion.call_args.kwargs["messages"]
assert messages[1] == {"role": "user", "content": "Hallo"}
assert messages[2] == {"role": "assistant", "content": "Guten Tag"}
def test_max_tokens_passed(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
llm.complete("anthropic/claude-opus-4-6", "system", [])
assert mock_completion.call_args.kwargs["max_tokens"] == 2048
def test_litellm_exception_propagates(self, mocker):
mocker.patch("litellm.completion", side_effect=RuntimeError("API error"))
with pytest.raises(RuntimeError, match="API error"):
llm.complete("anthropic/claude-opus-4-6", "system", [])
+152
View File
@@ -0,0 +1,152 @@
"""Tests for data models: RegistrationData and ConversationState."""
import pytest
from src.models.registration import (
RegistrationData,
ChildInfo,
ParentGuardian,
EmergencyContact,
Booking,
BookingDay,
)
from src.models.conversation import ConversationState, ChatMessage
# ---------------------------------------------------------------------------
# RegistrationData.is_complete()
# ---------------------------------------------------------------------------
class TestRegistrationDataIsComplete:
def test_complete_registration_passes(self, complete_registration):
assert complete_registration.is_complete() is True
def test_empty_registration_fails(self):
assert RegistrationData().is_complete() is False
def test_missing_child_name_fails(self, complete_registration):
complete_registration.child.full_name = None
assert complete_registration.is_complete() is False
def test_missing_dob_fails(self, complete_registration):
complete_registration.child.date_of_birth = None
assert complete_registration.is_complete() is False
def test_missing_special_needs_fails(self, complete_registration):
complete_registration.child.special_needs = None
assert complete_registration.is_complete() is False
def test_missing_parent_name_fails(self, complete_registration):
complete_registration.parent_guardian.full_name = None
assert complete_registration.is_complete() is False
def test_missing_parent_email_fails(self, complete_registration):
complete_registration.parent_guardian.email = None
assert complete_registration.is_complete() is False
def test_missing_emergency_contact_fails(self, complete_registration):
complete_registration.emergency_contact.full_name = None
assert complete_registration.is_complete() is False
def test_missing_booking_days_fails(self, complete_registration):
complete_registration.booking.selected_days = []
assert complete_registration.is_complete() is False
def test_missing_playgroup_types_fails(self, complete_registration):
complete_registration.booking.playgroup_types = []
assert complete_registration.is_complete() is False
# ---------------------------------------------------------------------------
# RegistrationData serialisation round-trip
# ---------------------------------------------------------------------------
class TestRegistrationDataSerialization:
def test_to_dict_contains_expected_keys(self, complete_registration):
d = complete_registration.to_dict()
assert "child" in d
assert "parentGuardian" in d
assert "emergencyContact" in d
assert "booking" in d
def test_to_dict_child_fields(self, complete_registration):
d = complete_registration.to_dict()
assert d["child"]["fullName"] == "Lena Muster"
assert d["child"]["dateOfBirth"] == "2022-03-15"
assert d["child"]["specialNeeds"] == "None"
def test_to_dict_parent_fields(self, complete_registration):
d = complete_registration.to_dict()
assert d["parentGuardian"]["email"] == "anna.muster@example.com"
assert d["parentGuardian"]["postalCode"] == "8117"
def test_to_dict_booking_fields(self, complete_registration):
d = complete_registration.to_dict()
assert d["booking"]["playgroupTypes"] == ["indoor"]
assert d["booking"]["selectedDays"] == [{"day": "monday", "type": "indoor"}]
def test_from_dict_round_trip(self, complete_registration):
d = complete_registration.to_dict()
restored = RegistrationData.from_dict(d)
assert restored.child.full_name == complete_registration.child.full_name
assert restored.parent_guardian.email == complete_registration.parent_guardian.email
assert restored.emergency_contact.phone == complete_registration.emergency_contact.phone
assert len(restored.booking.selected_days) == len(complete_registration.booking.selected_days)
def test_from_dict_outdoor_booking(self):
data = {
"child": {"fullName": "Tim", "dateOfBirth": "2021-01-01", "specialNeeds": "None"},
"parentGuardian": {
"fullName": "Eva", "streetAddress": "Seeweg 2", "postalCode": "8117",
"city": "Fällanden", "phone": "044 000 00 00", "email": "eva@example.com",
},
"emergencyContact": {"fullName": "Bob", "phone": "079 000 00 00"},
"booking": {
"playgroupTypes": ["outdoor"],
"selectedDays": [{"day": "monday", "type": "outdoor"}],
},
}
reg = RegistrationData.from_dict(data)
assert reg.booking.playgroup_types == ["outdoor"]
assert reg.booking.selected_days[0].day == "monday"
# ---------------------------------------------------------------------------
# ConversationState serialisation round-trip
# ---------------------------------------------------------------------------
class TestConversationStateSerialization:
def test_to_dict_contains_expected_keys(self, fresh_state):
d = fresh_state.to_dict()
assert "conversation_id" in d
assert "language" in d
assert "flow_step" in d
assert "messages" in d
assert "completed" in d
def test_default_language_is_german(self, fresh_state):
assert fresh_state.language == "de"
def test_default_flow_step_is_greeting(self, fresh_state):
assert fresh_state.flow_step == "greeting"
def test_default_completed_is_false(self, fresh_state):
assert fresh_state.completed is False
def test_from_dict_round_trip(self, state_with_messages):
state_with_messages.language = "en"
state_with_messages.flow_step = "parent_name"
d = state_with_messages.to_dict()
restored = ConversationState.from_dict(d)
assert restored.conversation_id == state_with_messages.conversation_id
assert restored.language == "en"
assert restored.flow_step == "parent_name"
assert len(restored.messages) == len(state_with_messages.messages)
def test_messages_serialized_with_role_and_content(self, state_with_messages):
d = state_with_messages.to_dict()
assert d["messages"][0]["role"] == "user"
assert "Hallo" in d["messages"][0]["content"]
+155
View File
@@ -0,0 +1,155 @@
"""Tests for AdminNotifier helper methods."""
import pytest
from src.notifications.notifier import AdminNotifier
from src.models.registration import RegistrationData, Booking, BookingDay
@pytest.fixture
def notifier():
return AdminNotifier(
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
use_tls=True,
from_email="agent@example.com",
)
# ---------------------------------------------------------------------------
# _recipients_for
# ---------------------------------------------------------------------------
class TestRecipientsFor:
def test_indoor_only_recipients(self, notifier):
recipients = notifier._recipients_for(["indoor"])
assert any("andrea" in r.lower() or "sigrist" in r.lower() for r in recipients)
def test_outdoor_only_recipients(self, notifier):
recipients = notifier._recipients_for(["outdoor"])
assert any("baba.laeubli" in r.lower() for r in recipients)
def test_both_includes_both_leaders(self, notifier):
recipients = notifier._recipients_for(["indoor", "outdoor"])
joined = " ".join(recipients).lower()
assert "andrea.sigrist" in joined
assert "baba.laeubli" in joined
# ---------------------------------------------------------------------------
# _format_types
# ---------------------------------------------------------------------------
class TestFormatTypes:
def test_indoor_label(self, notifier):
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
def test_outdoor_label(self, notifier):
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
def test_both_labels(self, notifier):
result = notifier._format_types(["indoor", "outdoor"])
assert len(result) > 0
# ---------------------------------------------------------------------------
# _calculate_age
# ---------------------------------------------------------------------------
class TestCalculateAge:
def test_returns_age_string(self, notifier):
result = notifier._calculate_age("2022-01-01")
assert isinstance(result, str)
assert len(result) > 0
def test_invalid_dob_returns_original_string(self, notifier):
result = notifier._calculate_age("not-a-date")
assert result == "not-a-date"
# ---------------------------------------------------------------------------
# _calculate_monthly_fee
# ---------------------------------------------------------------------------
class TestCalculateMonthlyFee:
def test_indoor_one_day(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "130" in fee
def test_indoor_two_days(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="wednesday", type="indoor"),
],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "260" in fee
def test_indoor_three_days(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="wednesday", type="indoor"),
BookingDay(day="thursday", type="indoor"),
],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "390" in fee
def test_outdoor_one_day(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["outdoor"],
selected_days=[BookingDay(day="monday", type="outdoor")],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "250" in fee
# ---------------------------------------------------------------------------
# _send — SMTP interaction
# ---------------------------------------------------------------------------
class TestSend:
def test_send_calls_smtp(self, notifier, mocker):
# _send uses smtplib.SMTP directly (not as context manager)
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier._send(
to=["admin@example.com"],
cc=["cc@example.com"],
subject="Test",
body="Hello",
)
mock_server.sendmail.assert_called_once()
def test_send_includes_all_recipients(self, notifier, mocker):
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier._send(
to=["a@example.com"],
cc=["b@example.com"],
subject="Test",
body="Hello",
)
call_args = mock_server.sendmail.call_args
recipients = call_args[0][1] # positional arg: to_addrs
assert "a@example.com" in recipients
assert "b@example.com" in recipients
+159
View File
@@ -0,0 +1,159 @@
"""Tests for ConversationStore and storage helpers."""
import json
from pathlib import Path
import pytest
from src.storage.json_store import (
ConversationStore,
normalize_email,
_diff_registrations,
)
from src.models.conversation import ConversationState
# ---------------------------------------------------------------------------
# normalize_email
# ---------------------------------------------------------------------------
class TestNormalizeEmail:
def test_lowercases(self):
assert normalize_email("Anna.Muster@Example.COM") == "anna.muster@example.com"
def test_strips_whitespace(self):
assert normalize_email(" user@example.com ") == "user@example.com"
def test_already_normalized(self):
assert normalize_email("user@example.com") == "user@example.com"
# ---------------------------------------------------------------------------
# _diff_registrations
# ---------------------------------------------------------------------------
class TestDiffRegistrations:
def test_detects_changed_field(self):
old = {"child": {"fullName": "Lena"}}
new = {"child": {"fullName": "Lena Muster"}}
diff = _diff_registrations(old, new)
assert "child.fullName" in diff
assert diff["child.fullName"] == ("Lena", "Lena Muster")
def test_unchanged_fields_not_included(self):
old = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
new = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
assert _diff_registrations(old, new) == {}
def test_nested_change_detected(self):
old = {"parentGuardian": {"email": "old@example.com"}}
new = {"parentGuardian": {"email": "new@example.com"}}
diff = _diff_registrations(old, new)
assert "parentGuardian.email" in diff
# ---------------------------------------------------------------------------
# ConversationStore — CRUD
# ---------------------------------------------------------------------------
@pytest.fixture
def store(tmp_path) -> ConversationStore:
return ConversationStore(tmp_path)
class TestConversationStoreCRUD:
def test_load_returns_none_for_unknown_email(self, store):
assert store.load("nobody@example.com") is None
def test_save_and_load_round_trip(self, store, fresh_state):
store.save(fresh_state)
loaded = store.load(fresh_state.parent_email)
assert loaded is not None
assert loaded.conversation_id == fresh_state.conversation_id
def test_save_overwrites_existing(self, store, fresh_state):
store.save(fresh_state)
fresh_state.language = "en"
store.save(fresh_state)
loaded = store.load(fresh_state.parent_email)
assert loaded.language == "en"
def test_delete_removes_conversation(self, store, fresh_state):
store.save(fresh_state)
store.delete(fresh_state.parent_email)
assert store.load(fresh_state.parent_email) is None
def test_delete_nonexistent_is_silent(self, store):
store.delete("ghost@example.com") # should not raise
def test_list_incomplete_returns_non_completed(self, store, fresh_state):
store.save(fresh_state)
incomplete = store.list_incomplete()
assert any(s.conversation_id == fresh_state.conversation_id for s in incomplete)
def test_list_incomplete_excludes_completed(self, store, fresh_state):
fresh_state.completed = True
store.save(fresh_state)
incomplete = store.list_incomplete()
assert all(not s.completed for s in incomplete)
def test_find_by_email_is_alias_for_load(self, store, fresh_state):
store.save(fresh_state)
assert store.find_by_email(fresh_state.parent_email) is not None
# ---------------------------------------------------------------------------
# ConversationStore — registration versioning
# ---------------------------------------------------------------------------
class TestRegistrationVersioning:
def test_save_registration_creates_version_1(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
email_key, version = store.save_registration(fresh_state)
assert version == 1
# email_key is the filesystem-safe form (@ → _at_)
assert email_key == "anna.muster_at_example.com"
def test_save_registration_writes_current_json(self, store, fresh_state, complete_registration, tmp_path):
fresh_state.registration = complete_registration
fresh_state.completed = True
email_key, _ = store.save_registration(fresh_state)
current = tmp_path / "registrations" / email_key / "current.json"
assert current.exists()
def test_save_registration_version_increments(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
_, v2 = store.save_registration_version(
fresh_state, {"child.fullName": ("Old", "New")}
)
assert v2 == 2
def test_get_current_registration_returns_latest(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
current = store.get_current_registration(fresh_state.parent_email)
assert current is not None
assert current["metadata"]["version"] == 1
def test_get_registration_history_returns_all_versions(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
store.save_registration_version(fresh_state, {"child.fullName": ("A", "B")})
history = store.get_registration_history(fresh_state.parent_email)
assert len(history) == 2
def test_list_registrations_includes_saved(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
registrations = store.list_registrations()
assert len(registrations) == 1