2026-02-21 08:07:31 +00:00
|
|
|
"""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
|
2026-02-22 21:23:32 +00:00
|
|
|
|
|
|
|
|
def test_save_registration_persists_language(self, store, fresh_state, complete_registration):
|
|
|
|
|
fresh_state.registration = complete_registration
|
|
|
|
|
fresh_state.completed = True
|
|
|
|
|
fresh_state.language = "en"
|
|
|
|
|
store.save_registration(fresh_state)
|
|
|
|
|
current = store.get_current_registration(fresh_state.parent_email)
|
|
|
|
|
assert current is not None
|
|
|
|
|
assert current["metadata"]["language"] == "en"
|
|
|
|
|
|
|
|
|
|
def test_save_registration_defaults_language_to_de(self, store, fresh_state, complete_registration):
|
|
|
|
|
fresh_state.registration = complete_registration
|
|
|
|
|
fresh_state.completed = True
|
|
|
|
|
# language defaults to "de" in ConversationState
|
|
|
|
|
store.save_registration(fresh_state)
|
|
|
|
|
current = store.get_current_registration(fresh_state.parent_email)
|
|
|
|
|
assert current["metadata"]["language"] == "de"
|