@@ -1006,3 +1006,126 @@ def test_plan_no_warning_when_stats_missing_failures_key(monkeypatch, tmp_path):
10061006
10071007 failure_warnings = [w for w in plan .warnings if "hash indexing failure" in w ]
10081008 assert len (failure_warnings ) == 0
1009+
1010+
1011+ # =============================================================================
1012+ # TDD: Validation cluster-safe EXISTS + multi-prefix key translation
1013+ # =============================================================================
1014+ from unittest .mock import MagicMock
1015+
1016+ from redisvl .migration .models import (
1017+ DiffClassification ,
1018+ KeyspaceSnapshot ,
1019+ MigrationPlan ,
1020+ MigrationValidation ,
1021+ RenameOperations ,
1022+ SourceSnapshot ,
1023+ ValidationPolicy ,
1024+ )
1025+ from redisvl .migration .validation import MigrationValidator
1026+
1027+
1028+ def _make_minimal_plan (
1029+ * ,
1030+ key_sample ,
1031+ prefixes ,
1032+ change_prefix = None ,
1033+ merged_target_schema = None ,
1034+ ):
1035+ """Build a minimal MigrationPlan for validator testing."""
1036+ if merged_target_schema is None :
1037+ merged_target_schema = {
1038+ "index" : {"name" : "target_idx" , "prefix" : "new:" , "storage_type" : "hash" },
1039+ "fields" : [{"name" : "title" , "type" : "text" }],
1040+ }
1041+
1042+ return MigrationPlan (
1043+ source = SourceSnapshot (
1044+ index_name = "src_idx" ,
1045+ schema_snapshot = {
1046+ "index" : {"name" : "src_idx" , "prefix" : "old:" , "storage_type" : "hash" },
1047+ "fields" : [{"name" : "title" , "type" : "text" }],
1048+ },
1049+ stats_snapshot = {"num_docs" : 3 , "hash_indexing_failures" : 0 },
1050+ keyspace = KeyspaceSnapshot (
1051+ storage_type = "hash" ,
1052+ prefixes = prefixes ,
1053+ key_separator = ":" ,
1054+ key_sample = key_sample ,
1055+ ),
1056+ ),
1057+ requested_changes = {"version" : 1 , "changes" : {}},
1058+ merged_target_schema = merged_target_schema ,
1059+ diff_classification = DiffClassification (supported = True ),
1060+ rename_operations = RenameOperations (change_prefix = change_prefix ),
1061+ )
1062+
1063+
1064+ class TestValidatorClusterSafeExists :
1065+ """Verify per-key EXISTS calls (not multi-key splat)."""
1066+
1067+ def test_exists_called_per_key (self , monkeypatch ):
1068+ """EXISTS should be called once per key, not with *keys_to_check."""
1069+ plan = _make_minimal_plan (
1070+ key_sample = ["old:1" , "old:2" , "old:3" ],
1071+ prefixes = ["old:" ],
1072+ )
1073+
1074+ mock_client = MagicMock ()
1075+ mock_client .exists .return_value = 1 # Each key exists
1076+
1077+ mock_index = MagicMock ()
1078+ mock_index .client = mock_client
1079+ mock_index .info .return_value = {"num_docs" : 3 , "hash_indexing_failures" : 0 }
1080+ mock_index .schema .to_dict .return_value = plan .merged_target_schema
1081+ mock_index .search .return_value = MagicMock (total = 3 )
1082+
1083+ monkeypatch .setattr (
1084+ "redisvl.migration.validation.SearchIndex.from_existing" ,
1085+ lambda * a , ** kw : mock_index ,
1086+ )
1087+
1088+ validator = MigrationValidator ()
1089+ validation , _ , _ = validator .validate (plan , redis_url = "redis://localhost" )
1090+
1091+ # EXISTS should have been called 3 times (once per key), not once with 3 args
1092+ assert mock_client .exists .call_count == 3
1093+ for call in mock_client .exists .call_args_list :
1094+ # Each call should have exactly 1 positional arg
1095+ assert len (call .args ) == 1
1096+
1097+
1098+ class TestValidatorMultiPrefixKeyTranslation :
1099+ """Verify multi-prefix key translation during prefix change."""
1100+
1101+ def test_multi_prefix_keys_translated (self , monkeypatch ):
1102+ """Keys matching different prefixes should all be translated correctly."""
1103+ plan = _make_minimal_plan (
1104+ key_sample = ["pfx_a:1" , "pfx_b:2" , "pfx_a:3" ],
1105+ prefixes = ["pfx_a:" , "pfx_b:" ],
1106+ change_prefix = "new:" ,
1107+ )
1108+
1109+ mock_client = MagicMock ()
1110+ mock_client .exists .return_value = 1
1111+
1112+ mock_index = MagicMock ()
1113+ mock_index .client = mock_client
1114+ mock_index .info .return_value = {"num_docs" : 3 , "hash_indexing_failures" : 0 }
1115+ mock_index .schema .to_dict .return_value = plan .merged_target_schema
1116+ mock_index .search .return_value = MagicMock (total = 3 )
1117+
1118+ monkeypatch .setattr (
1119+ "redisvl.migration.validation.SearchIndex.from_existing" ,
1120+ lambda * a , ** kw : mock_index ,
1121+ )
1122+
1123+ validator = MigrationValidator ()
1124+ validation , _ , _ = validator .validate (plan , redis_url = "redis://localhost" )
1125+
1126+ # Verify the keys were translated correctly
1127+ called_keys = [call .args [0 ] for call in mock_client .exists .call_args_list ]
1128+ assert "new:1" in called_keys
1129+ assert "new:2" in called_keys
1130+ assert "new:3" in called_keys
1131+ assert validation .key_sample_exists is True
0 commit comments