Skip to content

Commit aae36f1

Browse files
authored
test: valkey - add unit tests (#3210)
1 parent a311bf4 commit aae36f1

2 files changed

Lines changed: 252 additions & 1 deletion

File tree

integrations/valkey/tests/test_document_store.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66

77
import struct
88
from dataclasses import replace
9+
from unittest.mock import MagicMock, patch
910

1011
import pytest
1112
from glide_shared.commands.server_modules.ft_options.ft_create_options import DistanceMetricType
1213
from glide_shared.commands.server_modules.ft_options.ft_search_options import FtSearchOptions
1314
from haystack.dataclasses import Document
1415
from haystack.dataclasses.byte_stream import ByteStream
1516
from haystack.document_stores.types import DuplicatePolicy
17+
from haystack.errors import FilterError
1618
from haystack.testing.document_store import (
1719
CountDocumentsByFilterTest,
1820
CountDocumentsTest,
@@ -31,6 +33,8 @@
3133
from haystack.utils import Secret
3234

3335
from haystack_integrations.document_stores.valkey import ValkeyDocumentStore
36+
from haystack_integrations.document_stores.valkey import document_store as ds_module
37+
from haystack_integrations.document_stores.valkey.document_store import ValkeyDocumentStoreError
3438

3539

3640
def _filterable_docs_embedding_dim_3() -> list[Document]:
@@ -1087,6 +1091,22 @@ def test_static_methods_dont_need_instance(self):
10871091
ValkeyDocumentStore._validate_documents([Document(id="1", content="test")])
10881092
ValkeyDocumentStore._validate_policy(DuplicatePolicy.NONE)
10891093

1094+
@pytest.mark.parametrize(
1095+
"metadata_fields, expected_error",
1096+
[
1097+
("not_a_dict", r"metadata_fields must be a dictionary"),
1098+
({"": str}, r"Field name must be a non-empty string"),
1099+
({"category": list}, r"Unsupported field type"),
1100+
],
1101+
)
1102+
def test_validate_and_normalize_metadata_fields_rejects_invalid_inputs(self, metadata_fields, expected_error):
1103+
with pytest.raises(ValueError, match=expected_error):
1104+
ValkeyDocumentStore._validate_and_normalize_metadata_fields(metadata_fields)
1105+
1106+
def test_validate_policy_logs_warning_for_unsupported_policy(self, caplog):
1107+
ValkeyDocumentStore._validate_policy(DuplicatePolicy.SKIP)
1108+
assert "only supports `DuplicatePolicy.OVERWRITE`" in caplog.text
1109+
10901110

10911111
class TestValkeyDocumentStoreConverters:
10921112
def test_to_dict(self):
@@ -1205,3 +1225,118 @@ def test_prepare_document_dict_validates_numeric_field_type():
12051225

12061226
with pytest.raises(ValueError, match="Field 'priority' expects numeric value but got str"):
12071227
store._prepare_document_dict(doc)
1228+
1229+
1230+
@pytest.fixture
1231+
def unit_store():
1232+
return ValkeyDocumentStore(
1233+
index_name="unit_test",
1234+
embedding_dim=3,
1235+
metadata_fields={"category": str, "priority": int},
1236+
)
1237+
1238+
1239+
class TestValkeyDocumentStoreErrorPaths:
1240+
@pytest.mark.parametrize("cluster_mode", [False, True])
1241+
def test_get_connection_wraps_create_errors(self, unit_store, cluster_mode):
1242+
unit_store._cluster_mode = cluster_mode
1243+
target = "SyncGlideClusterClient" if cluster_mode else "SyncGlideClient"
1244+
with patch.object(ds_module, target) as mock_cls:
1245+
mock_cls.create.side_effect = RuntimeError("connect failed")
1246+
with pytest.raises(ValkeyDocumentStoreError, match="Failed to connect to Valkey"):
1247+
unit_store._get_connection()
1248+
1249+
def test_close_swallows_client_exception(self, unit_store, caplog):
1250+
unit_store._client = MagicMock()
1251+
unit_store._client.close.side_effect = RuntimeError("close boom")
1252+
unit_store.close()
1253+
assert unit_store._client is None
1254+
assert "Failed to close Valkey client" in caplog.text
1255+
1256+
def test_count_documents_returns_zero_when_no_index(self, unit_store):
1257+
unit_store._client = MagicMock()
1258+
with patch.object(unit_store, "_has_index", return_value=False):
1259+
assert unit_store.count_documents() == 0
1260+
1261+
def test_embedding_retrieval_returns_empty_when_no_index(self, unit_store, caplog):
1262+
unit_store._client = MagicMock()
1263+
with patch.object(unit_store, "_has_index", return_value=False):
1264+
assert unit_store._embedding_retrieval([0.1, 0.2, 0.3]) == []
1265+
assert "does not exist" in caplog.text
1266+
1267+
def test_embedding_retrieval_returns_empty_for_nonpositive_limit(self, unit_store):
1268+
unit_store._client = MagicMock()
1269+
with patch.object(unit_store, "_has_index", return_value=True):
1270+
assert unit_store._embedding_retrieval([0.1, 0.2, 0.3], limit=0) == []
1271+
assert unit_store._embedding_retrieval([0.1, 0.2, 0.3], limit=-1) == []
1272+
1273+
def test_embedding_retrieval_reraises_filter_error(self, unit_store):
1274+
unit_store._client = MagicMock()
1275+
with patch.object(unit_store, "_has_index", return_value=True):
1276+
bad_filters = {
1277+
"operator": "AND",
1278+
"conditions": [{"field": "meta.unknown", "operator": "==", "value": "x"}],
1279+
}
1280+
with pytest.raises(FilterError):
1281+
unit_store._embedding_retrieval([0.1, 0.2, 0.3], filters=bad_filters, limit=5)
1282+
1283+
def test_write_documents_empty_list_returns_zero(self, unit_store, caplog):
1284+
assert unit_store.write_documents([]) == 0
1285+
assert "empty list" in caplog.text
1286+
1287+
def test_delete_all_documents_without_index_is_noop(self, unit_store):
1288+
unit_store._client = MagicMock()
1289+
with (
1290+
patch.object(unit_store, "_has_index", return_value=False),
1291+
patch.object(ds_module, "sync_ft") as mock_ft,
1292+
):
1293+
unit_store.delete_all_documents()
1294+
mock_ft.dropindex.assert_not_called()
1295+
1296+
def test_create_index_wraps_non_ok_response(self, unit_store):
1297+
unit_store._client = MagicMock()
1298+
with (
1299+
patch.object(unit_store, "_has_index", return_value=False),
1300+
patch.object(ds_module, "sync_ft") as mock_ft,
1301+
):
1302+
mock_ft.create.return_value = b"ERR"
1303+
with pytest.raises(ValkeyDocumentStoreError, match="Error creating collection"):
1304+
unit_store._create_index()
1305+
1306+
@pytest.mark.parametrize(
1307+
"method_name, args, expected_msg",
1308+
[
1309+
(
1310+
"filter_documents",
1311+
({"field": "meta.category", "operator": "==", "value": "x"},),
1312+
"Error filtering documents",
1313+
),
1314+
(
1315+
"delete_by_filter",
1316+
({"field": "meta.category", "operator": "==", "value": "x"},),
1317+
"Failed to delete documents by filter",
1318+
),
1319+
(
1320+
"update_by_filter",
1321+
({"field": "meta.category", "operator": "==", "value": "x"}, {"status": "new"}),
1322+
"Failed to update documents by filter",
1323+
),
1324+
(
1325+
"count_documents_by_filter",
1326+
({"field": "meta.category", "operator": "==", "value": "x"},),
1327+
"Failed to count documents by filter",
1328+
),
1329+
(
1330+
"count_unique_metadata_by_filter",
1331+
({"field": "meta.category", "operator": "==", "value": "x"}, ["category"]),
1332+
"Failed to count unique metadata by filter",
1333+
),
1334+
("get_metadata_field_min_max", ("priority",), "Failed to get min/max for field"),
1335+
("get_metadata_field_unique_values", ("category",), "Failed to get unique values for field"),
1336+
],
1337+
)
1338+
def test_filter_dependent_methods_wrap_internal_errors(self, unit_store, method_name, args, expected_msg):
1339+
with patch.object(unit_store, "count_documents", side_effect=RuntimeError("boom")):
1340+
method = getattr(unit_store, method_name)
1341+
with pytest.raises(ValkeyDocumentStoreError, match=expected_msg):
1342+
method(*args)

integrations/valkey/tests/test_filters.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@
77
from haystack.testing.document_store import FilterDocumentsTest
88

99
from haystack_integrations.document_stores.valkey import ValkeyDocumentStore
10-
from haystack_integrations.document_stores.valkey.filters import _normalize_filters, _validate_filters
10+
from haystack_integrations.document_stores.valkey.filters import (
11+
_greater_than,
12+
_greater_than_equal,
13+
_less_than,
14+
_less_than_equal,
15+
_normalize_filters,
16+
_not_equal,
17+
_validate_filters,
18+
)
1119

1220

1321
@pytest.mark.integration
@@ -177,6 +185,32 @@ def test_comparison_less_than_equal_with_list(self, document_store, filterable_d
177185
},
178186
"(@meta_timestamp:[1609459200 +inf] @meta_timestamp:[-inf (1640995200])",
179187
),
188+
# NOT logical operator combining two conditions
189+
(
190+
{
191+
"operator": "NOT",
192+
"conditions": [
193+
{"field": "meta.category", "operator": "==", "value": "spam"},
194+
{"field": "meta.priority", "operator": ">", "value": 5},
195+
],
196+
},
197+
"-(@meta_category:{spam} @meta_priority:[(5 +inf])",
198+
),
199+
# NOT IN for NumericField
200+
(
201+
{"operator": "AND", "conditions": [{"field": "meta.priority", "operator": "not in", "value": [1, 2]}]},
202+
"(-(@meta_priority:[1 1] | @meta_priority:[2 2]))",
203+
),
204+
# Field already with meta_ prefix (no "meta." remapping)
205+
(
206+
{"operator": "AND", "conditions": [{"field": "meta_category", "operator": "==", "value": "news"}]},
207+
"(@meta_category:{news})",
208+
),
209+
# Bool value normalized to int on numeric field
210+
(
211+
{"operator": "AND", "conditions": [{"field": "meta.priority", "operator": "==", "value": True}]},
212+
"(@meta_priority:[1 1])",
213+
),
180214
]
181215

182216

@@ -327,3 +361,85 @@ def test_numeric_not_equal():
327361
def test_filters_must_be_dict():
328362
with pytest.raises(FilterError, match="Filters must be a dictionary"):
329363
_normalize_filters("invalid", DEFAULT_SUPPORTED_FIELDS)
364+
365+
366+
@pytest.mark.parametrize("operator", [">", ">=", "<", "<="])
367+
@pytest.mark.parametrize(
368+
"value, expected_error",
369+
[
370+
(None, r"None value is not supported"),
371+
("bad_string", r"requires numeric value"),
372+
],
373+
ids=["none_value", "non_numeric_value"],
374+
)
375+
def test_numeric_comparison_invalid_inputs(operator, value, expected_error):
376+
with pytest.raises(FilterError, match=expected_error):
377+
_normalize_filters(
378+
{"operator": "AND", "conditions": [{"field": "meta.score", "operator": operator, "value": value}]},
379+
DEFAULT_SUPPORTED_FIELDS,
380+
)
381+
382+
383+
@pytest.mark.parametrize(
384+
"func, op_symbol",
385+
[
386+
(_greater_than, ">"),
387+
(_greater_than_equal, ">="),
388+
(_less_than, "<"),
389+
(_less_than_equal, "<="),
390+
],
391+
)
392+
def test_numeric_comparison_fn_rejects_tag_field_type(func, op_symbol):
393+
with pytest.raises(FilterError, match=f"Operator '{op_symbol}' not supported for TagField"):
394+
func("meta_category", "value", "tag")
395+
396+
397+
@pytest.mark.parametrize(
398+
"operator, expected",
399+
[
400+
("==", "(-@meta_score:[-inf +inf])"),
401+
("!=", "(@meta_score:[-inf +inf])"),
402+
],
403+
)
404+
def test_equal_operators_with_none_on_numeric(operator, expected):
405+
result = _normalize_filters(
406+
{"operator": "AND", "conditions": [{"field": "meta.score", "operator": operator, "value": None}]},
407+
DEFAULT_SUPPORTED_FIELDS,
408+
)
409+
assert result == expected
410+
411+
412+
@pytest.mark.parametrize("operator", ["==", "!="])
413+
@pytest.mark.parametrize(
414+
"field, value, expected_error",
415+
[
416+
("meta.category", 123, r"TagField 'meta_category' requires string value"),
417+
("meta.score", "bad", r"NumericField 'meta_score' requires numeric value"),
418+
],
419+
)
420+
def test_equality_operators_reject_wrong_value_types(operator, field, value, expected_error):
421+
with pytest.raises(FilterError, match=expected_error):
422+
_normalize_filters(
423+
{"operator": "AND", "conditions": [{"field": field, "operator": operator, "value": value}]},
424+
DEFAULT_SUPPORTED_FIELDS,
425+
)
426+
427+
428+
def test_not_equal_fn_direct_call_with_none_on_tag_field():
429+
assert _not_equal("meta_category", None, "tag") == "@meta_category:[-inf +inf]"
430+
431+
432+
@pytest.mark.parametrize(
433+
"field, value, expected_error",
434+
[
435+
("meta.category", "not_a_list", r"'not in' operator requires a list value"),
436+
("meta.category", ["valid", 123], r"TagField 'meta_category' requires string values in list"),
437+
("meta.priority", [1, "invalid"], r"NumericField 'meta_priority' requires numeric values in list"),
438+
],
439+
)
440+
def test_not_in_rejects_invalid_inputs(field, value, expected_error):
441+
with pytest.raises(FilterError, match=expected_error):
442+
_normalize_filters(
443+
{"operator": "AND", "conditions": [{"field": field, "operator": "not in", "value": value}]},
444+
DEFAULT_SUPPORTED_FIELDS,
445+
)

0 commit comments

Comments
 (0)