package vectorstore import ( "bufio" "context" "fmt" "io" "net" "os" "strconv" "strings" "sync" "testing" "time" bifrost "github.com/maximhq/bifrost/core" "github.com/maximhq/bifrost/core/schemas" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Test constants const ( RedisTestTimeout = 30 * time.Second TestNamespace = "TestRedis" DefaultTestAddr = "localhost:6379" DefaultRedisTestTimeout = 10 * time.Second RedisTestDimension = 1536 ) // TestSetup provides common test infrastructure type RedisTestSetup struct { Store *RedisStore Logger schemas.Logger Config RedisConfig ctx context.Context cancel context.CancelFunc } // NewRedisTestSetup creates a test setup with environment-driven configuration func NewRedisTestSetup(t *testing.T) *RedisTestSetup { // Get configuration from environment variables addr := schemas.NewEnvVar(getEnvWithDefault("REDIS_ADDR", DefaultTestAddr)) username := schemas.NewEnvVar(os.Getenv("REDIS_USERNAME")) password := schemas.NewEnvVar(os.Getenv("REDIS_PASSWORD")) db := schemas.NewEnvVar(getEnvWithDefault("REDIS_DB", "0")) useTLS := schemas.NewEnvVar(os.Getenv("REDIS_USE_TLS")) insecureSkipVerify := schemas.NewEnvVar(os.Getenv("REDIS_INSECURE_SKIP_VERIFY")) clusterMode := schemas.NewEnvVar(os.Getenv("REDIS_CLUSTER_MODE")) timeoutStr := getEnvWithDefault("REDIS_TIMEOUT", "10s") timeout, err := time.ParseDuration(timeoutStr) if err != nil { timeout = DefaultRedisTestTimeout } config := RedisConfig{ Addr: addr, Username: username, Password: password, DB: db, UseTLS: useTLS, InsecureSkipVerify: insecureSkipVerify, ClusterMode: clusterMode, ContextTimeout: timeout, } logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) ctx, cancel := context.WithTimeout(context.Background(), RedisTestTimeout) store, err := newRedisStore(ctx, config, logger) if err != nil { cancel() t.Fatalf("Failed to create Redis store: %v", err) } setup := &RedisTestSetup{ Store: store, Logger: logger, Config: config, ctx: ctx, cancel: cancel, } // Ensure namespace exists for integration tests if !testing.Short() { setup.ensureNamespaceExists(t) } return setup } // Cleanup cleans up test resources func (ts *RedisTestSetup) Cleanup(t *testing.T) { defer ts.cancel() if !testing.Short() { // Clean up test data ts.cleanupTestData(t) } if err := ts.Store.Close(ts.ctx, TestNamespace); err != nil { t.Logf("Warning: Failed to close store: %v", err) } } // ensureNamespaceExists creates the test namespace in Redis func (ts *RedisTestSetup) ensureNamespaceExists(t *testing.T) { // Create namespace with test properties properties := map[string]VectorStoreProperties{ "key": { DataType: VectorStorePropertyTypeString, }, "type": { DataType: VectorStorePropertyTypeString, }, "test_type": { DataType: VectorStorePropertyTypeString, }, "size": { DataType: VectorStorePropertyTypeInteger, }, "public": { DataType: VectorStorePropertyTypeBoolean, }, "author": { DataType: VectorStorePropertyTypeString, }, "request_hash": { DataType: VectorStorePropertyTypeString, }, "user": { DataType: VectorStorePropertyTypeString, }, "lang": { DataType: VectorStorePropertyTypeString, }, "category": { DataType: VectorStorePropertyTypeString, }, "content": { DataType: VectorStorePropertyTypeString, }, "response": { DataType: VectorStorePropertyTypeString, }, "from_bifrost_semantic_cache_plugin": { DataType: VectorStorePropertyTypeBoolean, }, } err := ts.Store.CreateNamespace(ts.ctx, TestNamespace, RedisTestDimension, properties) if err != nil { t.Fatalf("Failed to create namespace %q: %v", TestNamespace, err) } t.Logf("Created test namespace: %s", TestNamespace) } // cleanupTestData removes all test objects from the namespace func (ts *RedisTestSetup) cleanupTestData(t *testing.T) { // Delete all objects in the test namespace allTestKeys, _, err := ts.Store.GetAll(ts.ctx, TestNamespace, []Query{}, []string{}, nil, 1000) if err != nil { t.Logf("Warning: Failed to get all test keys: %v", err) return } for _, key := range allTestKeys { err := ts.Store.Delete(ts.ctx, TestNamespace, key.ID) if err != nil { t.Logf("Warning: Failed to delete test key %s: %v", key.ID, err) } } t.Logf("Cleaned up test namespace: %s", TestNamespace) } // ============================================================================ // UNIT TESTS // ============================================================================ func TestRedisConfig_Validation(t *testing.T) { logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) ctx := context.Background() tests := []struct { name string config RedisConfig expectError bool errorMsg string }{ { name: "valid config", config: RedisConfig{ Addr: schemas.NewEnvVar("localhost:6379"), }, expectError: false, }, { name: "missing addr", config: RedisConfig{ Username: schemas.NewEnvVar("user"), }, expectError: true, errorMsg: "redis addr is required", }, { name: "with credentials", config: RedisConfig{ Addr: schemas.NewEnvVar("localhost:6379"), Username: schemas.NewEnvVar("default"), Password: schemas.NewEnvVar(""), }, expectError: false, }, { name: "with custom db", config: RedisConfig{ Addr: schemas.NewEnvVar("localhost:6379"), DB: schemas.NewEnvVar("1"), }, expectError: false, }, { name: "tls enabled", config: RedisConfig{ Addr: schemas.NewEnvVar("localhost:6380"), UseTLS: schemas.NewEnvVar("true"), InsecureSkipVerify: schemas.NewEnvVar("true"), }, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { store, err := newRedisStore(ctx, tt.config, logger) if tt.expectError { assert.Error(t, err) assert.Nil(t, store) if tt.errorMsg != "" { assert.Contains(t, err.Error(), tt.errorMsg) } } else { // For valid configs, store creation should succeed // (connection will fail later when actually using Redis) assert.NoError(t, err) assert.NotNil(t, store) } }) } } // readTestCACert loads the CA cert generated by the tests/docker-compose.yml // redis-certs-init service. Tests requiring CA-verified TLS skip if the file // isn't present (e.g. when docker compose hasn't been brought up). func readTestCACert(t *testing.T) string { t.Helper() data, err := os.ReadFile("../../tests/redis-certs/ca.crt") if err != nil { t.Skipf("redis test CA cert not available; bring up tests/docker-compose.yml first: %v", err) } return string(data) } func TestNewRedisStore_ConfiguresStandaloneTLSClient(t *testing.T) { logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) store, err := newRedisStore(context.Background(), RedisConfig{ Addr: schemas.NewEnvVar("localhost:6380"), DB: schemas.NewEnvVar("2"), UseTLS: schemas.NewEnvVar("true"), InsecureSkipVerify: schemas.NewEnvVar("true"), }, logger) require.NoError(t, err) client, ok := store.client.(*redis.Client) require.True(t, ok, "expected standalone redis client") require.Equal(t, 2, client.Options().DB) require.NotNil(t, client.Options().TLSConfig) assert.True(t, client.Options().TLSConfig.InsecureSkipVerify) } func TestNewRedisStore_ConfiguresStandaloneTLSClientWithCACert(t *testing.T) { logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) store, err := newRedisStore(context.Background(), RedisConfig{ Addr: schemas.NewEnvVar("localhost:6380"), DB: schemas.NewEnvVar("2"), UseTLS: schemas.NewEnvVar("true"), CACertPEM: schemas.NewEnvVar(readTestCACert(t)), }, logger) require.NoError(t, err) client, ok := store.client.(*redis.Client) require.True(t, ok, "expected standalone redis client") require.NotNil(t, client.Options().TLSConfig) require.NotNil(t, client.Options().TLSConfig.RootCAs) assert.False(t, client.Options().TLSConfig.InsecureSkipVerify) } func TestNewRedisStore_ConfiguresClusterTLSClient(t *testing.T) { logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) store, err := newRedisStore(context.Background(), RedisConfig{ Addr: schemas.NewEnvVar("localhost:7100"), UseTLS: schemas.NewEnvVar("true"), InsecureSkipVerify: schemas.NewEnvVar("true"), ClusterMode: schemas.NewEnvVar("true"), }, logger) require.NoError(t, err) client, ok := store.client.(*redis.ClusterClient) require.True(t, ok, "expected redis cluster client") require.Equal(t, []string{"localhost:7100"}, client.Options().Addrs) require.NotNil(t, client.Options().TLSConfig) assert.True(t, client.Options().TLSConfig.InsecureSkipVerify) } func TestNewRedisStore_ConfiguresClusterTLSClientWithCACert(t *testing.T) { logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) store, err := newRedisStore(context.Background(), RedisConfig{ Addr: schemas.NewEnvVar("localhost:7100"), UseTLS: schemas.NewEnvVar("true"), CACertPEM: schemas.NewEnvVar(readTestCACert(t)), ClusterMode: schemas.NewEnvVar("true"), }, logger) require.NoError(t, err) client, ok := store.client.(*redis.ClusterClient) require.True(t, ok, "expected redis cluster client") require.NotNil(t, client.Options().TLSConfig) require.NotNil(t, client.Options().TLSConfig.RootCAs) assert.False(t, client.Options().TLSConfig.InsecureSkipVerify) } func TestNewRedisStore_RejectsInvalidCACertPEM(t *testing.T) { logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) store, err := newRedisStore(context.Background(), RedisConfig{ Addr: schemas.NewEnvVar("localhost:6379"), UseTLS: schemas.NewEnvVar("true"), CACertPEM: schemas.NewEnvVar("not-valid-pem"), }, logger) require.Error(t, err) assert.Nil(t, store) assert.Contains(t, err.Error(), "failed to configure Redis TLS CA certificate") } type fakeRedisSearchServer struct { listener net.Listener searchErrors int mu sync.Mutex ftSearchCalls int sawScan bool } func newFakeRedisSearchServer(t *testing.T, searchErrors int) *fakeRedisSearchServer { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) server := &fakeRedisSearchServer{ listener: listener, searchErrors: searchErrors, } go server.serve(t) return server } func (s *fakeRedisSearchServer) serve(t *testing.T) { t.Helper() for { conn, err := s.listener.Accept() if err != nil { if ne, ok := err.(net.Error); ok && ne.Temporary() { continue } return } go s.handleConn(t, conn) } } func (s *fakeRedisSearchServer) handleConn(t *testing.T, conn net.Conn) { t.Helper() defer conn.Close() reader := bufio.NewReader(conn) writer := bufio.NewWriter(conn) for { command, err := readRESPCommand(reader) if err != nil { if err == io.EOF || strings.Contains(strings.ToLower(err.Error()), "use of closed network connection") { return } return } if len(command) == 0 { continue } switch strings.ToUpper(command[0]) { case "HELLO": _, _ = writer.WriteString("%2\r\n+server\r\n+redis\r\n+version\r\n+7.0.0\r\n") case "CLIENT": _, _ = writer.WriteString("+OK\r\n") case "SELECT": _, _ = writer.WriteString("+OK\r\n") case "PING": _, _ = writer.WriteString("+PONG\r\n") case "FT.SEARCH": s.mu.Lock() s.ftSearchCalls++ shouldError := s.ftSearchCalls <= s.searchErrors s.mu.Unlock() if shouldError { _, _ = writer.WriteString("-Invalid query\r\n") } else { _, _ = writer.WriteString("*1\r\n:0\r\n") } case "SCAN": s.mu.Lock() s.sawScan = true s.mu.Unlock() _, _ = writer.WriteString("*2\r\n$1\r\n0\r\n*0\r\n") case "HGETALL": _, _ = writer.WriteString("*0\r\n") default: _, _ = writer.WriteString("+OK\r\n") } if err := writer.Flush(); err != nil { return } } } func (s *fakeRedisSearchServer) close() error { return s.listener.Close() } func (s *fakeRedisSearchServer) addr() string { return s.listener.Addr().String() } func (s *fakeRedisSearchServer) stats() (ftSearchCalls int, sawScan bool) { s.mu.Lock() defer s.mu.Unlock() return s.ftSearchCalls, s.sawScan } func readRESPCommand(reader *bufio.Reader) ([]string, error) { header, err := reader.ReadString('\n') if err != nil { return nil, err } header = strings.TrimSpace(header) if header == "" { return nil, nil } if header[0] != '*' { return nil, fmt.Errorf("unexpected RESP header %q", header) } count, err := strconv.Atoi(header[1:]) if err != nil { return nil, fmt.Errorf("invalid RESP array length %q: %w", header, err) } command := make([]string, 0, count) for i := 0; i < count; i++ { bulkHeader, err := reader.ReadString('\n') if err != nil { return nil, err } bulkHeader = strings.TrimSpace(bulkHeader) if bulkHeader == "" || bulkHeader[0] != '$' { return nil, fmt.Errorf("unexpected RESP bulk header %q", bulkHeader) } size, err := strconv.Atoi(bulkHeader[1:]) if err != nil { return nil, fmt.Errorf("invalid RESP bulk length %q: %w", bulkHeader, err) } payload := make([]byte, size+2) if _, err := io.ReadFull(reader, payload); err != nil { return nil, err } command = append(command, string(payload[:size])) } return command, nil } func TestRedisStore_ExecuteSearch_DisableScanFallbackOnQuerySyntaxError(t *testing.T) { server := newFakeRedisSearchServer(t, 2) defer func() { require.NoError(t, server.close()) }() client := redis.NewClient(&redis.Options{ Addr: server.addr(), Protocol: 2, DisableIdentity: true, MaxRetries: 0, }) defer func() { require.NoError(t, client.Close()) }() store := &RedisStore{ client: client, logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug), config: RedisConfig{ ContextTimeout: time.Second, }, namespaceFieldTypes: make(map[string]map[string]VectorStorePropertyType), } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() _, err := store.executeSearch(WithDisableScanFallback(ctx), TestNamespace, "*", nil, nil, 0, 1) require.Error(t, err) assert.ErrorIs(t, err, ErrQuerySyntax) ftSearchCalls, sawScan := server.stats() assert.Equal(t, 2, ftSearchCalls) assert.False(t, sawScan, "expected executeSearch to return before scan fallback") } func TestRedisStore_ParseSearchResults_RESP3Map(t *testing.T) { store := &RedisStore{} resp := map[interface{}]interface{}{ "results": []interface{}{ map[interface{}]interface{}{ "id": "TestRedis:doc-1", "extra_attributes": map[interface{}]interface{}{ "score": "0.123", "request_hash": "abc123", "cache_key": "session-1", }, }, }, } results, err := store.parseSearchResults(resp, TestNamespace, []string{"request_hash"}) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "doc-1", results[0].ID) assert.Equal(t, "abc123", results[0].Properties["request_hash"]) assert.Equal(t, "0.123", results[0].Properties["score"]) assert.NotContains(t, results[0].Properties, "cache_key") require.NotNil(t, results[0].Score) assert.InDelta(t, 0.123, *results[0].Score, 0.000001) } func TestRedisStore_ParseSearchResults_RESP2Array(t *testing.T) { store := &RedisStore{} resp := []interface{}{ int64(1), "TestRedis:doc-2", []interface{}{ "score", []byte("0.25"), "request_hash", "def456", "cache_key", "session-2", }, } results, err := store.parseSearchResults(resp, TestNamespace, nil) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "doc-2", results[0].ID) assert.Equal(t, "def456", results[0].Properties["request_hash"]) assert.Equal(t, "session-2", results[0].Properties["cache_key"]) assert.Equal(t, []byte("0.25"), results[0].Properties["score"]) require.NotNil(t, results[0].Score) assert.InDelta(t, 0.25, *results[0].Score, 0.000001) } func TestRedisStore_ParseSearchResults_RESP3StringKeyMap(t *testing.T) { store := &RedisStore{} resp := map[string]interface{}{ "results": []interface{}{ map[string]interface{}{ "id": "TestRedis:doc-3", "extra_attributes": map[string]interface{}{ "score": "0.456", "request_hash": "ghi789", "cache_key": "session-3", }, }, }, } results, err := store.parseSearchResults(resp, TestNamespace, []string{"request_hash"}) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "doc-3", results[0].ID) assert.Equal(t, "ghi789", results[0].Properties["request_hash"]) assert.Equal(t, "0.456", results[0].Properties["score"]) assert.NotContains(t, results[0].Properties, "cache_key") require.NotNil(t, results[0].Score) assert.InDelta(t, 0.456, *results[0].Score, 0.000001) } func TestRedisStore_ParseSearchResults_EmptyRESP2(t *testing.T) { store := &RedisStore{} // RESP2 array with total count 0 and no documents resp := []interface{}{ int64(0), } results, err := store.parseSearchResults(resp, TestNamespace, nil) require.NoError(t, err) assert.Empty(t, results) } func TestRedisStore_ParseSearchResults_ByteScore(t *testing.T) { store := &RedisStore{} // Simulates Valkey RESP2 returning score as []byte resp := []interface{}{ int64(1), "TestRedis:doc-4", []interface{}{ "score", []byte("0.75"), "request_hash", "jkl012", }, } results, err := store.parseSearchResults(resp, TestNamespace, nil) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "doc-4", results[0].ID) require.NotNil(t, results[0].Score) assert.InDelta(t, 0.75, *results[0].Score, 0.000001) } func TestRedisStore_ParseSearchResults_NamespaceWithColon(t *testing.T) { store := &RedisStore{} namespace := "ns:team" resp := map[interface{}]interface{}{ "results": []interface{}{ map[interface{}]interface{}{ "id": namespace + ":doc-1", "extra_attributes": map[interface{}]interface{}{ "request_hash": "abc123", }, }, }, } results, err := store.parseSearchResults(resp, namespace, nil) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "doc-1", results[0].ID) } func TestParseSearchResultIDs(t *testing.T) { t.Run("RESP3 map parses namespace-trimmed ids", func(t *testing.T) { namespace := "ns:team" resp := map[interface{}]interface{}{ "results": []interface{}{ map[interface{}]interface{}{"id": namespace + ":doc-1"}, map[interface{}]interface{}{"id": "other:doc-2"}, }, } ids := parseSearchResultIDs(resp, namespace) assert.Equal(t, []string{"doc-1", "other:doc-2"}, ids) }) t.Run("RESP2 no-content parses ids", func(t *testing.T) { namespace := "ns" resp := []interface{}{ int64(2), "ns:doc-1", "ns:doc-2", } ids := parseSearchResultIDs(resp, namespace) assert.Equal(t, []string{"doc-1", "doc-2"}, ids) }) t.Run("RESP2 pair payload parses ids", func(t *testing.T) { namespace := "ns" resp := []interface{}{ int64(2), "ns:doc-1", []interface{}{"field", "value"}, "ns:doc-2", []interface{}{"field", "value"}, } ids := parseSearchResultIDs(resp, namespace) assert.Equal(t, []string{"doc-1", "doc-2"}, ids) }) } func TestParseOffsetCursor(t *testing.T) { tests := []struct { name string cursor *string want int errSubstr string }{ { name: "nil cursor", cursor: nil, want: 0, }, { name: "empty cursor", cursor: ptr(""), want: 0, }, { name: "valid positive cursor", cursor: ptr("12"), want: 12, }, { name: "negative cursor errors", cursor: ptr("-1"), errSubstr: "cannot be negative", }, { name: "cursor overflow errors", cursor: ptr("2147483648"), errSubstr: "exceeds maximum allowed value", }, { name: "invalid cursor treated as zero", cursor: ptr("not-a-number"), want: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseOffsetCursor(tt.cursor) if tt.errSubstr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.errSubstr) return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } func TestBuildRedisQueryCondition_NumericEquality(t *testing.T) { fieldTypes := map[string]VectorStorePropertyType{ "size": VectorStorePropertyTypeInteger, "type": VectorStorePropertyTypeString, } tests := []struct { name string query Query expected string }{ { name: "numeric equal uses range syntax for integer field", query: Query{ Field: "size", Operator: QueryOperatorEqual, Value: 1024, }, expected: "@size:[1024 1024]", }, { name: "numeric not equal uses negative range syntax for integer field", query: Query{ Field: "size", Operator: QueryOperatorNotEqual, Value: 1024, }, expected: "-@size:[1024 1024]", }, { name: "string field equal remains tag syntax", query: Query{ Field: "type", Operator: QueryOperatorEqual, Value: "pdf", }, expected: "@type:{pdf}", }, { name: "unknown field with numeric literal falls back to numeric range", query: Query{ Field: "unknown_field", Operator: QueryOperatorEqual, Value: 7, }, expected: "@unknown_field:[7 7]", }, { name: "known non-numeric field with numeric literal remains tag", query: Query{ Field: "type", Operator: QueryOperatorEqual, Value: 7, }, expected: "@type:{7}", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := buildRedisQueryCondition(tt.query, fieldTypes) assert.Equal(t, tt.expected, got) }) } } func ptr(v string) *string { return bifrost.Ptr(v) } func TestMatchesQueriesForScan(t *testing.T) { tests := []struct { name string properties map[string]interface{} queries []Query expected bool }{ // GreaterThan { name: "GreaterThan true", properties: map[string]interface{}{"size": "1024"}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}}, expected: true, }, { name: "GreaterThan false", properties: map[string]interface{}{"size": "500"}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}}, expected: false, }, { name: "GreaterThan equal value is false", properties: map[string]interface{}{"size": "1000"}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}}, expected: false, }, // LessThan { name: "LessThan true", properties: map[string]interface{}{"size": "500"}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThan, Value: 1000}}, expected: true, }, { name: "LessThan false", properties: map[string]interface{}{"size": "1024"}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThan, Value: 1000}}, expected: false, }, { name: "LessThan equal value is false", properties: map[string]interface{}{"size": "1000"}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThan, Value: 1000}}, expected: false, }, // GreaterThanOrEqual { name: "GreaterThanOrEqual boundary true", properties: map[string]interface{}{"size": "1000"}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThanOrEqual, Value: 1000}}, expected: true, }, { name: "GreaterThanOrEqual above true", properties: map[string]interface{}{"size": "1001"}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThanOrEqual, Value: 1000}}, expected: true, }, { name: "GreaterThanOrEqual below false", properties: map[string]interface{}{"size": "999"}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThanOrEqual, Value: 1000}}, expected: false, }, // LessThanOrEqual { name: "LessThanOrEqual boundary true", properties: map[string]interface{}{"size": "1000"}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThanOrEqual, Value: 1000}}, expected: true, }, { name: "LessThanOrEqual below true", properties: map[string]interface{}{"size": "999"}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThanOrEqual, Value: 1000}}, expected: true, }, { name: "LessThanOrEqual above false", properties: map[string]interface{}{"size": "1001"}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThanOrEqual, Value: 1000}}, expected: false, }, // Non-numeric value { name: "Non-numeric string returns false for GreaterThan", properties: map[string]interface{}{"size": "not-a-number"}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}}, expected: false, }, { name: "Non-numeric string returns false for LessThan", properties: map[string]interface{}{"size": "abc"}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThan, Value: 1000}}, expected: false, }, // Missing field { name: "Missing field returns false for GreaterThan", properties: map[string]interface{}{}, queries: []Query{{Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}}, expected: false, }, { name: "Missing field returns false for LessThanOrEqual", properties: map[string]interface{}{}, queries: []Query{{Field: "size", Operator: QueryOperatorLessThanOrEqual, Value: 1000}}, expected: false, }, // Float values { name: "Float GreaterThan", properties: map[string]interface{}{"score": "0.95"}, queries: []Query{{Field: "score", Operator: QueryOperatorGreaterThan, Value: 0.5}}, expected: true, }, { name: "Float LessThan", properties: map[string]interface{}{"score": "0.3"}, queries: []Query{{Field: "score", Operator: QueryOperatorLessThan, Value: 0.5}}, expected: true, }, // Multiple queries combined { name: "Multiple numeric queries all match", properties: map[string]interface{}{"size": "500", "count": "10"}, queries: []Query{ {Field: "size", Operator: QueryOperatorGreaterThan, Value: 100}, {Field: "count", Operator: QueryOperatorLessThanOrEqual, Value: 10}, }, expected: true, }, { name: "Multiple numeric queries one fails", properties: map[string]interface{}{"size": "500", "count": "20"}, queries: []Query{ {Field: "size", Operator: QueryOperatorGreaterThan, Value: 100}, {Field: "count", Operator: QueryOperatorLessThanOrEqual, Value: 10}, }, expected: false, }, // Empty queries { name: "No queries matches everything", properties: map[string]interface{}{"size": "500"}, queries: []Query{}, expected: true, }, // ContainsAny / ContainsAll { name: "ContainsAny true with JSON array property", properties: map[string]interface{}{"tags": "[\"red\",\"blue\"]"}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAny, Value: []interface{}{"green", "blue"}}}, expected: true, }, { name: "ContainsAny false with JSON array property", properties: map[string]interface{}{"tags": "[\"red\",\"blue\"]"}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAny, Value: []interface{}{"green", "yellow"}}}, expected: false, }, { name: "ContainsAll true with JSON array property", properties: map[string]interface{}{"tags": "[\"red\",\"blue\",\"green\"]"}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAll, Value: []interface{}{"red", "green"}}}, expected: true, }, { name: "ContainsAll false with JSON array property", properties: map[string]interface{}{"tags": "[\"red\",\"blue\"]"}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAll, Value: []interface{}{"red", "green"}}}, expected: false, }, { name: "ContainsAny true with scalar string property", properties: map[string]interface{}{"tags": "red"}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAny, Value: []interface{}{"red", "green"}}}, expected: true, }, { name: "ContainsAll false with scalar string property", properties: map[string]interface{}{"tags": "red"}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAll, Value: []interface{}{"red", "green"}}}, expected: false, }, { name: "ContainsAny malformed query value returns false", properties: map[string]interface{}{"tags": "[\"red\",\"blue\"]"}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAny, Value: "red"}}, expected: false, }, { name: "ContainsAll missing field returns false", properties: map[string]interface{}{}, queries: []Query{{Field: "tags", Operator: QueryOperatorContainsAll, Value: []interface{}{"red"}}}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := matchesQueriesForScan(tt.properties, tt.queries) assert.Equal(t, tt.expected, result) }) } } // ============================================================================ // INTEGRATION TESTS (require real Redis instance with RediSearch) // ============================================================================ func TestRedisStore_Integration(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } setup := NewRedisTestSetup(t) defer setup.Cleanup(t) t.Run("Add and GetChunk", func(t *testing.T) { testKey := generateUUID() embedding := generateTestEmbedding(RedisTestDimension) metadata := map[string]interface{}{ "type": "document", "size": 1024, "public": true, } // Add object err := setup.Store.Add(setup.ctx, TestNamespace, testKey, embedding, metadata) require.NoError(t, err) // Small delay to ensure consistency time.Sleep(100 * time.Millisecond) // Get single chunk result, err := setup.Store.GetChunk(setup.ctx, TestNamespace, testKey) require.NoError(t, err) assert.NotEmpty(t, result) assert.Equal(t, "document", result.Properties["type"]) // Should contain metadata }) t.Run("Add without embedding", func(t *testing.T) { testKey := generateUUID() metadata := map[string]interface{}{ "type": "metadata-only", } // Add object without embedding err := setup.Store.Add(setup.ctx, TestNamespace, testKey, nil, metadata) require.NoError(t, err) time.Sleep(100 * time.Millisecond) // Retrieve it result, err := setup.Store.GetChunk(setup.ctx, TestNamespace, testKey) require.NoError(t, err) assert.Equal(t, "metadata-only", result.Properties["type"]) }) t.Run("GetChunks batch retrieval", func(t *testing.T) { // Add multiple objects keys := []string{generateUUID(), generateUUID(), generateUUID()} embeddings := [][]float32{ generateTestEmbedding(RedisTestDimension), generateTestEmbedding(RedisTestDimension), nil, } metadata := []map[string]interface{}{ {"type": "doc1", "size": 100}, {"type": "doc2", "size": 200}, {"type": "doc3", "size": 300}, } for i, key := range keys { emb := embeddings[i] err := setup.Store.Add(setup.ctx, TestNamespace, key, emb, metadata[i]) require.NoError(t, err) } time.Sleep(100 * time.Millisecond) // Get all chunks results, err := setup.Store.GetChunks(setup.ctx, TestNamespace, keys) require.NoError(t, err) assert.Len(t, results, 3) // Verify each result for i, result := range results { assert.Equal(t, keys[i], result.ID) assert.Equal(t, metadata[i]["type"], result.Properties["type"]) } }) } func TestRedisStore_FilteringScenarios(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } setup := NewRedisTestSetup(t) defer setup.Cleanup(t) // Setup test data for filtering scenarios testData := []struct { key string metadata map[string]interface{} }{ { generateUUID(), map[string]interface{}{ "type": "pdf", "size": 1024, "public": true, "author": "alice", }, }, { generateUUID(), map[string]interface{}{ "type": "docx", "size": 2048, "public": false, "author": "bob", }, }, { generateUUID(), map[string]interface{}{ "type": "pdf", "size": 512, "public": true, "author": "alice", }, }, { generateUUID(), map[string]interface{}{ "type": "txt", "size": 256, "public": true, "author": "charlie", }, }, } filterFields := []string{"type", "size", "public", "author"} // Add all test data for _, item := range testData { embedding := generateTestEmbedding(RedisTestDimension) err := setup.Store.Add(setup.ctx, TestNamespace, item.key, embedding, item.metadata) require.NoError(t, err) } time.Sleep(500 * time.Millisecond) // Wait for consistency t.Run("Filter by numeric comparison", func(t *testing.T) { queries := []Query{ {Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}, } results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 2) // doc1 (1024) and doc2 (2048) }) t.Run("Filter by numeric equality", func(t *testing.T) { queries := []Query{ {Field: "size", Operator: QueryOperatorEqual, Value: 1024}, } results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, "1024", results[0].Properties["size"]) }) t.Run("Filter by numeric inequality", func(t *testing.T) { queries := []Query{ {Field: "size", Operator: QueryOperatorNotEqual, Value: 1024}, } results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 3) }) t.Run("Filter by boolean", func(t *testing.T) { queries := []Query{ {Field: "public", Operator: QueryOperatorEqual, Value: true}, } results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 3) // doc1, doc3, doc4 }) t.Run("Multiple filters (AND)", func(t *testing.T) { queries := []Query{ {Field: "type", Operator: QueryOperatorEqual, Value: "pdf"}, {Field: "public", Operator: QueryOperatorEqual, Value: true}, } results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 2) // doc1 and doc3 }) t.Run("Complex multi-condition filter", func(t *testing.T) { queries := []Query{ {Field: "author", Operator: QueryOperatorEqual, Value: "alice"}, {Field: "size", Operator: QueryOperatorLessThan, Value: 2000}, {Field: "public", Operator: QueryOperatorEqual, Value: true}, } results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 2) // doc1 and doc3 (both by alice, < 2000 size, public) }) t.Run("Pagination test", func(t *testing.T) { // Test with limit of 2 results, cursor, err := setup.Store.GetAll(setup.ctx, TestNamespace, nil, filterFields, nil, 2) require.NoError(t, err) assert.Len(t, results, 2) if cursor != nil { // Get next page nextResults, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, nil, filterFields, cursor, 2) require.NoError(t, err) assert.LessOrEqual(t, len(nextResults), 2) t.Logf("First page: %d results, Next page: %d results", len(results), len(nextResults)) } }) t.Run("Scan fallback pagination is deterministic", func(t *testing.T) { firstPage, cursor, err := setup.Store.getAllByScan(setup.ctx, TestNamespace, nil, filterFields, nil, 2) require.NoError(t, err) require.Len(t, firstPage, 2) require.NotNil(t, cursor) secondPage, _, err := setup.Store.getAllByScan(setup.ctx, TestNamespace, nil, filterFields, cursor, 2) require.NoError(t, err) require.Len(t, secondPage, 2) combined := append(firstPage, secondPage...) for i := 1; i < len(combined); i++ { assert.LessOrEqual(t, combined[i-1].ID, combined[i].ID) } seen := make(map[string]struct{}, len(combined)) for _, result := range combined { _, exists := seen[result.ID] assert.False(t, exists, "duplicate id across pages: %s", result.ID) seen[result.ID] = struct{}{} } }) } func TestRedisStore_VectorSearch(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } setup := NewRedisTestSetup(t) defer setup.Cleanup(t) // Add test documents with embeddings testDocs := []struct { key string embedding []float32 metadata map[string]interface{} }{ { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{ "type": "tech", "category": "programming", "content": "Go programming language", }, }, { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{ "type": "tech", "category": "programming", "content": "Python programming language", }, }, { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{ "type": "sports", "category": "football", "content": "Football match results", }, }, } for _, doc := range testDocs { err := setup.Store.Add(setup.ctx, TestNamespace, doc.key, doc.embedding, doc.metadata) require.NoError(t, err) } time.Sleep(500 * time.Millisecond) t.Run("Vector similarity search", func(t *testing.T) { // Search for similar content to the first document queryEmbedding := testDocs[0].embedding results, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, nil, []string{"type", "category", "content"}, 0.1, 10) require.NoError(t, err) assert.GreaterOrEqual(t, len(results), 1) // Check that results have scores and are not nil require.NotEmpty(t, results) require.NotNil(t, results[0].Score) assert.InDelta(t, 1.0, *results[0].Score, 1e-4) }) t.Run("Vector search with metadata filters", func(t *testing.T) { // Search for tech content only queries := []Query{ {Field: "type", Operator: QueryOperatorEqual, Value: "tech"}, } queryEmbedding := testDocs[0].embedding results, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, queries, []string{"type", "category", "content"}, 0.1, 10) require.NoError(t, err) assert.GreaterOrEqual(t, len(results), 1) // All results should be tech type for _, result := range results { assert.Equal(t, "tech", result.Properties["type"]) } }) t.Run("Vector search with threshold", func(t *testing.T) { // Use a very high threshold to get only very similar results queryEmbedding := testDocs[0].embedding results, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, nil, []string{"type", "category", "content"}, 0.99, 10) require.NoError(t, err) // Should return fewer results due to high threshold t.Logf("High threshold search returned %d results", len(results)) }) } func TestRedisStore_CompleteUseCases(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } setup := NewRedisTestSetup(t) defer setup.Cleanup(t) t.Run("Document Storage & Retrieval Scenario", func(t *testing.T) { // Add documents with different types documents := []struct { key string embedding []float32 metadata map[string]interface{} }{ { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "pdf", "size": 1024, "public": true}, }, { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "docx", "size": 2048, "public": false}, }, { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "pdf", "size": 512, "public": true}, }, } filterFields := []string{"type", "size", "public"} for _, doc := range documents { err := setup.Store.Add(setup.ctx, TestNamespace, doc.key, doc.embedding, doc.metadata) require.NoError(t, err) } time.Sleep(300 * time.Millisecond) // Test various retrieval patterns // Get PDF documents pdfQuery := []Query{{Field: "type", Operator: QueryOperatorEqual, Value: "pdf"}} results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, pdfQuery, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 2) // doc1, doc3 // Get large documents (size > 1000) sizeQuery := []Query{{Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}} results, _, err = setup.Store.GetAll(setup.ctx, TestNamespace, sizeQuery, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 2) // doc1, doc2 // Get public PDFs combinedQuery := []Query{ {Field: "public", Operator: QueryOperatorEqual, Value: true}, {Field: "type", Operator: QueryOperatorEqual, Value: "pdf"}, } results, _, err = setup.Store.GetAll(setup.ctx, TestNamespace, combinedQuery, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 2) // doc1, doc3 // Vector similarity search queryEmbedding := documents[0].embedding // Similar to doc1 vectorResults, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, nil, filterFields, 0.8, 10) require.NoError(t, err) assert.GreaterOrEqual(t, len(vectorResults), 1) }) t.Run("Semantic Cache-like Workflow", func(t *testing.T) { // Add request-response pairs with parameters cacheEntries := []struct { key string embedding []float32 metadata map[string]interface{} }{ { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{ "request_hash": "abc123", "user": "u1", "lang": "en", "response": "answer1", "from_bifrost_semantic_cache_plugin": true, }, }, { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{ "request_hash": "def456", "user": "u1", "lang": "es", "response": "answer2", "from_bifrost_semantic_cache_plugin": true, }, }, } filterFields := []string{"request_hash", "user", "lang", "response", "from_bifrost_semantic_cache_plugin"} for _, entry := range cacheEntries { err := setup.Store.Add(setup.ctx, TestNamespace, entry.key, entry.embedding, entry.metadata) require.NoError(t, err) } time.Sleep(300 * time.Millisecond) // Test hash-based direct retrieval (exact match) hashQuery := []Query{{Field: "request_hash", Operator: QueryOperatorEqual, Value: "abc123"}} results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, hashQuery, filterFields, nil, 10) require.NoError(t, err) assert.Len(t, results, 1) // Test semantic search with user and language filters userLangFilter := []Query{ {Field: "user", Operator: QueryOperatorEqual, Value: "u1"}, {Field: "lang", Operator: QueryOperatorEqual, Value: "en"}, } similarEmbedding := generateSimilarEmbedding(cacheEntries[0].embedding, 0.9) vectorResults, err := setup.Store.GetNearest(setup.ctx, TestNamespace, similarEmbedding, userLangFilter, filterFields, 0.7, 10) require.NoError(t, err) assert.Len(t, vectorResults, 1) // Should find English content for u1 }) } func TestRedisStore_DeleteOperations(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } setup := NewRedisTestSetup(t) defer setup.Cleanup(t) t.Run("Delete single item", func(t *testing.T) { // Add an item key := generateUUID() embedding := generateTestEmbedding(RedisTestDimension) metadata := map[string]interface{}{"type": "test", "value": "delete_me"} err := setup.Store.Add(setup.ctx, TestNamespace, key, embedding, metadata) require.NoError(t, err) time.Sleep(100 * time.Millisecond) // Verify it exists result, err := setup.Store.GetChunk(setup.ctx, TestNamespace, key) require.NoError(t, err) assert.Equal(t, "test", result.Properties["type"]) // Delete it err = setup.Store.Delete(setup.ctx, TestNamespace, key) require.NoError(t, err) // Verify it's gone _, err = setup.Store.GetChunk(setup.ctx, TestNamespace, key) assert.Error(t, err) }) t.Run("DeleteAll with filters", func(t *testing.T) { // Add multiple items with different types testItems := []struct { key string embedding []float32 metadata map[string]interface{} }{ { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "delete_me", "category": "test"}, }, { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "delete_me", "category": "test"}, }, { generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "keep_me", "category": "test"}, }, } for _, item := range testItems { err := setup.Store.Add(setup.ctx, TestNamespace, item.key, item.embedding, item.metadata) require.NoError(t, err) } time.Sleep(300 * time.Millisecond) // Delete all items with type "delete_me" queries := []Query{ {Field: "type", Operator: QueryOperatorEqual, Value: "delete_me"}, } deleteResults, err := setup.Store.DeleteAll(setup.ctx, TestNamespace, queries) require.NoError(t, err) assert.Len(t, deleteResults, 2) // Should delete 2 items // Verify only "keep_me" items remain allResults, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, nil, []string{"type"}, nil, 10) require.NoError(t, err) assert.Len(t, allResults, 1) // Only the "keep_me" item should remain assert.Equal(t, "keep_me", allResults[0].Properties["type"]) }) t.Run("DeleteAll with more than BatchLimit matches", func(t *testing.T) { const deleteCount = BatchLimit + 23 for i := 0; i < deleteCount; i++ { err := setup.Store.Add( setup.ctx, TestNamespace, generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "delete_me_large", "category": "test"}, ) require.NoError(t, err) } keepID := generateUUID() err := setup.Store.Add( setup.ctx, TestNamespace, keepID, generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "keep_large", "category": "test"}, ) require.NoError(t, err) time.Sleep(500 * time.Millisecond) deleteQuery := []Query{ {Field: "type", Operator: QueryOperatorEqual, Value: "delete_me_large"}, } beforeDelete, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, deleteQuery, []string{"type"}, nil, 0) require.NoError(t, err) require.Len(t, beforeDelete, deleteCount) deleteResults, err := setup.Store.DeleteAll(setup.ctx, TestNamespace, deleteQuery) require.NoError(t, err) assert.Len(t, deleteResults, deleteCount) afterDelete, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, deleteQuery, []string{"type"}, nil, 0) require.NoError(t, err) assert.Len(t, afterDelete, 0) keepDoc, err := setup.Store.GetChunk(setup.ctx, TestNamespace, keepID) require.NoError(t, err) assert.Equal(t, "keep_large", keepDoc.Properties["type"]) }) t.Run("getAllMatchingIDs returns matching ids", func(t *testing.T) { targetType := "ids_only_target" for i := 0; i < 3; i++ { err := setup.Store.Add( setup.ctx, TestNamespace, generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": targetType, "category": "test"}, ) require.NoError(t, err) } err := setup.Store.Add( setup.ctx, TestNamespace, generateUUID(), generateTestEmbedding(RedisTestDimension), map[string]interface{}{"type": "ids_only_other", "category": "test"}, ) require.NoError(t, err) time.Sleep(300 * time.Millisecond) ids, err := setup.Store.getAllMatchingIDs(setup.ctx, TestNamespace, []Query{ {Field: "type", Operator: QueryOperatorEqual, Value: targetType}, }) require.NoError(t, err) assert.Len(t, ids, 3) for _, id := range ids { assert.NotEmpty(t, id) } }) } // ============================================================================ // INTERFACE COMPLIANCE TESTS // ============================================================================ func TestRedisStore_InterfaceCompliance(t *testing.T) { // Verify that RedisStore implements VectorStore interface var _ VectorStore = (*RedisStore)(nil) } func TestVectorStoreFactory_Redis(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) config := &Config{ Enabled: true, Type: VectorStoreTypeRedis, Config: RedisConfig{ Addr: schemas.NewEnvVar(getEnvWithDefault("REDIS_ADDR", DefaultTestAddr)), Username: schemas.NewEnvVar("env.REDIS_USERNAME"), Password: schemas.NewEnvVar("env.REDIS_PASSWORD"), }, } store, err := NewVectorStore(context.Background(), config, logger) if err != nil { t.Skipf("Could not create Redis store: %v", err) } defer store.Close(context.Background(), TestNamespace) // Verify it's actually a RedisStore redisStore, ok := store.(*RedisStore) assert.True(t, ok) assert.NotNil(t, redisStore) } // ============================================================================ // ERROR HANDLING TESTS // ============================================================================ func TestRedisStore_ErrorHandling(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } setup := NewRedisTestSetup(t) defer setup.Cleanup(t) t.Run("GetChunk with non-existent key", func(t *testing.T) { _, err := setup.Store.GetChunk(setup.ctx, TestNamespace, "non-existent-key") assert.Error(t, err) }) t.Run("Delete non-existent key", func(t *testing.T) { err := setup.Store.Delete(setup.ctx, TestNamespace, "non-existent-key") assert.Error(t, err) }) t.Run("Add with empty ID", func(t *testing.T) { embedding := generateTestEmbedding(RedisTestDimension) metadata := map[string]interface{}{"type": "test"} err := setup.Store.Add(setup.ctx, TestNamespace, "", embedding, metadata) assert.Error(t, err) }) t.Run("GetNearest with empty namespace", func(t *testing.T) { embedding := generateTestEmbedding(RedisTestDimension) _, err := setup.Store.GetNearest(setup.ctx, "", embedding, nil, []string{}, 0.8, 10) assert.Error(t, err) }) } func TestRedisStore_NamespaceDimensionHandling(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } setup := NewRedisTestSetup(t) defer setup.Cleanup(t) testNamespace := "TestDimensionHandling" t.Run("Recreate namespace with different dimension should not crash", func(t *testing.T) { properties := map[string]VectorStoreProperties{ "type": {DataType: VectorStorePropertyTypeString}, "test": {DataType: VectorStorePropertyTypeString}, } // Step 1: Create namespace with dimension 512 err := setup.Store.CreateNamespace(setup.ctx, testNamespace, 512, properties) require.NoError(t, err) // Add a document with 512-dimensional embedding embedding512 := generateTestEmbedding(512) metadata := map[string]interface{}{ "type": "test_doc", "test": "dimension_512", } err = setup.Store.Add(setup.ctx, testNamespace, "test-key-512", embedding512, metadata) require.NoError(t, err) // Verify it was added result, err := setup.Store.GetChunk(setup.ctx, testNamespace, "test-key-512") require.NoError(t, err) assert.Equal(t, "dimension_512", result.Properties["test"]) // Step 2: Delete the namespace err = setup.Store.DeleteNamespace(setup.ctx, testNamespace) require.NoError(t, err) assert.Empty(t, setup.Store.getNamespaceFieldTypes(testNamespace)) // Step 3: Create namespace with same name but different dimension - should not crash err = setup.Store.CreateNamespace(setup.ctx, testNamespace, 1024, properties) require.NoError(t, err) // Add a document with 1024-dimensional embedding embedding1024 := generateTestEmbedding(1024) metadata1024 := map[string]interface{}{ "type": "test_doc", "test": "dimension_1024", } err = setup.Store.Add(setup.ctx, testNamespace, "test-key-1024", embedding1024, metadata1024) require.NoError(t, err) // Verify new document exists result, err = setup.Store.GetChunk(setup.ctx, testNamespace, "test-key-1024") require.NoError(t, err) assert.Equal(t, "dimension_1024", result.Properties["test"]) // Verify vector search works with new dimension vectorResults, err := setup.Store.GetNearest(setup.ctx, testNamespace, embedding1024, nil, []string{"type", "test"}, 0.8, 10) require.NoError(t, err) assert.GreaterOrEqual(t, len(vectorResults), 1) assert.NotNil(t, vectorResults[0].Score) // Cleanup err = setup.Store.DeleteNamespace(setup.ctx, testNamespace) if err != nil { t.Logf("Warning: Failed to cleanup namespace: %v", err) } assert.Empty(t, setup.Store.getNamespaceFieldTypes(testNamespace)) }) }