Files
MyGoNavi/internal/app/methods_redis_test.go
Syngnat a9bae6bb3f 🐛 fix(explorer): 补齐 Redis 部分导入与库节点加载态
- 新增 Redis 导入预览与按 key 勾选导入能力

- 后端支持导入文件预读、选中 key 过滤与本地化错误提示

- 打开数据库节点时同步展示加载态并补齐前后端回归测试

Fixes #607

Fixes #609
2026-06-30 21:17:00 +08:00

1046 lines
32 KiB
Go

package app
import (
"errors"
"fmt"
"reflect"
"sort"
"testing"
"GoNavi-Wails/internal/connection"
redislib "GoNavi-Wails/internal/redis"
)
type capturingRedisClient struct {
connectConfig connection.ConnectionConfig
deletedHashKey string
deletedHashFields []string
closed int
closeErr error
}
func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error {
c.connectConfig = config
return nil
}
func (c *capturingRedisClient) Close() error {
c.closed++
return c.closeErr
}
func (c *capturingRedisClient) Ping() error { return nil }
func (c *capturingRedisClient) ScanKeys(pattern string, cursor uint64, count int64) (*redislib.RedisScanResult, error) {
return &redislib.RedisScanResult{}, nil
}
func (c *capturingRedisClient) GetKeyType(key string) (string, error) { return "", nil }
func (c *capturingRedisClient) GetTTL(key string) (int64, error) { return 0, nil }
func (c *capturingRedisClient) SetTTL(key string, ttl int64) error { return nil }
func (c *capturingRedisClient) DeleteKeys(keys []string) (int64, error) { return 0, nil }
func (c *capturingRedisClient) RenameKey(oldKey, newKey string) error { return nil }
func (c *capturingRedisClient) KeyExists(key string) (bool, error) { return false, nil }
func (c *capturingRedisClient) GetValue(key string) (*redislib.RedisValue, error) {
return &redislib.RedisValue{}, nil
}
func (c *capturingRedisClient) GetString(key string) (string, error) { return "", nil }
func (c *capturingRedisClient) SetString(key, value string, ttl int64) error { return nil }
func (c *capturingRedisClient) GetHash(key string) (map[string]string, error) {
return map[string]string{}, nil
}
func (c *capturingRedisClient) SetHashField(key, field, value string) error { return nil }
func (c *capturingRedisClient) DeleteHashField(key string, fields ...string) error {
c.deletedHashKey = key
c.deletedHashFields = append([]string(nil), fields...)
return nil
}
func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string, error) {
return nil, nil
}
func (c *capturingRedisClient) ListPush(key string, values ...string) error { return nil }
func (c *capturingRedisClient) ListSet(key string, index int64, value string) error { return nil }
func (c *capturingRedisClient) GetSet(key string) ([]string, error) { return nil, nil }
func (c *capturingRedisClient) SetAdd(key string, members ...string) error { return nil }
func (c *capturingRedisClient) SetRemove(key string, members ...string) error { return nil }
func (c *capturingRedisClient) GetZSet(key string, start, stop int64) ([]redislib.ZSetMember, error) {
return nil, nil
}
func (c *capturingRedisClient) ZSetAdd(key string, members ...redislib.ZSetMember) error { return nil }
func (c *capturingRedisClient) ZSetRemove(key string, members ...string) error { return nil }
func (c *capturingRedisClient) GetStream(key, start, stop string, count int64) ([]redislib.StreamEntry, error) {
return nil, nil
}
func (c *capturingRedisClient) StreamAdd(key string, fields map[string]string, id string) (string, error) {
return "", nil
}
func (c *capturingRedisClient) StreamDelete(key string, ids ...string) (int64, error) { return 0, nil }
func (c *capturingRedisClient) ExecuteCommand(args []string) (interface{}, error) { return nil, nil }
func (c *capturingRedisClient) GetServerInfo() (map[string]string, error) {
return map[string]string{}, nil
}
func (c *capturingRedisClient) GetDatabases() ([]redislib.RedisDBInfo, error) { return nil, nil }
func (c *capturingRedisClient) SelectDB(index int) error { return nil }
func (c *capturingRedisClient) GetCurrentDB() int { return 0 }
func (c *capturingRedisClient) FlushDB() error { return nil }
type scriptedRedisClient struct {
capturingRedisClient
connectErr error
connectCalls *[]connection.ConnectionConfig
}
func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error {
c.connectConfig = config
if c.connectCalls != nil {
*c.connectCalls = append(*c.connectCalls, config)
}
return c.connectErr
}
type redisTransferTestClient struct {
capturingRedisClient
scanPages []*redislib.RedisScanResult
scanCallCount int
keyTypes map[string]string
ttls map[string]int64
stringValues map[string]string
hashValues map[string]map[string]string
listValues map[string][]string
setValues map[string][]string
zsetValues map[string][]redislib.ZSetMember
streamValues map[string][]redislib.StreamEntry
existingKeys map[string]bool
deletedKeys []string
setStringCalls []struct {
key string
value string
ttl int64
}
setTTLCalls []struct {
key string
ttl int64
}
setHashFieldCalls []struct {
key string
field string
value string
}
listPushCalls []struct {
key string
values []string
}
setAddCalls []struct {
key string
members []string
}
zsetAddCalls []struct {
key string
members []redislib.ZSetMember
}
streamAddCalls []struct {
key string
fields map[string]string
id string
}
}
func (c *redisTransferTestClient) ScanKeys(pattern string, cursor uint64, count int64) (*redislib.RedisScanResult, error) {
if c.scanCallCount >= len(c.scanPages) {
return &redislib.RedisScanResult{Cursor: "0"}, nil
}
page := c.scanPages[c.scanCallCount]
c.scanCallCount++
return page, nil
}
func (c *redisTransferTestClient) GetKeyType(key string) (string, error) {
if value, ok := c.keyTypes[key]; ok {
return value, nil
}
return "", fmt.Errorf("missing key type for %s", key)
}
func (c *redisTransferTestClient) GetTTL(key string) (int64, error) {
if value, ok := c.ttls[key]; ok {
return value, nil
}
return -1, nil
}
func (c *redisTransferTestClient) DeleteKeys(keys []string) (int64, error) {
c.deletedKeys = append(c.deletedKeys, keys...)
for _, key := range keys {
if c.existingKeys != nil {
delete(c.existingKeys, key)
}
}
return int64(len(keys)), nil
}
func (c *redisTransferTestClient) KeyExists(key string) (bool, error) {
if c.existingKeys == nil {
return false, nil
}
return c.existingKeys[key], nil
}
func (c *redisTransferTestClient) GetString(key string) (string, error) {
if value, ok := c.stringValues[key]; ok {
return value, nil
}
return "", fmt.Errorf("missing string value for %s", key)
}
func (c *redisTransferTestClient) SetString(key, value string, ttl int64) error {
c.setStringCalls = append(c.setStringCalls, struct {
key string
value string
ttl int64
}{key: key, value: value, ttl: ttl})
if c.existingKeys == nil {
c.existingKeys = make(map[string]bool)
}
c.existingKeys[key] = true
return nil
}
func (c *redisTransferTestClient) GetHash(key string) (map[string]string, error) {
if value, ok := c.hashValues[key]; ok {
cloned := make(map[string]string, len(value))
for field, item := range value {
cloned[field] = item
}
return cloned, nil
}
return nil, fmt.Errorf("missing hash value for %s", key)
}
func (c *redisTransferTestClient) SetHashField(key, field, value string) error {
c.setHashFieldCalls = append(c.setHashFieldCalls, struct {
key string
field string
value string
}{key: key, field: field, value: value})
if c.existingKeys == nil {
c.existingKeys = make(map[string]bool)
}
c.existingKeys[key] = true
return nil
}
func (c *redisTransferTestClient) GetList(key string, start, stop int64) ([]string, error) {
if value, ok := c.listValues[key]; ok {
return append([]string(nil), value...), nil
}
return nil, fmt.Errorf("missing list value for %s", key)
}
func (c *redisTransferTestClient) ListPush(key string, values ...string) error {
c.listPushCalls = append(c.listPushCalls, struct {
key string
values []string
}{key: key, values: append([]string(nil), values...)})
if c.existingKeys == nil {
c.existingKeys = make(map[string]bool)
}
c.existingKeys[key] = true
return nil
}
func (c *redisTransferTestClient) GetSet(key string) ([]string, error) {
if value, ok := c.setValues[key]; ok {
return append([]string(nil), value...), nil
}
return nil, fmt.Errorf("missing set value for %s", key)
}
func (c *redisTransferTestClient) SetAdd(key string, members ...string) error {
c.setAddCalls = append(c.setAddCalls, struct {
key string
members []string
}{key: key, members: append([]string(nil), members...)})
if c.existingKeys == nil {
c.existingKeys = make(map[string]bool)
}
c.existingKeys[key] = true
return nil
}
func (c *redisTransferTestClient) GetZSet(key string, start, stop int64) ([]redislib.ZSetMember, error) {
if value, ok := c.zsetValues[key]; ok {
return append([]redislib.ZSetMember(nil), value...), nil
}
return nil, fmt.Errorf("missing zset value for %s", key)
}
func (c *redisTransferTestClient) ZSetAdd(key string, members ...redislib.ZSetMember) error {
c.zsetAddCalls = append(c.zsetAddCalls, struct {
key string
members []redislib.ZSetMember
}{key: key, members: append([]redislib.ZSetMember(nil), members...)})
if c.existingKeys == nil {
c.existingKeys = make(map[string]bool)
}
c.existingKeys[key] = true
return nil
}
func (c *redisTransferTestClient) GetStream(key, start, stop string, count int64) ([]redislib.StreamEntry, error) {
values, ok := c.streamValues[key]
if !ok {
return nil, fmt.Errorf("missing stream value for %s", key)
}
startIndex := 0
if start != "" && start != "-" {
for index, item := range values {
if item.ID == start {
startIndex = index
break
}
}
}
if startIndex >= len(values) {
return []redislib.StreamEntry{}, nil
}
endIndex := len(values)
if count > 0 && startIndex+int(count) < endIndex {
endIndex = startIndex + int(count)
}
return append([]redislib.StreamEntry(nil), values[startIndex:endIndex]...), nil
}
func (c *redisTransferTestClient) StreamAdd(key string, fields map[string]string, id string) (string, error) {
cloned := make(map[string]string, len(fields))
for field, value := range fields {
cloned[field] = value
}
c.streamAddCalls = append(c.streamAddCalls, struct {
key string
fields map[string]string
id string
}{key: key, fields: cloned, id: id})
if c.existingKeys == nil {
c.existingKeys = make(map[string]bool)
}
c.existingKeys[key] = true
return id, nil
}
func (c *redisTransferTestClient) SetTTL(key string, ttl int64) error {
c.setTTLCalls = append(c.setTTLCalls, struct {
key string
ttl int64
}{key: key, ttl: ttl})
return nil
}
func TestRedisTestConnectionUsesIsolatedClientAndClosesIt(t *testing.T) {
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
proxySnapshot := currentGlobalProxyConfig()
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil {
t.Fatalf("restore global proxy failed: %v", err)
}
CloseAllRedisClients()
}()
CloseAllRedisClients()
if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil {
t.Fatalf("disable global proxy failed: %v", err)
}
client := &capturingRedisClient{}
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
app := NewApp()
result := app.RedisTestConnection(connection.ConnectionConfig{
Type: "redis",
Host: "127.0.0.1",
Port: 6379,
})
if !result.Success {
t.Fatalf("expected redis test connection success, got %s", result.Message)
}
if client.closed != 1 {
t.Fatalf("expected isolated redis test client to be closed once, got %d", client.closed)
}
if len(redisCache) != 0 {
t.Fatalf("redis test connection must not write global redis cache, got %d entries", len(redisCache))
}
}
func TestRedisTestConnectionReturnsLocalizedCloseFailure(t *testing.T) {
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
proxySnapshot := currentGlobalProxyConfig()
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil {
t.Fatalf("restore global proxy failed: %v", err)
}
CloseAllRedisClients()
}()
CloseAllRedisClients()
if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil {
t.Fatalf("disable global proxy failed: %v", err)
}
client := &capturingRedisClient{closeErr: errors.New("close failed")}
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
app := NewApp()
result := app.RedisTestConnection(connection.ConnectionConfig{
Type: "redis",
Host: "127.0.0.1",
Port: 6379,
})
if result.Success {
t.Fatalf("expected localized close failure, got success with %q", result.Message)
}
if want := app.appText("redis.backend.error.test_connection_close_failed", map[string]any{"detail": "close failed"}); result.Message != want {
t.Fatalf("expected localized close failure message %q, got %q", want, result.Message)
}
if client.closed != 1 {
t.Fatalf("expected isolated redis test client to be closed once, got %d", client.closed)
}
}
func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
testCases := []struct {
name string
savedConfig connection.ConnectionConfig
runtimeConfig connection.ConnectionConfig
assertResolved func(t *testing.T, got connection.ConnectionConfig)
}{
{
name: "redis and ssh secrets",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
Password: "redis-secret",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "ssh.local",
Port: 22,
User: "ops",
Password: "ssh-secret",
},
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
UseSSH: true,
SSH: connection.SSHConfig{
Host: "ssh.local",
Port: 22,
User: "ops",
},
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.Password != "redis-secret" {
t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password)
}
if got.SSH.Password != "ssh-secret" {
t.Fatalf("expected RedisConnect to resolve saved SSH password, got %q", got.SSH.Password)
}
},
},
{
name: "proxy secret",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
Password: "redis-secret",
UseProxy: true,
Proxy: connection.ProxyConfig{
Type: "http",
Host: "proxy.local",
Port: 8080,
User: "proxy-user",
Password: "proxy-secret",
},
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
UseProxy: true,
Proxy: connection.ProxyConfig{
Type: "http",
Host: "proxy.local",
Port: 8080,
User: "proxy-user",
},
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.Password != "redis-secret" {
t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password)
}
if got.Proxy.Password != "proxy-secret" {
t.Fatalf("expected RedisConnect to resolve saved proxy password, got %q", got.Proxy.Password)
}
},
},
{
name: "http tunnel secret",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
Password: "redis-secret",
UseHTTPTunnel: true,
HTTPTunnel: connection.HTTPTunnelConfig{
Host: "tunnel.local",
Port: 8443,
User: "tunnel-user",
Password: "tunnel-secret",
},
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
UseHTTPTunnel: true,
HTTPTunnel: connection.HTTPTunnelConfig{
Host: "tunnel.local",
Port: 8443,
User: "tunnel-user",
},
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.Password != "redis-secret" {
t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password)
}
if got.HTTPTunnel.Password != "tunnel-secret" {
t.Fatalf("expected RedisConnect to resolve saved HTTP tunnel password, got %q", got.HTTPTunnel.Password)
}
},
},
{
name: "explicit redis username from uri is preserved even when it is root",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
Password: "redis-secret",
URI: "redis://root:redis-secret@redis.local:6379/0",
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.User != "root" {
t.Fatalf("expected RedisConnect to preserve explicit uri user root, got %q", got.User)
}
if got.URI != "redis://root:redis-secret@redis.local:6379/0" {
t.Fatalf("expected RedisConnect to restore saved redis uri, got %q", got.URI)
}
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "redis-1",
Name: "Redis Saved",
Config: testCase.savedConfig,
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
CloseAllRedisClients()
client := &capturingRedisClient{}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisConnect(testCase.runtimeConfig)
if !result.Success {
t.Fatalf("RedisConnect returned failure: %+v", result)
}
testCase.assertResolved(t, client.connectConfig)
})
}
}
func TestRedisConnectPreservesExplicitRootUserWithoutURIWhenConnectSucceeds(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "redis-1",
Name: "Redis Saved",
Config: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
Password: "redis-secret",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
CloseAllRedisClients()
connectCalls := make([]connection.ConnectionConfig, 0, 1)
client := &scriptedRedisClient{connectCalls: &connectCalls}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisConnect(connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
})
if !result.Success {
t.Fatalf("RedisConnect returned failure: %+v", result)
}
if len(connectCalls) != 1 {
t.Fatalf("expected exactly one Redis connect attempt, got %d", len(connectCalls))
}
if connectCalls[0].User != "root" {
t.Fatalf("expected RedisConnect to preserve explicit root user when connect succeeds, got %q", connectCalls[0].User)
}
}
func TestRedisConnectRetriesLegacyDefaultRootUserWithoutUsernameAfterAuthFailure(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "redis-1",
Name: "Redis Saved",
Config: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
Password: "redis-secret",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
CloseAllRedisClients()
connectCalls := make([]connection.ConnectionConfig, 0, 2)
clients := []redislib.RedisClient{
&scriptedRedisClient{
connectErr: errors.New("WRONGPASS invalid username-password pair"),
connectCalls: &connectCalls,
},
&scriptedRedisClient{
connectCalls: &connectCalls,
},
}
clientIndex := 0
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
if clientIndex >= len(clients) {
t.Fatalf("unexpected Redis client allocation #%d", clientIndex+1)
}
client := clients[clientIndex]
clientIndex++
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisConnect(connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
User: "root",
})
if !result.Success {
t.Fatalf("RedisConnect returned failure after fallback: %+v", result)
}
if len(connectCalls) != 2 {
t.Fatalf("expected RedisConnect to retry exactly once after auth failure, got %d attempts", len(connectCalls))
}
if connectCalls[0].User != "root" {
t.Fatalf("expected first Redis connect attempt to keep root user, got %q", connectCalls[0].User)
}
if connectCalls[1].User != "" {
t.Fatalf("expected fallback Redis connect attempt to clear legacy root user, got %q", connectCalls[1].User)
}
}
func TestRedisDeleteHashFieldAcceptsSingleStringField(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
CloseAllRedisClients()
client := &capturingRedisClient{}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisDeleteHashField(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "profile", "nickname")
if !result.Success {
t.Fatalf("RedisDeleteHashField returned failure: %+v", result)
}
if client.deletedHashKey != "profile" {
t.Fatalf("expected hash key profile, got %q", client.deletedHashKey)
}
if len(client.deletedHashFields) != 1 || client.deletedHashFields[0] != "nickname" {
t.Fatalf("expected one deleted hash field nickname, got %v", client.deletedHashFields)
}
}
func TestRedisDeleteHashFieldAcceptsStringSlice(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
CloseAllRedisClients()
client := &capturingRedisClient{}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisDeleteHashField(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "profile", []string{"nickname", "avatar"})
if !result.Success {
t.Fatalf("RedisDeleteHashField returned failure: %+v", result)
}
if client.deletedHashKey != "profile" {
t.Fatalf("expected hash key profile, got %q", client.deletedHashKey)
}
if len(client.deletedHashFields) != 2 || client.deletedHashFields[0] != "nickname" || client.deletedHashFields[1] != "avatar" {
t.Fatalf("unexpected deleted hash fields: %v", client.deletedHashFields)
}
}
func TestBuildRedisExportPayloadCollectsPagedKeysAndSerializesAllSupportedShapes(t *testing.T) {
client := &redisTransferTestClient{
scanPages: []*redislib.RedisScanResult{
{
Keys: []redislib.RedisKeyInfo{
{Key: "beta"},
{Key: "alpha"},
},
Cursor: "1",
},
{
Keys: []redislib.RedisKeyInfo{
{Key: "alpha"},
{Key: "events"},
},
Cursor: "0",
},
},
keyTypes: map[string]string{
"alpha": "string",
"beta": "set",
"events": "stream",
},
ttls: map[string]int64{
"alpha": 120,
"beta": -1,
"events": 45,
},
stringValues: map[string]string{
"alpha": "value-1",
},
setValues: map[string][]string{
"beta": {"member-b", "member-a"},
},
streamValues: map[string][]redislib.StreamEntry{
"events": {
{ID: "1710000000000-0", Fields: map[string]string{"kind": "start"}},
{ID: "1710000000001-0", Fields: map[string]string{"kind": "finish"}},
},
},
}
payload, err := buildRedisExportPayload(client, 7, RedisExportKeysOptions{
Scope: "all",
Pattern: "app:*",
})
if err != nil {
t.Fatalf("buildRedisExportPayload returned error: %v", err)
}
if payload.Format != redisTransferFileFormat || payload.Version != redisTransferFileVersion {
t.Fatalf("unexpected redis transfer header: %+v", payload)
}
if payload.Database != 7 || payload.Scope != "all" || payload.Pattern != "app:*" {
t.Fatalf("unexpected redis transfer metadata: %+v", payload)
}
if got := len(payload.Keys); got != 3 {
t.Fatalf("expected 3 exported keys, got %d", got)
}
if payload.Keys[0].Key != "alpha" || payload.Keys[1].Key != "beta" || payload.Keys[2].Key != "events" {
t.Fatalf("expected sorted exported keys, got %+v", payload.Keys)
}
if value, ok := payload.Keys[0].Value.(string); !ok || value != "value-1" {
t.Fatalf("expected string payload for alpha, got %#v", payload.Keys[0].Value)
}
setMembers, ok := payload.Keys[1].Value.([]string)
if !ok {
t.Fatalf("expected set payload slice, got %#v", payload.Keys[1].Value)
}
if !reflect.DeepEqual(setMembers, []string{"member-a", "member-b"}) {
t.Fatalf("expected sorted set members, got %v", setMembers)
}
streamEntries, ok := payload.Keys[2].Value.([]redislib.StreamEntry)
if !ok {
t.Fatalf("expected stream payload slice, got %#v", payload.Keys[2].Value)
}
if len(streamEntries) != 2 || streamEntries[1].Fields["kind"] != "finish" {
t.Fatalf("unexpected stream payload: %+v", streamEntries)
}
}
func TestImportRedisTransferPayloadHonorsConflictMode(t *testing.T) {
basePayload := redisTransferFile{
Keys: []redisTransferEntry{
{Key: "existing", Type: "string", TTL: 90, Value: "updated"},
{Key: "profile", Type: "hash", TTL: 30, Value: map[string]string{"name": "neo", "role": "admin"}},
{Key: "events", Type: "stream", TTL: -1, Value: []redislib.StreamEntry{{ID: "1710000000000-0", Fields: map[string]string{"status": "ok"}}}},
},
}
skipClient := &redisTransferTestClient{
existingKeys: map[string]bool{
"existing": true,
},
}
skipSummary, err := importRedisTransferPayload(skipClient, basePayload, RedisImportKeysOptions{ConflictMode: "skip"})
if err != nil {
t.Fatalf("importRedisTransferPayload(skip) returned error: %v", err)
}
if skipSummary["imported"] != 2 || skipSummary["skipped"] != 1 || skipSummary["total"] != 3 {
t.Fatalf("unexpected skip import summary: %+v", skipSummary)
}
if len(skipClient.deletedKeys) != 0 {
t.Fatalf("skip mode should not delete existing keys, got %v", skipClient.deletedKeys)
}
if len(skipClient.setStringCalls) != 0 {
t.Fatalf("skip mode should not overwrite existing string keys, got %+v", skipClient.setStringCalls)
}
if len(skipClient.setHashFieldCalls) != 2 {
t.Fatalf("expected hash fields to import in skip mode, got %+v", skipClient.setHashFieldCalls)
}
sort.Slice(skipClient.setHashFieldCalls, func(i, j int) bool {
return skipClient.setHashFieldCalls[i].field < skipClient.setHashFieldCalls[j].field
})
if skipClient.setHashFieldCalls[0].field != "name" || skipClient.setHashFieldCalls[1].field != "role" {
t.Fatalf("unexpected imported hash fields: %+v", skipClient.setHashFieldCalls)
}
if len(skipClient.setTTLCalls) != 1 || skipClient.setTTLCalls[0].key != "profile" || skipClient.setTTLCalls[0].ttl != 30 {
t.Fatalf("expected imported hash ttl to be restored, got %+v", skipClient.setTTLCalls)
}
if len(skipClient.streamAddCalls) != 1 || skipClient.streamAddCalls[0].id != "1710000000000-0" {
t.Fatalf("expected stream entry import in skip mode, got %+v", skipClient.streamAddCalls)
}
overwriteClient := &redisTransferTestClient{
existingKeys: map[string]bool{
"existing": true,
},
}
overwriteSummary, err := importRedisTransferPayload(overwriteClient, basePayload, RedisImportKeysOptions{ConflictMode: "overwrite"})
if err != nil {
t.Fatalf("importRedisTransferPayload(overwrite) returned error: %v", err)
}
if overwriteSummary["imported"] != 3 || overwriteSummary["skipped"] != 0 || overwriteSummary["total"] != 3 {
t.Fatalf("unexpected overwrite import summary: %+v", overwriteSummary)
}
if !reflect.DeepEqual(overwriteClient.deletedKeys, []string{"existing"}) {
t.Fatalf("expected overwrite mode to delete the existing key first, got %v", overwriteClient.deletedKeys)
}
if len(overwriteClient.setStringCalls) != 1 {
t.Fatalf("expected overwrite mode to restore one string key, got %+v", overwriteClient.setStringCalls)
}
if overwriteClient.setStringCalls[0].key != "existing" || overwriteClient.setStringCalls[0].value != "updated" || overwriteClient.setStringCalls[0].ttl != 90 {
t.Fatalf("unexpected overwrite string restore call: %+v", overwriteClient.setStringCalls[0])
}
}
func TestImportRedisTransferPayloadHonorsSelectedScope(t *testing.T) {
payload := redisTransferFile{
Keys: []redisTransferEntry{
{Key: "existing", Type: "string", TTL: 90, Value: "updated"},
{Key: "profile", Type: "hash", TTL: 30, Value: map[string]string{"name": "neo"}},
{Key: "events", Type: "stream", TTL: -1, Value: []redislib.StreamEntry{{ID: "1710000000000-0", Fields: map[string]string{"status": "ok"}}}},
},
}
client := &redisTransferTestClient{
existingKeys: map[string]bool{
"existing": true,
},
}
result, err := importRedisTransferPayload(client, payload, RedisImportKeysOptions{
Scope: "selected",
Keys: []string{"profile", "events"},
ConflictMode: "overwrite",
})
if err != nil {
t.Fatalf("importRedisTransferPayload(selected) returned error: %v", err)
}
if result["imported"] != 2 || result["skipped"] != 0 || result["total"] != 2 {
t.Fatalf("unexpected selected import summary: %+v", result)
}
if len(client.deletedKeys) != 0 {
t.Fatalf("selected import should not touch unselected keys, got %v", client.deletedKeys)
}
if len(client.setStringCalls) != 0 {
t.Fatalf("selected import should not restore unselected string keys, got %+v", client.setStringCalls)
}
if len(client.setHashFieldCalls) != 1 || client.setHashFieldCalls[0].key != "profile" {
t.Fatalf("expected only selected hash key import, got %+v", client.setHashFieldCalls)
}
if len(client.streamAddCalls) != 1 || client.streamAddCalls[0].key != "events" {
t.Fatalf("expected only selected stream key import, got %+v", client.streamAddCalls)
}
}
func TestSelectRedisTransferEntriesForImportRequiresSelectedKeys(t *testing.T) {
payload := redisTransferFile{
Keys: []redisTransferEntry{
{Key: "alpha", Type: "string", TTL: -1, Value: "1"},
},
}
_, err := selectRedisTransferEntriesForImport(payload, RedisImportKeysOptions{Scope: "selected"})
if !errors.Is(err, errRedisImportNoKeysSelected) {
t.Fatalf("expected errRedisImportNoKeysSelected, got %v", err)
}
}