# #region Test.Assistant.History [C:2] [TYPE Module] [SEMANTICS test,assistant,history,audit,confirmation] # @BRIEF Tests for _history.py — conversation history, audit trail, confirmation persistence helpers. # @RELATION BINDS_TO -> [AssistantHistory] # @TEST_EDGE: append_history -> In-memory history appends correctly # @TEST_EDGE: persist_message -> DB persistence with rollback on failure # @TEST_EDGE: audit -> In-memory audit recording # @TEST_EDGE: persist_audit -> DB audit persistence # @TEST_EDGE: persist_confirmation -> Confirmation token persistence # @TEST_EDGE: update_confirmation_state -> State transitions # @TEST_EDGE: load_confirmation_from_db -> Confirmation loading # @TEST_EDGE: ensure_conversation -> Conversation ID resolution # @TEST_EDGE: resolve_or_create_conversation -> DB-backed conversation resolution # @TEST_EDGE: cleanup_history_ttl -> TTL-based cleanup # @TEST_EDGE: is_conversation_archived -> Archive detection # @TEST_EDGE: coerce_query_bool -> Boolean coercion from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch import uuid import pytest class TestAppendHistory: """_append_history — in-memory conversation buffer.""" def test_append_new_conversation(self): from datetime import UTC, datetime from src.api.routes.assistant._schemas import CONVERSATIONS from src.api.routes.assistant._history import _append_history CONVERSATIONS.clear() _append_history("user-1", "conv-1", "user", "Hello") key = ("user-1", "conv-1") assert key in CONVERSATIONS assert len(CONVERSATIONS[key]) == 1 assert CONVERSATIONS[key][0]["role"] == "user" assert CONVERSATIONS[key][0]["text"] == "Hello" def test_append_existing_conversation(self): from src.api.routes.assistant._schemas import CONVERSATIONS from src.api.routes.assistant._history import _append_history CONVERSATIONS.clear() _append_history("user-1", "conv-1", "user", "First") _append_history("user-1", "conv-1", "assistant", "Response") key = ("user-1", "conv-1") assert len(CONVERSATIONS[key]) == 2 assert CONVERSATIONS[key][0]["role"] == "user" assert CONVERSATIONS[key][1]["role"] == "assistant" def test_append_with_state_and_task(self): from src.api.routes.assistant._schemas import CONVERSATIONS from src.api.routes.assistant._history import _append_history CONVERSATIONS.clear() _append_history("user-1", "conv-1", "assistant", "Done", state="success", task_id="task-42") key = ("user-1", "conv-1") assert CONVERSATIONS[key][0]["state"] == "success" assert CONVERSATIONS[key][0]["task_id"] == "task-42" def test_message_id_is_uuid(self): from src.api.routes.assistant._schemas import CONVERSATIONS from src.api.routes.assistant._history import _append_history CONVERSATIONS.clear() _append_history("user-1", "conv-1", "user", "test") mid = CONVERSATIONS[("user-1", "conv-1")][0]["message_id"] assert uuid.UUID(mid) class TestPersistMessage: """_persist_message — DB persistence.""" def test_persist_success(self): from src.api.routes.assistant._history import _persist_message db = MagicMock() _persist_message(db, "user-1", "conv-1", "user", "Hello", state="active") db.add.assert_called_once() db.commit.assert_called_once() def test_persist_with_metadata(self): from src.api.routes.assistant._history import _persist_message db = MagicMock() _persist_message(db, "user-1", "conv-1", "user", "test", metadata={"key": "val"}) # Verify the payload field was set added = db.add.call_args[0][0] assert added.payload == {"key": "val"} def test_persist_failure_rollback(self): from src.api.routes.assistant._history import _persist_message db = MagicMock() db.commit.side_effect = Exception("DB error") # Should not raise — just log warning _persist_message(db, "user-1", "conv-1", "user", "Hello") db.rollback.assert_called_once() class TestAudit: """_audit — in-memory audit recording.""" def test_audit_new_user(self): from src.api.routes.assistant._schemas import ASSISTANT_AUDIT from src.api.routes.assistant._history import _audit ASSISTANT_AUDIT.clear() _audit("user-1", {"decision": "executed", "task_id": "task-42"}) assert "user-1" in ASSISTANT_AUDIT assert len(ASSISTANT_AUDIT["user-1"]) == 1 entry = ASSISTANT_AUDIT["user-1"][0] assert entry["decision"] == "executed" assert "created_at" in entry def test_audit_append(self): from src.api.routes.assistant._schemas import ASSISTANT_AUDIT from src.api.routes.assistant._history import _audit ASSISTANT_AUDIT.clear() _audit("user-1", {"decision": "first"}) _audit("user-1", {"decision": "second"}) assert len(ASSISTANT_AUDIT["user-1"]) == 2 class TestPersistAudit: """_persist_audit — DB audit persistence.""" def test_persist_audit_success(self): from src.api.routes.assistant._history import _persist_audit db = MagicMock() _persist_audit(db, "user-1", {"decision": "executed"}, "conv-1") db.add.assert_called_once() db.commit.assert_called_once() def test_persist_audit_failure(self): from src.api.routes.assistant._history import _persist_audit db = MagicMock() db.commit.side_effect = Exception("DB error") _persist_audit(db, "user-1", {"decision": "executed"}, "conv-1") db.rollback.assert_called_once() class TestPersistConfirmation: """_persist_confirmation — Confirmation token DB persistence.""" def test_persist_confirmation_success(self): from src.api.routes.assistant._history import _persist_confirmation from src.api.routes.assistant._schemas import ConfirmationRecord db = MagicMock() record = ConfirmationRecord( id="conf-1", user_id="user-1", conversation_id="conv-1", state="pending", intent={"op": "test"}, dispatch={}, expires_at=datetime.now(UTC) + timedelta(minutes=5), created_at=datetime.now(UTC), ) _persist_confirmation(db, record) db.merge.assert_called_once() db.commit.assert_called_once() def test_persist_confirmation_failure(self): from src.api.routes.assistant._history import _persist_confirmation from src.api.routes.assistant._schemas import ConfirmationRecord db = MagicMock() db.commit.side_effect = Exception("DB error") record = ConfirmationRecord( id="conf-1", user_id="user-1", conversation_id="conv-1", state="pending", intent={"op": "test"}, dispatch={}, expires_at=datetime.now(UTC) + timedelta(minutes=5), created_at=datetime.now(UTC), ) _persist_confirmation(db, record) db.rollback.assert_called_once() class TestUpdateConfirmationState: """_update_confirmation_state — confirmation lifecycle.""" def test_update_to_consumed(self): from src.api.routes.assistant._history import _update_confirmation_state from src.models.assistant import AssistantConfirmationRecord db = MagicMock() row = MagicMock(spec=AssistantConfirmationRecord) row.state = "pending" row.consumed_at = None db.query.return_value.filter.return_value.first.return_value = row _update_confirmation_state(db, "conf-1", "consumed") assert row.state == "consumed" assert row.consumed_at is not None db.commit.assert_called_once() def test_update_not_found(self): from src.api.routes.assistant._history import _update_confirmation_state db = MagicMock() db.query.return_value.filter.return_value.first.return_value = None # Should not raise _update_confirmation_state(db, "conf-nonexistent", "consumed") db.commit.assert_not_called() def test_update_failure_rollback(self): from src.api.routes.assistant._history import _update_confirmation_state from src.models.assistant import AssistantConfirmationRecord db = MagicMock() row = MagicMock(spec=AssistantConfirmationRecord) row.state = "pending" db.query.return_value.filter.return_value.first.return_value = row db.commit.side_effect = Exception("DB error") _update_confirmation_state(db, "conf-1", "consumed") db.rollback.assert_called_once() class TestLoadConfirmationFromDb: """_load_confirmation_from_db — confirmation loading.""" def test_load_found(self): from src.api.routes.assistant._history import _load_confirmation_from_db from src.models.assistant import AssistantConfirmationRecord db = MagicMock() mock_row = MagicMock(spec=AssistantConfirmationRecord) mock_row.id = "conf-1" mock_row.user_id = "user-1" mock_row.conversation_id = "conv-1" mock_row.intent = {"op": "test"} mock_row.dispatch = {} mock_row.state = "pending" mock_row.expires_at = datetime.now(UTC) + timedelta(hours=1) mock_row.created_at = datetime.now(UTC) db.query.return_value.filter.return_value.first.return_value = mock_row record = _load_confirmation_from_db(db, "conf-1") assert record is not None assert record.id == "conf-1" assert record.user_id == "user-1" assert record.intent == {"op": "test"} def test_load_not_found(self): from src.api.routes.assistant._history import _load_confirmation_from_db db = MagicMock() db.query.return_value.filter.return_value.first.return_value = None record = _load_confirmation_from_db(db, "conf-nonexistent") assert record is None class TestEnsureConversation: """_ensure_conversation — conversation ID resolution.""" def test_with_explicit_id(self): from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION from src.api.routes.assistant._history import _ensure_conversation USER_ACTIVE_CONVERSATION.clear() result = _ensure_conversation("user-1", "conv-1") assert result == "conv-1" assert USER_ACTIVE_CONVERSATION.get("user-1") == "conv-1" def test_with_active_conversation(self): from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION from src.api.routes.assistant._history import _ensure_conversation USER_ACTIVE_CONVERSATION.clear() USER_ACTIVE_CONVERSATION["user-1"] = "existing-conv" result = _ensure_conversation("user-1", None) assert result == "existing-conv" def test_creates_new(self): from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION from src.api.routes.assistant._history import _ensure_conversation USER_ACTIVE_CONVERSATION.clear() result = _ensure_conversation("user-1", None) assert result is not None assert uuid.UUID(result) assert USER_ACTIVE_CONVERSATION.get("user-1") == result class TestResolveOrCreateConversation: """_resolve_or_create_conversation — DB-backed conversation resolution.""" def test_with_explicit_id(self): from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION from src.api.routes.assistant._history import _resolve_or_create_conversation USER_ACTIVE_CONVERSATION.clear() db = MagicMock() result = _resolve_or_create_conversation("user-1", "conv-1", db) assert result == "conv-1" def test_with_active_memory(self): from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION from src.api.routes.assistant._history import _resolve_or_create_conversation USER_ACTIVE_CONVERSATION.clear() USER_ACTIVE_CONVERSATION["user-1"] = "mem-conv" db = MagicMock() result = _resolve_or_create_conversation("user-1", None, db) assert result == "mem-conv" def test_creates_new_without_db_hit(self): from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION from src.api.routes.assistant._history import _resolve_or_create_conversation USER_ACTIVE_CONVERSATION.clear() db = MagicMock() db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None result = _resolve_or_create_conversation("user-1", None, db) assert result is not None assert uuid.UUID(result) class TestCleanupHistoryTtl: """_cleanup_history_ttl — TTL-based history cleanup.""" def test_cleanup_deletes_expired_db_rows(self): from src.api.routes.assistant._history import _cleanup_history_ttl db = MagicMock() db.query.return_value.filter.return_value = MagicMock() db.query.return_value.filter.return_value.filter.return_value.delete.return_value = 5 _cleanup_history_ttl(db, "user-1") db.commit.assert_called_once() def test_cleanup_failure_rollback(self): from src.api.routes.assistant._history import _cleanup_history_ttl db = MagicMock() # The second filter (for TTL) raises q = MagicMock() q.filter.return_value = q # chain .filter().filter() q.delete.side_effect = Exception("DB error") db.query.return_value = q _cleanup_history_ttl(db, "user-1") db.rollback.assert_called_once() def test_cleanup_clears_stale_in_memory(self): from datetime import UTC, datetime, timedelta from src.api.routes.assistant._schemas import CONVERSATIONS from src.api.routes.assistant._history import _cleanup_history_ttl CONVERSATIONS.clear() old_time = datetime.now() - timedelta(days=400) # Add a stale entry CONVERSATIONS[("user-1", "old-conv")] = [ {"created_at": old_time, "role": "user", "text": "old"} ] # Add a recent entry CONVERSATIONS[("user-1", "new-conv")] = [ {"created_at": datetime.now(UTC), "role": "user", "text": "new"} ] db = MagicMock() db.query.return_value.filter.return_value = MagicMock() db.query.return_value.filter.return_value.filter.return_value.delete.return_value = 0 db.query.return_value.filter.return_value.filter.return_value = db.query.return_value.filter.return_value _cleanup_history_ttl(db, "user-1") # Old conversation should be removed assert ("user-1", "old-conv") not in CONVERSATIONS # New conversation should be kept assert ("user-1", "new-conv") in CONVERSATIONS class TestIsConversationArchived: """_is_conversation_archived — archive detection.""" def test_updated_at_none(self): from src.api.routes.assistant._history import _is_conversation_archived assert _is_conversation_archived(None) is False def test_recent_conversation(self): from datetime import datetime from src.api.routes.assistant._history import _is_conversation_archived recent = datetime.now() assert _is_conversation_archived(recent) is False def test_old_conversation(self): from datetime import datetime, timedelta from src.api.routes.assistant._history import _is_conversation_archived old = datetime.now() - timedelta(days=100) assert _is_conversation_archived(old) is True def test_aware_datetime(self): from datetime import UTC, datetime, timedelta from src.api.routes.assistant._history import _is_conversation_archived old = datetime.now(UTC) - timedelta(days=100) assert _is_conversation_archived(old) is True class TestCoerceQueryBool: """_coerce_query_bool — boolean normalization.""" def test_bool_values(self): from src.api.routes.assistant._history import _coerce_query_bool assert _coerce_query_bool(True) is True assert _coerce_query_bool(False) is False @pytest.mark.parametrize("val,expected", [ ("1", True), ("true", True), ("yes", True), ("on", True), ("0", False), ("false", False), ("no", False), ("off", False), ("", False), ]) def test_string_values(self, val, expected): from src.api.routes.assistant._history import _coerce_query_bool assert _coerce_query_bool(val) is expected def test_non_bool_non_string(self): from src.api.routes.assistant._history import _coerce_query_bool assert _coerce_query_bool(None) is False assert _coerce_query_bool(42) is False assert _coerce_query_bool([]) is False # #endregion Test.Assistant.History