445 lines
17 KiB
Python
445 lines
17 KiB
Python
# #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
|