Files
bifrost/framework/vectorstore/redis_test.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

1818 lines
52 KiB
Go

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))
})
}