Files
ss-tools/backend/tests/api/test_assistant_history.py

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