mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: add RedisContextProvider (#716)
* Setting up * Readme * Add redis tests path to all-tests * First pass integration * Keep provider convention * First pass integration * add redis integration tests * update README.md * Add basic sample for redis integration * Add partitioning, add partition-aware tests, improve sample script * Fix code quality check * Try to resolve pytest check * Try to identify if pytest is the cause of failed checks * Re-enable tests * Rename redis test file * Removing some tests to narrow down issue * Revert, no difference * Delete temp files * Starting refactor of RedisProvider * Build dynamic schema builder, still need to do dynamic embedding model config * Add scope control * Complete first pass functionality with OpenAI + HF vectors -> Tests, Samples, Demo to follow * Fix code quality * attempt to identify rootcause of failed test * attempt to identify rootcause of failed test * Attempt to resolve code quality fail * Update pyproject.toml for foundry to pin azure-ai-projects == 1.1.0b3,azure-ai-agents == 1.2.0b3 * Add tests for redisprovider * Remove invalid tests * Add API key handling for openai vectorizer * Update uv.locl * Use master uv.lock * Begin sample file, add lazy index creation, fix faulty override * Index drop and reinit depends on drop_redis_index not overwrite * Add samples, threading included, escaped queries, verify threading works, sample README.md * Refactor filters * Opinionated vars * Allow filter expression combination * Try inline stubs for mypy * Address mypy errors * Better docstrings, tweaks for feedback * Tweak example 3 in redis_threads.py sample * adjust confusing name * Enrich docstrings * Add descriptions and comments to samples, externalize vectorizer choice, remove nltk and sentencetransformers dependnecy * Add descriptions and comments to samples, externalize vectorizer choice, remove nltk and sentencetransformers dependnecy * Incorporate initial feedback from dmytrostruk * Fix uv.lock * Attempt to resolve conflict * Use remote .tomls * Sanity check * fix tests * Remove hardcoded API key from samples * Fix incorrect env var * Make add and redis_search private * Fix tests relying on private funcs * Expand tests * Explainer comments to each test * Add a 'get_conversation_history' function to RedisProvider - This just returns messages in sequential order. Added 'created_at_*' timestamps to facilitate sequential recovery. function has to be manually invoked by user * Add agent-framework-redis to python/pyproject.toml * Remove get_conversation_history * improve redis context provider with pydantic techniques and safe index handling patterns * add RedisChatMessageStore * remove integration test :( * fix mypy error * Remove unused params * Redo schema validation to be order-invariant, handle attrs (previously throwing errors due to strict ==) * Expand explanation * Add ChatMessageStore example * Fix comments in redis_conversation.py * Resolving uv.lock conflict, update to match main * Fix test in redis provider * Apply suggestion from @ekzhu * Update python/packages/main/pyproject.toml --------- Co-authored-by: Tyler Hutcherson <tyler.hutcherson@redis.com> Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, Role, TextContent
|
||||
|
||||
from agent_framework_redis import RedisChatMessageStore
|
||||
|
||||
|
||||
class TestRedisChatMessageStore:
|
||||
"""Unit tests for RedisChatMessageStore using mocked Redis client.
|
||||
|
||||
These tests use mocked Redis operations to verify the logic and behavior
|
||||
of the RedisChatMessageStore without requiring a real Redis server.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_messages(self):
|
||||
"""Sample chat messages for testing."""
|
||||
return [
|
||||
ChatMessage(role=Role.USER, text="Hello", message_id="msg1"),
|
||||
ChatMessage(role=Role.ASSISTANT, text="Hi there!", message_id="msg2"),
|
||||
ChatMessage(role=Role.USER, text="How are you?", message_id="msg3"),
|
||||
]
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self):
|
||||
"""Mock Redis client with all required methods."""
|
||||
client = MagicMock()
|
||||
# Core list operations
|
||||
client.lrange = AsyncMock(return_value=[])
|
||||
client.llen = AsyncMock(return_value=0)
|
||||
client.lindex = AsyncMock(return_value=None)
|
||||
client.lset = AsyncMock(return_value=True)
|
||||
client.lrem = AsyncMock(return_value=0)
|
||||
client.lpop = AsyncMock(return_value=None)
|
||||
client.rpop = AsyncMock(return_value=None)
|
||||
client.ltrim = AsyncMock(return_value=True)
|
||||
client.delete = AsyncMock(return_value=1)
|
||||
|
||||
# Pipeline operations
|
||||
mock_pipeline = AsyncMock()
|
||||
mock_pipeline.rpush = AsyncMock()
|
||||
mock_pipeline.execute = AsyncMock()
|
||||
client.pipeline.return_value.__aenter__.return_value = mock_pipeline
|
||||
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def redis_store(self, mock_redis_client):
|
||||
"""Redis chat message store with mocked client."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url:
|
||||
mock_from_url.return_value = mock_redis_client
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test_thread_123")
|
||||
store._redis_client = mock_redis_client
|
||||
return store
|
||||
|
||||
def test_init_with_thread_id(self):
|
||||
"""Test initialization with explicit thread ID."""
|
||||
thread_id = "user123_session456"
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url"):
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id=thread_id)
|
||||
|
||||
assert store.thread_id == thread_id
|
||||
assert store.redis_url == "redis://localhost:6379"
|
||||
assert store.key_prefix == "chat_messages"
|
||||
assert store.redis_key == f"chat_messages:{thread_id}"
|
||||
|
||||
def test_init_auto_generate_thread_id(self):
|
||||
"""Test initialization with auto-generated thread ID."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url"):
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379")
|
||||
|
||||
assert store.thread_id is not None
|
||||
assert store.thread_id.startswith("thread_")
|
||||
assert len(store.thread_id) > 10 # Should be a UUID
|
||||
|
||||
def test_init_with_custom_prefix(self):
|
||||
"""Test initialization with custom key prefix."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url"):
|
||||
store = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379", thread_id="test123", key_prefix="custom_messages"
|
||||
)
|
||||
|
||||
assert store.redis_key == "custom_messages:test123"
|
||||
|
||||
def test_init_with_max_messages(self):
|
||||
"""Test initialization with message limit."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url"):
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123", max_messages=100)
|
||||
|
||||
assert store.max_messages == 100
|
||||
|
||||
def test_init_with_redis_url_required(self):
|
||||
"""Test that redis_url is required for initialization."""
|
||||
with pytest.raises(ValueError, match="redis_url is required for Redis connection"):
|
||||
# Should raise an exception since redis_url is required
|
||||
RedisChatMessageStore(thread_id="test123")
|
||||
|
||||
def test_init_with_initial_messages(self, sample_messages):
|
||||
"""Test initialization with initial messages."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url"):
|
||||
store = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379", thread_id="test123", messages=sample_messages
|
||||
)
|
||||
|
||||
assert store._initial_messages == sample_messages
|
||||
|
||||
async def test_add_messages_single(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test adding a single message using pipeline operations."""
|
||||
message = sample_messages[0]
|
||||
|
||||
await redis_store.add_messages([message])
|
||||
|
||||
# Verify pipeline operations were called
|
||||
mock_redis_client.pipeline.assert_called_with(transaction=True)
|
||||
|
||||
# Get the pipeline mock and verify it was used correctly
|
||||
pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value
|
||||
pipeline_mock.rpush.assert_called()
|
||||
pipeline_mock.execute.assert_called()
|
||||
|
||||
async def test_add_messages_multiple(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test adding multiple messages using pipeline operations."""
|
||||
await redis_store.add_messages(sample_messages)
|
||||
|
||||
# Verify pipeline operations
|
||||
mock_redis_client.pipeline.assert_called_with(transaction=True)
|
||||
|
||||
# Verify rpush was called for each message
|
||||
pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value
|
||||
assert pipeline_mock.rpush.call_count == len(sample_messages)
|
||||
|
||||
async def test_add_messages_with_max_limit(self, mock_redis_client):
|
||||
"""Test adding messages with max limit triggers trimming."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url:
|
||||
mock_from_url.return_value = mock_redis_client
|
||||
|
||||
# Mock llen to return count that exceeds limit after adding
|
||||
mock_redis_client.llen.return_value = 5
|
||||
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123", max_messages=3)
|
||||
store._redis_client = mock_redis_client
|
||||
|
||||
message = ChatMessage(role=Role.USER, text="Test")
|
||||
await store.add_messages([message])
|
||||
|
||||
# Should trim after adding to keep only last 3 messages
|
||||
mock_redis_client.ltrim.assert_called_once_with("chat_messages:test123", -3, -1)
|
||||
|
||||
async def test_list_messages_empty(self, redis_store, mock_redis_client):
|
||||
"""Test listing messages when store is empty."""
|
||||
mock_redis_client.lrange.return_value = []
|
||||
|
||||
messages = await redis_store.list_messages()
|
||||
|
||||
assert messages == []
|
||||
mock_redis_client.lrange.assert_called_once_with("chat_messages:test_thread_123", 0, -1)
|
||||
|
||||
async def test_list_messages_with_data(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test listing messages with data in Redis."""
|
||||
# Create proper serialized messages using the actual serialization method
|
||||
test_messages = [
|
||||
ChatMessage(role=Role.USER, text="Hello", message_id="msg1"),
|
||||
ChatMessage(role=Role.ASSISTANT, text="Hi there!", message_id="msg2"),
|
||||
]
|
||||
serialized_messages = [redis_store._serialize_message(msg) for msg in test_messages]
|
||||
mock_redis_client.lrange.return_value = serialized_messages
|
||||
|
||||
messages = await redis_store.list_messages()
|
||||
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == Role.USER
|
||||
assert messages[0].text == "Hello"
|
||||
assert messages[1].role == Role.ASSISTANT
|
||||
assert messages[1].text == "Hi there!"
|
||||
|
||||
async def test_list_messages_with_initial_messages(self, sample_messages):
|
||||
"""Test that initial messages are added to Redis and retrieved correctly."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url:
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_client.llen = AsyncMock(return_value=0) # Redis key is empty
|
||||
mock_redis_client.lrange = AsyncMock(return_value=[])
|
||||
|
||||
# Mock pipeline for adding initial messages
|
||||
mock_pipeline = AsyncMock()
|
||||
mock_pipeline.rpush = AsyncMock()
|
||||
mock_pipeline.execute = AsyncMock()
|
||||
mock_redis_client.pipeline.return_value.__aenter__.return_value = mock_pipeline
|
||||
|
||||
mock_from_url.return_value = mock_redis_client
|
||||
|
||||
store = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
thread_id="test123",
|
||||
messages=sample_messages[:1], # One initial message
|
||||
)
|
||||
store._redis_client = mock_redis_client
|
||||
|
||||
# Mock Redis to return the initial message after it's added
|
||||
initial_message_json = store._serialize_message(sample_messages[0])
|
||||
mock_redis_client.lrange.return_value = [initial_message_json]
|
||||
|
||||
messages = await store.list_messages()
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].text == "Hello"
|
||||
# Verify initial message was added to Redis via pipeline
|
||||
mock_pipeline.rpush.assert_called()
|
||||
|
||||
async def test_initial_messages_not_added_if_key_exists(self, sample_messages):
|
||||
"""Test that initial messages are not added if Redis key already has data."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url:
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_client.llen = AsyncMock(return_value=5) # Key already has messages
|
||||
mock_redis_client.lrange = AsyncMock(return_value=[])
|
||||
|
||||
# Pipeline should not be called since key already exists
|
||||
mock_pipeline = AsyncMock()
|
||||
mock_pipeline.rpush = AsyncMock()
|
||||
mock_pipeline.execute = AsyncMock()
|
||||
mock_redis_client.pipeline.return_value.__aenter__.return_value = mock_pipeline
|
||||
|
||||
mock_from_url.return_value = mock_redis_client
|
||||
|
||||
store = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
thread_id="test123",
|
||||
messages=sample_messages[:1], # One initial message
|
||||
)
|
||||
store._redis_client = mock_redis_client
|
||||
|
||||
await store.list_messages()
|
||||
|
||||
# Should check length but not add messages since key exists
|
||||
mock_redis_client.llen.assert_called()
|
||||
mock_pipeline.rpush.assert_not_called()
|
||||
|
||||
async def test_serialize_state(self, redis_store):
|
||||
"""Test state serialization."""
|
||||
state = await redis_store.serialize_state()
|
||||
|
||||
expected_state = {
|
||||
"thread_id": "test_thread_123",
|
||||
"redis_url": "redis://localhost:6379",
|
||||
"key_prefix": "chat_messages",
|
||||
"max_messages": None,
|
||||
}
|
||||
|
||||
assert state == expected_state
|
||||
|
||||
async def test_deserialize_state(self, redis_store):
|
||||
"""Test state deserialization."""
|
||||
serialized_state = {
|
||||
"thread_id": "restored_thread_456",
|
||||
"redis_url": "redis://localhost:6380",
|
||||
"key_prefix": "restored_messages",
|
||||
"max_messages": 50,
|
||||
}
|
||||
|
||||
await redis_store.deserialize_state(serialized_state)
|
||||
|
||||
assert redis_store.thread_id == "restored_thread_456"
|
||||
assert redis_store.redis_url == "redis://localhost:6380"
|
||||
assert redis_store.key_prefix == "restored_messages"
|
||||
assert redis_store.max_messages == 50
|
||||
|
||||
async def test_deserialize_state_empty(self, redis_store):
|
||||
"""Test deserializing empty state doesn't change anything."""
|
||||
original_thread_id = redis_store.thread_id
|
||||
|
||||
await redis_store.deserialize_state(None)
|
||||
|
||||
assert redis_store.thread_id == original_thread_id
|
||||
|
||||
async def test_clear_messages(self, redis_store, mock_redis_client):
|
||||
"""Test clearing all messages."""
|
||||
await redis_store.clear()
|
||||
|
||||
mock_redis_client.delete.assert_called_once_with("chat_messages:test_thread_123")
|
||||
|
||||
async def test_message_serialization_roundtrip(self, sample_messages):
|
||||
"""Test message serialization and deserialization roundtrip."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url"):
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123")
|
||||
|
||||
message = sample_messages[0]
|
||||
|
||||
# Test serialization
|
||||
serialized = store._serialize_message(message)
|
||||
assert isinstance(serialized, str)
|
||||
|
||||
# Test deserialization
|
||||
deserialized = store._deserialize_message(serialized)
|
||||
assert deserialized.role == message.role
|
||||
assert deserialized.text == message.text
|
||||
assert deserialized.message_id == message.message_id
|
||||
|
||||
async def test_message_serialization_with_complex_content(self):
|
||||
"""Test serialization of messages with complex content."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url"):
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123")
|
||||
|
||||
# Message with multiple content types
|
||||
message = ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[TextContent(text="Hello"), TextContent(text="World")],
|
||||
author_name="TestBot",
|
||||
message_id="complex_msg",
|
||||
additional_properties={"metadata": "test"},
|
||||
)
|
||||
|
||||
serialized = store._serialize_message(message)
|
||||
deserialized = store._deserialize_message(serialized)
|
||||
|
||||
assert deserialized.role == Role.ASSISTANT
|
||||
assert deserialized.text == "Hello World"
|
||||
assert deserialized.author_name == "TestBot"
|
||||
assert deserialized.message_id == "complex_msg"
|
||||
assert deserialized.additional_properties == {"metadata": "test"}
|
||||
|
||||
async def test_redis_connection_error_handling(self):
|
||||
"""Test handling Redis connection errors in add_messages."""
|
||||
with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url:
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Mock pipeline to raise exception during execution
|
||||
mock_pipeline = AsyncMock()
|
||||
mock_pipeline.rpush = AsyncMock()
|
||||
mock_pipeline.execute = AsyncMock(side_effect=Exception("Connection failed"))
|
||||
mock_client.pipeline.return_value.__aenter__.return_value = mock_pipeline
|
||||
|
||||
mock_from_url.return_value = mock_client
|
||||
|
||||
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123")
|
||||
store._redis_client = mock_client
|
||||
|
||||
message = ChatMessage(role=Role.USER, text="Test")
|
||||
|
||||
# Should propagate Redis connection errors
|
||||
with pytest.raises(Exception, match="Connection failed"):
|
||||
await store.add_messages([message])
|
||||
|
||||
async def test_getitem(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test getitem method using Redis LINDEX."""
|
||||
# Mock LINDEX to return specific messages
|
||||
serialized_msg0 = redis_store._serialize_message(sample_messages[0])
|
||||
serialized_msg1 = redis_store._serialize_message(sample_messages[1])
|
||||
|
||||
def mock_lindex(key, index):
|
||||
if index == 0:
|
||||
return serialized_msg0
|
||||
if index == -1 or index == 1:
|
||||
return serialized_msg1
|
||||
return None
|
||||
|
||||
mock_redis_client.lindex = AsyncMock(side_effect=mock_lindex)
|
||||
|
||||
# Test positive index
|
||||
message = await redis_store.getitem(0)
|
||||
assert message.text == "Hello"
|
||||
|
||||
# Test negative index
|
||||
message = await redis_store.getitem(-1)
|
||||
assert message.text == "Hi there!"
|
||||
|
||||
async def test_getitem_index_error(self, redis_store, mock_redis_client):
|
||||
"""Test getitem raises IndexError for invalid index."""
|
||||
mock_redis_client.lindex = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(IndexError):
|
||||
await redis_store.getitem(0)
|
||||
|
||||
async def test_setitem(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test setitem method using Redis LSET."""
|
||||
mock_redis_client.llen.return_value = 2
|
||||
mock_redis_client.lset = AsyncMock()
|
||||
|
||||
new_message = ChatMessage(role=Role.USER, text="Updated message")
|
||||
await redis_store.setitem(0, new_message)
|
||||
|
||||
mock_redis_client.lset.assert_called_once()
|
||||
call_args = mock_redis_client.lset.call_args
|
||||
assert call_args[0][0] == "chat_messages:test_thread_123"
|
||||
assert call_args[0][1] == 0
|
||||
|
||||
async def test_setitem_index_error(self, redis_store, mock_redis_client):
|
||||
"""Test setitem raises IndexError for invalid index."""
|
||||
mock_redis_client.llen.return_value = 0
|
||||
|
||||
new_message = ChatMessage(role=Role.USER, text="Test")
|
||||
with pytest.raises(IndexError):
|
||||
await redis_store.setitem(0, new_message)
|
||||
|
||||
async def test_append(self, redis_store, mock_redis_client):
|
||||
"""Test append method delegates to add_messages."""
|
||||
message = ChatMessage(role=Role.USER, text="Appended message")
|
||||
await redis_store.append(message)
|
||||
|
||||
# Should call pipeline operations via add_messages
|
||||
mock_redis_client.pipeline.assert_called_with(transaction=True)
|
||||
|
||||
# Verify the message was added via pipeline
|
||||
pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value
|
||||
pipeline_mock.rpush.assert_called()
|
||||
pipeline_mock.execute.assert_called()
|
||||
|
||||
async def test_count(self, redis_store, mock_redis_client):
|
||||
"""Test count method."""
|
||||
mock_redis_client.llen.return_value = 5
|
||||
|
||||
count = await redis_store.count()
|
||||
|
||||
assert count == 5
|
||||
mock_redis_client.llen.assert_called_with("chat_messages:test_thread_123")
|
||||
|
||||
async def test_len_method(self, redis_store, mock_redis_client):
|
||||
"""Test async __len__ method."""
|
||||
mock_redis_client.llen.return_value = 3
|
||||
|
||||
length = await redis_store.__len__()
|
||||
|
||||
assert length == 3
|
||||
mock_redis_client.llen.assert_called_with("chat_messages:test_thread_123")
|
||||
|
||||
def test_bool_method(self, redis_store):
|
||||
"""Test __bool__ method always returns True."""
|
||||
# Store should always be truthy
|
||||
assert bool(redis_store) is True
|
||||
assert redis_store.__bool__() is True
|
||||
|
||||
# Should work in if statements (this is what Agent Framework uses)
|
||||
if redis_store:
|
||||
assert True # Should reach this
|
||||
else:
|
||||
raise AssertionError("Store should be truthy")
|
||||
|
||||
async def test_index_found(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test index method when message is found using Redis LINDEX."""
|
||||
mock_redis_client.llen.return_value = 2
|
||||
|
||||
# Mock LINDEX to return messages at each position
|
||||
serialized_msg0 = redis_store._serialize_message(sample_messages[0])
|
||||
serialized_msg1 = redis_store._serialize_message(sample_messages[1])
|
||||
|
||||
def mock_lindex(key, index):
|
||||
if index == 0:
|
||||
return serialized_msg0
|
||||
if index == 1:
|
||||
return serialized_msg1
|
||||
return None
|
||||
|
||||
mock_redis_client.lindex = AsyncMock(side_effect=mock_lindex)
|
||||
|
||||
index = await redis_store.index(sample_messages[1])
|
||||
assert index == 1
|
||||
|
||||
# Should have called lindex twice (index 0, then index 1)
|
||||
assert mock_redis_client.lindex.call_count == 2
|
||||
|
||||
async def test_index_not_found(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test index method when message is not found."""
|
||||
mock_redis_client.llen.return_value = 1
|
||||
mock_redis_client.lindex = AsyncMock(return_value="different_message")
|
||||
|
||||
with pytest.raises(ValueError, match="ChatMessage not found in store"):
|
||||
await redis_store.index(sample_messages[0])
|
||||
|
||||
async def test_remove(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test remove method using Redis LREM."""
|
||||
mock_redis_client.lrem = AsyncMock(return_value=1) # 1 element removed
|
||||
|
||||
await redis_store.remove(sample_messages[0])
|
||||
|
||||
# Should use LREM to remove the message
|
||||
expected_serialized = redis_store._serialize_message(sample_messages[0])
|
||||
mock_redis_client.lrem.assert_called_once_with("chat_messages:test_thread_123", 1, expected_serialized)
|
||||
|
||||
async def test_remove_not_found(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test remove method when message is not found."""
|
||||
mock_redis_client.lrem = AsyncMock(return_value=0) # 0 elements removed
|
||||
|
||||
with pytest.raises(ValueError, match="ChatMessage not found in store"):
|
||||
await redis_store.remove(sample_messages[0])
|
||||
|
||||
async def test_extend(self, redis_store, mock_redis_client, sample_messages):
|
||||
"""Test extend method delegates to add_messages."""
|
||||
await redis_store.extend(sample_messages[:2])
|
||||
|
||||
# Should call pipeline operations via add_messages
|
||||
mock_redis_client.pipeline.assert_called_with(transaction=True)
|
||||
|
||||
# Verify rpush was called for each message
|
||||
pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value
|
||||
assert pipeline_mock.rpush.call_count >= 2
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
def test_self_through_main() -> None:
|
||||
try:
|
||||
from agent_framework.redis import __version__
|
||||
except ImportError:
|
||||
__version__ = None
|
||||
|
||||
assert __version__ is not None
|
||||
|
||||
|
||||
def test_self() -> None:
|
||||
try:
|
||||
from agent_framework_redis import __version__
|
||||
except ImportError:
|
||||
__version__ = None
|
||||
|
||||
assert __version__ is not None
|
||||
|
||||
|
||||
def test_agent_framework() -> None:
|
||||
try:
|
||||
from agent_framework import __version__
|
||||
except ImportError:
|
||||
__version__ = None
|
||||
|
||||
assert __version__ is not None
|
||||
@@ -0,0 +1,448 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, Role
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from pydantic import ValidationError
|
||||
from redisvl.utils.vectorize import CustomTextVectorizer
|
||||
|
||||
from agent_framework_redis import RedisProvider
|
||||
|
||||
CUSTOM_VECTORIZER = CustomTextVectorizer(embed=lambda x: [1.0, 2.0, 3.0], dtype="float32")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_index() -> AsyncMock:
|
||||
idx = AsyncMock()
|
||||
idx.create = AsyncMock()
|
||||
idx.load = AsyncMock()
|
||||
idx.query = AsyncMock()
|
||||
idx.exists = AsyncMock(return_value=False)
|
||||
|
||||
async def _paginate_generator(*_args: Any, **_kwargs: Any):
|
||||
# Default empty generator; override per-test as needed
|
||||
if False: # pragma: no cover
|
||||
yield []
|
||||
return
|
||||
|
||||
idx.paginate = _paginate_generator
|
||||
return idx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_index_from_dict(mock_index: AsyncMock):
|
||||
with patch("agent_framework_redis._provider.AsyncSearchIndex") as mock_cls:
|
||||
mock_cls.from_dict = MagicMock(return_value=mock_index)
|
||||
|
||||
# Mock from_existing to return a mock with matching schema by default
|
||||
# This prevents schema validation errors in tests that don't specifically test schema validation
|
||||
async def mock_from_existing(index_name, redis_url):
|
||||
mock_existing = AsyncMock()
|
||||
# Return a schema that will match whatever the provider generates
|
||||
# This is a bit of a hack, but allows existing tests to continue working
|
||||
mock_existing.schema.to_dict = MagicMock(
|
||||
side_effect=lambda: mock_cls.from_dict.call_args[0][0] if mock_cls.from_dict.call_args else {}
|
||||
)
|
||||
return mock_existing
|
||||
|
||||
mock_cls.from_existing = AsyncMock(side_effect=mock_from_existing)
|
||||
|
||||
yield mock_cls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_queries():
|
||||
calls: dict[str, Any] = {"TextQuery": [], "HybridQuery": [], "FilterExpression": []}
|
||||
|
||||
def _mk_query(kind: str):
|
||||
class _Q: # simple marker object with captured kwargs
|
||||
def __init__(self, **kwargs):
|
||||
self.kind = kind
|
||||
self.kwargs = kwargs
|
||||
|
||||
return _Q
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_redis._provider.TextQuery",
|
||||
side_effect=lambda **k: calls["TextQuery"].append(k) or _mk_query("text")(**k),
|
||||
) as text_q,
|
||||
patch(
|
||||
"agent_framework_redis._provider.HybridQuery",
|
||||
side_effect=lambda **k: calls["HybridQuery"].append(k) or _mk_query("hybrid")(**k),
|
||||
) as hybrid_q,
|
||||
patch(
|
||||
"agent_framework_redis._provider.FilterExpression",
|
||||
side_effect=lambda s: calls["FilterExpression"].append(s) or ("FE", s),
|
||||
) as filt,
|
||||
):
|
||||
yield {"calls": calls, "TextQuery": text_q, "HybridQuery": hybrid_q, "FilterExpression": filt}
|
||||
|
||||
|
||||
class TestRedisProviderInitialization:
|
||||
# Verifies the provider can be imported from the package
|
||||
def test_import(self):
|
||||
from agent_framework_redis._provider import RedisProvider
|
||||
|
||||
assert RedisProvider is not None
|
||||
|
||||
# Constructing without filters should not raise; filters are enforced at call-time
|
||||
def test_init_without_filters_ok(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider()
|
||||
assert provider.user_id is None
|
||||
assert provider.agent_id is None
|
||||
assert provider.application_id is None
|
||||
assert provider.thread_id is None
|
||||
|
||||
# Schema should omit vector field when no vector configuration is provided
|
||||
def test_schema_without_vector_field(self, patch_index_from_dict):
|
||||
RedisProvider(user_id="u1")
|
||||
# Inspect schema passed to from_dict
|
||||
args, kwargs = patch_index_from_dict.from_dict.call_args
|
||||
schema = args[0]
|
||||
assert isinstance(schema, dict)
|
||||
names = [f["name"] for f in schema["fields"]]
|
||||
types = [f["type"] for f in schema["fields"]]
|
||||
assert "content" in names
|
||||
assert "text" in types
|
||||
assert "vector" not in types
|
||||
|
||||
|
||||
class TestRedisProviderMessages:
|
||||
@pytest.fixture
|
||||
def sample_messages(self) -> list[ChatMessage]:
|
||||
return [
|
||||
ChatMessage(role=Role.USER, text="Hello, how are you?"),
|
||||
ChatMessage(role=Role.ASSISTANT, text="I'm doing well, thank you!"),
|
||||
ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant"),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Writes require at least one scoping filter to avoid unbounded operations
|
||||
async def test_messages_adding_requires_filters(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider()
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
await provider.messages_adding("thread123", ChatMessage(role=Role.USER, text="Hello"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Captures the per-operation thread id when provided
|
||||
async def test_thread_created_sets_per_operation_id(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1")
|
||||
await provider.thread_created("t1")
|
||||
assert provider._per_operation_thread_id == "t1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Enforces single-thread usage when scope_to_per_operation_thread_id is True
|
||||
async def test_thread_created_conflict_when_scoped(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True)
|
||||
provider._per_operation_thread_id = "t1"
|
||||
with pytest.raises(ValueError) as exc:
|
||||
await provider.thread_created("t2")
|
||||
assert "only be used with one thread" in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Aggregates all results from the async paginator into a flat list
|
||||
async def test_search_all_paginates(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
async def gen(_q, page_size: int = 200): # noqa: ARG001, ANN001
|
||||
yield [{"id": 1}]
|
||||
yield [{"id": 2}, {"id": 3}]
|
||||
|
||||
mock_index.paginate = gen
|
||||
provider = RedisProvider(user_id="u1")
|
||||
res = await provider.search_all(page_size=2)
|
||||
assert res == [{"id": 1}, {"id": 2}, {"id": 3}]
|
||||
|
||||
|
||||
class TestRedisProviderModelInvoking:
|
||||
@pytest.mark.asyncio
|
||||
# Reads require at least one scoping filter to avoid unbounded operations
|
||||
async def test_model_invoking_requires_filters(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider()
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
await provider.model_invoking(ChatMessage(role=Role.USER, text="Hi"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Ensures text-only search path is used and context is composed from hits
|
||||
async def test_textquery_path_and_context_contents(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict, patch_queries
|
||||
): # noqa: ARG002
|
||||
# Arrange: text-only search
|
||||
mock_index.query = AsyncMock(return_value=[{"content": "A"}, {"content": "B"}])
|
||||
provider = RedisProvider(user_id="u1")
|
||||
|
||||
# Act
|
||||
ctx = await provider.model_invoking([ChatMessage(role=Role.USER, text="q1")])
|
||||
|
||||
# Assert: TextQuery used (not HybridQuery), filter_expression included
|
||||
assert patch_queries["TextQuery"].call_count == 1
|
||||
assert patch_queries["HybridQuery"].call_count == 0
|
||||
kwargs = patch_queries["calls"]["TextQuery"][0]
|
||||
assert kwargs["text"] == "q1"
|
||||
assert kwargs["text_field_name"] == "content"
|
||||
assert kwargs["num_results"] == 10
|
||||
assert "filter_expression" in kwargs
|
||||
|
||||
# Context contains memories joined after the default prompt
|
||||
assert ctx.contents is not None and len(ctx.contents) == 1
|
||||
text = ctx.contents[0].text
|
||||
assert text.endswith("A\nB")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# When no results are returned, Context should have no contents
|
||||
async def test_model_invoking_empty_results_returns_empty_context(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict, patch_queries
|
||||
): # noqa: ARG002
|
||||
mock_index.query = AsyncMock(return_value=[])
|
||||
provider = RedisProvider(user_id="u1")
|
||||
ctx = await provider.model_invoking([ChatMessage(role=Role.USER, text="any")])
|
||||
assert ctx.contents is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Ensures hybrid vector-text search is used when a vectorizer and vector field are configured
|
||||
async def test_hybridquery_path_with_vectorizer(self, mock_index: AsyncMock, patch_index_from_dict, patch_queries): # noqa: ARG002
|
||||
mock_index.query = AsyncMock(return_value=[{"content": "Hit"}])
|
||||
provider = RedisProvider(user_id="u1", redis_vectorizer=CUSTOM_VECTORIZER, vector_field_name="vec")
|
||||
|
||||
ctx = await provider.model_invoking([ChatMessage(role=Role.USER, text="hello")])
|
||||
|
||||
# Assert: HybridQuery used with vector and vector field
|
||||
assert patch_queries["HybridQuery"].call_count == 1
|
||||
k = patch_queries["calls"]["HybridQuery"][0]
|
||||
assert k["text"] == "hello"
|
||||
assert k["vector_field_name"] == "vec"
|
||||
assert k["vector"] == [1.0, 2.0, 3.0]
|
||||
assert k["dtype"] == "float32"
|
||||
assert k["num_results"] == 10
|
||||
assert "filter_expression" in k
|
||||
|
||||
# Context assembled from returned memories
|
||||
assert ctx.contents and "Hit" in ctx.contents[0].text
|
||||
|
||||
|
||||
class TestRedisProviderContextManager:
|
||||
@pytest.mark.asyncio
|
||||
# Verifies async context manager returns self for chaining
|
||||
async def test_async_context_manager_returns_self(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1")
|
||||
async with provider as ctx:
|
||||
assert ctx is provider
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Exit should be a no-op and not raise
|
||||
async def test_aexit_noop(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1")
|
||||
assert await provider.__aexit__(None, None, None) is None
|
||||
|
||||
|
||||
class TestMessagesAddingBehavior:
|
||||
@pytest.mark.asyncio
|
||||
# Adds messages while injecting partition defaults and preserving allowed roles
|
||||
async def test_messages_adding_adds_partition_defaults_and_roles(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
provider = RedisProvider(
|
||||
application_id="app",
|
||||
agent_id="agent",
|
||||
user_id="u1",
|
||||
scope_to_per_operation_thread_id=True,
|
||||
)
|
||||
|
||||
msgs = [
|
||||
ChatMessage(role=Role.USER, text="u"),
|
||||
ChatMessage(role=Role.ASSISTANT, text="a"),
|
||||
ChatMessage(role=Role.SYSTEM, text="s"),
|
||||
]
|
||||
|
||||
await provider.messages_adding("t1", msgs)
|
||||
|
||||
# Ensure load invoked with shaped docs containing defaults
|
||||
assert mock_index.load.await_count == 1
|
||||
(loaded_args, _kwargs) = mock_index.load.call_args
|
||||
docs = loaded_args[0]
|
||||
assert isinstance(docs, list) and len(docs) == 3
|
||||
for d in docs:
|
||||
assert d["role"] in {"user", "assistant", "system"}
|
||||
assert d["content"] in {"u", "a", "s"}
|
||||
assert d["application_id"] == "app"
|
||||
assert d["agent_id"] == "agent"
|
||||
assert d["user_id"] == "u1"
|
||||
assert d["thread_id"] == "t1" # scoped via per-operation thread id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Skips blank text and disallowed roles (e.g., TOOL) when adding messages
|
||||
async def test_messages_adding_ignores_blank_and_disallowed_roles(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True)
|
||||
msgs = [
|
||||
ChatMessage(role=Role.USER, text=" "),
|
||||
ChatMessage(role=Role.TOOL, text="tool output"),
|
||||
]
|
||||
await provider.messages_adding("tid", msgs)
|
||||
# No valid messages -> no load
|
||||
assert mock_index.load.await_count == 0
|
||||
|
||||
|
||||
class TestIndexCreationPublicCalls:
|
||||
@pytest.mark.asyncio
|
||||
# Ensures index is created only once when drop=True on first public write call
|
||||
async def test_messages_adding_triggers_index_create_once_when_drop_true(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1", drop_redis_index=True)
|
||||
await provider.messages_adding("t1", ChatMessage(role=Role.USER, text="m1"))
|
||||
await provider.messages_adding("t1", ChatMessage(role=Role.USER, text="m2"))
|
||||
# create only on first call
|
||||
assert mock_index.create.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Ensures index is created when drop=False and the index does not exist on first read
|
||||
async def test_model_invoking_triggers_create_when_drop_false_and_not_exists(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=False)
|
||||
provider = RedisProvider(user_id="u1", drop_redis_index=False)
|
||||
mock_index.query = AsyncMock(return_value=[{"content": "C"}])
|
||||
await provider.model_invoking([ChatMessage(role=Role.USER, text="q")])
|
||||
assert mock_index.create.await_count == 1
|
||||
|
||||
|
||||
class TestThreadCreatedAdditional:
|
||||
@pytest.mark.asyncio
|
||||
# Allows None or same thread id repeatedly; different id raises when scoped
|
||||
async def test_thread_created_allows_none_and_same_id(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True)
|
||||
# None is allowed
|
||||
await provider.thread_created(None)
|
||||
# Same id is allowed repeatedly
|
||||
await provider.thread_created("t1")
|
||||
await provider.thread_created("t1")
|
||||
# Different id should raise
|
||||
with pytest.raises(ValueError):
|
||||
await provider.thread_created("t2")
|
||||
|
||||
|
||||
class TestVectorPopulation:
|
||||
@pytest.mark.asyncio
|
||||
# When vectorizer configured, messages_adding should embed content and populate the vector field
|
||||
async def test_messages_adding_populates_vector_field_when_vectorizer_present(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
provider = RedisProvider(
|
||||
user_id="u1",
|
||||
scope_to_per_operation_thread_id=True,
|
||||
redis_vectorizer=CUSTOM_VECTORIZER,
|
||||
vector_field_name="vec",
|
||||
)
|
||||
|
||||
await provider.messages_adding("t1", ChatMessage(role=Role.USER, text="hello"))
|
||||
assert mock_index.load.await_count == 1
|
||||
(loaded_args, _kwargs) = mock_index.load.call_args
|
||||
docs = loaded_args[0]
|
||||
assert isinstance(docs, list) and len(docs) == 1
|
||||
vec = docs[0].get("vec")
|
||||
assert isinstance(vec, (bytes, bytearray))
|
||||
assert len(vec) == 3 * np.dtype(np.float32).itemsize
|
||||
|
||||
|
||||
class TestRedisProviderSchemaVectors:
|
||||
# Adds a vector field when vectorizer supplies dims implicitly
|
||||
def test_schema_with_vector_field_and_dims_inferred(self, patch_index_from_dict): # noqa: ARG002
|
||||
RedisProvider(user_id="u1", redis_vectorizer=CUSTOM_VECTORIZER, vector_field_name="vec")
|
||||
args, _ = patch_index_from_dict.from_dict.call_args
|
||||
schema = args[0]
|
||||
names = [f["name"] for f in schema["fields"]]
|
||||
types = {f["name"]: f["type"] for f in schema["fields"]}
|
||||
assert "vec" in names
|
||||
assert types["vec"] == "vector"
|
||||
|
||||
# Raises when redis_vectorizer is not the correct type
|
||||
def test_init_invalid_vectorizer(self, patch_index_from_dict): # noqa: ARG002
|
||||
class DummyVectorizer:
|
||||
pass
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
RedisProvider(user_id="u1", redis_vectorizer=DummyVectorizer(), vector_field_name="vec")
|
||||
|
||||
|
||||
class TestEnsureIndex:
|
||||
@pytest.mark.asyncio
|
||||
# Creates index once and marks _index_initialized to prevent duplicate calls
|
||||
async def test_ensure_index_creates_once(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
# Mock index doesn't exist, so it will be created
|
||||
mock_index.exists = AsyncMock(return_value=False)
|
||||
provider = RedisProvider(user_id="u1", overwrite_index=False)
|
||||
|
||||
assert provider._index_initialized is False
|
||||
await provider._ensure_index()
|
||||
assert mock_index.create.await_count == 1
|
||||
assert provider._index_initialized is True
|
||||
|
||||
# Second call should not create again due to _index_initialized flag
|
||||
await provider._ensure_index()
|
||||
assert mock_index.create.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Creates index with overwrite=True when overwrite_index=True
|
||||
async def test_ensure_index_with_overwrite_true(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=True)
|
||||
provider = RedisProvider(user_id="u1", overwrite_index=True)
|
||||
|
||||
await provider._ensure_index()
|
||||
|
||||
# Should call create with overwrite=True, drop=False
|
||||
mock_index.create.assert_called_once_with(overwrite=True, drop=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Creates index with overwrite=False when index doesn't exist
|
||||
async def test_ensure_index_create_if_missing(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=False)
|
||||
provider = RedisProvider(user_id="u1", overwrite_index=False)
|
||||
|
||||
await provider._ensure_index()
|
||||
|
||||
# Should call create with overwrite=False, drop=False
|
||||
mock_index.create.assert_called_once_with(overwrite=False, drop=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Validates schema compatibility when index exists and overwrite=False
|
||||
async def test_ensure_index_schema_validation_success(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=True)
|
||||
provider = RedisProvider(user_id="u1", overwrite_index=False)
|
||||
|
||||
# Mock existing index with matching schema
|
||||
expected_schema = provider.schema_dict
|
||||
patch_index_from_dict.from_existing.return_value.schema.to_dict.return_value = expected_schema
|
||||
|
||||
await provider._ensure_index()
|
||||
|
||||
# Should validate schema and proceed to create
|
||||
patch_index_from_dict.from_existing.assert_called_once_with("context", redis_url="redis://localhost:6379")
|
||||
mock_index.create.assert_called_once_with(overwrite=False, drop=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Raises ServiceInitializationError when schemas don't match
|
||||
async def test_ensure_index_schema_validation_failure(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=True)
|
||||
provider = RedisProvider(user_id="u1", overwrite_index=False)
|
||||
|
||||
# Override the mock to return a different schema after provider is created
|
||||
async def mock_from_existing_different(index_name, redis_url):
|
||||
mock_existing = AsyncMock()
|
||||
mock_existing.schema.to_dict = MagicMock(return_value={"different": "schema"})
|
||||
return mock_existing
|
||||
|
||||
patch_index_from_dict.from_existing = AsyncMock(side_effect=mock_from_existing_different)
|
||||
|
||||
with pytest.raises(ServiceInitializationError) as exc:
|
||||
await provider._ensure_index()
|
||||
|
||||
assert "incompatible with the current configuration" in str(exc.value)
|
||||
assert "overwrite_index=True" in str(exc.value)
|
||||
|
||||
# Should not call create when schema validation fails
|
||||
mock_index.create.assert_not_called()
|
||||
Reference in New Issue
Block a user