🐛 fix(redis): 修复列表新增与精确删除

- 对齐新版 Redis List 头尾新增的 Wails 参数契约

- 分别使用 LPUSH 与 RPUSH 保证插入位置语义

- 按索引原子删除并避免重复值误删

- 补充多语言文案与前后端回归测试

Refs #639
This commit is contained in:
Syngnat
2026-08-05 22:33:53 +08:00
parent 450b70178c
commit 005113dd79
17 changed files with 622 additions and 70 deletions

View File

@@ -54,6 +54,11 @@ type RedisImportKeysOptions struct {
File string `json:"file,omitempty"`
}
type RedisListPushOptions struct {
Values []string `json:"values"`
Position string `json:"position"`
}
type RedisImportPreview struct {
File string `json:"file"`
ExportedAt string `json:"exportedAt,omitempty"`
@@ -1379,14 +1384,31 @@ func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key strin
}
// RedisListPush pushes values to a list
func (a *App) RedisListPush(config connection.ConnectionConfig, key string, values []string) connection.QueryResult {
func (a *App) RedisListPush(config connection.ConnectionConfig, key string, options RedisListPushOptions) connection.QueryResult {
if len(options.Values) == 0 {
return connection.QueryResult{
Success: false,
Message: a.appText("redis.backend.error.argument_required", map[string]any{"name": "values"}),
}
}
if options.Position != "left" && options.Position != "right" {
return connection.QueryResult{
Success: false,
Message: a.appText("redis.backend.error.list_position_invalid", nil),
}
}
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := client.ListPush(key, values...); err != nil {
push := client.ListPush
if options.Position == "left" {
push = client.ListPushLeft
}
if err := push(key, options.Values...); err != nil {
logger.Error(err, "RedisListPush 添加失败key=%s", key)
return connection.QueryResult{Success: false, Message: err.Error()}
}
@@ -1410,16 +1432,22 @@ func (a *App) RedisListSet(config connection.ConnectionConfig, key string, index
return connection.QueryResult{Success: true, Message: a.appText("redis.backend.message.set_success", nil)}
}
// RedisListRemove removes one matching value from a list.
func (a *App) RedisListRemove(config connection.ConnectionConfig, key, value string) connection.QueryResult {
// RedisListRemove removes the expected value at an index in a list.
func (a *App) RedisListRemove(config connection.ConnectionConfig, key string, index int64, value string) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := client.ListRemove(key, value); err != nil {
logger.Error(err, "RedisListRemove 删除失败key=%s", key)
if err := client.ListRemoveAt(key, index, value); err != nil {
if errors.Is(err, redis.ErrRedisListItemChanged) {
return connection.QueryResult{
Success: false,
Message: a.appText("redis.backend.error.list_item_changed", nil),
}
}
logger.Error(err, "RedisListRemove 删除失败key=%s index=%d", key, index)
return connection.QueryResult{Success: false, Message: err.Error()}
}

View File

@@ -1,12 +1,12 @@
package app
import (
"errors"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
redislib "GoNavi-Wails/internal/redis"
"GoNavi-Wails/shared/i18n"
"errors"
"strings"
"testing"
)
func redisFunctionSource(t *testing.T, source string, signature string) string {
@@ -23,7 +23,6 @@ func redisFunctionSource(t *testing.T, source string, signature string) string {
return source[start : start+len(signature)+end]
}
func TestRedisBackendOperationMessageCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
@@ -44,6 +43,8 @@ func TestRedisBackendOperationMessageCatalogKeysExist(t *testing.T) {
"redis.backend.error.command_required",
"redis.backend.error.argument_required",
"redis.backend.error.argument_invalid_type",
"redis.backend.error.list_item_changed",
"redis.backend.error.list_position_invalid",
"redis.backend.error.export_no_keys",
"redis.backend.error.import_no_keys_selected",
"redis.backend.error.import_payload_invalid",

View File

@@ -12,19 +12,25 @@ import (
)
type capturingRedisClient struct {
connectConfig connection.ConnectionConfig
deletedHashKey string
deletedHashFields []string
removedListKey string
removedListValue string
valueResult *redislib.RedisValue
listResult []string
listKey string
listStart int64
listStop int64
listCalls int
closed int
closeErr error
connectConfig connection.ConnectionConfig
deletedHashKey string
deletedHashFields []string
listPushKey string
listPushValues []string
listPushLeftKey string
listPushLeftValues []string
removedListKey string
removedListIndex int64
removedListValue string
listRemoveErr error
valueResult *redislib.RedisValue
listResult []string
listKey string
listStart int64
listStop int64
listCalls int
closed int
closeErr error
}
func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error {
@@ -86,14 +92,25 @@ func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string,
return append([]string(nil), c.listResult...), nil
}
func (c *capturingRedisClient) ListPush(key string, values ...string) error { return nil }
func (c *capturingRedisClient) ListPush(key string, values ...string) error {
c.listPushKey = key
c.listPushValues = append([]string(nil), values...)
return nil
}
func (c *capturingRedisClient) ListPushLeft(key string, values ...string) error {
c.listPushLeftKey = key
c.listPushLeftValues = append([]string(nil), values...)
return nil
}
func (c *capturingRedisClient) ListSet(key string, index int64, value string) error { return nil }
func (c *capturingRedisClient) ListRemove(key, value string) error {
func (c *capturingRedisClient) ListRemoveAt(key string, index int64, value string) error {
c.removedListKey = key
c.removedListIndex = index
c.removedListValue = value
return nil
return c.listRemoveErr
}
func (c *capturingRedisClient) GetSet(key string) ([]string, error) { return nil, nil }
@@ -919,7 +936,161 @@ func TestRedisGetListValueDescendingReadsAndReversesTailWindow(t *testing.T) {
}
}
func TestRedisListRemoveDeletesOneValue(t *testing.T) {
func TestRedisListPushAppendsValuesWithoutChangingThem(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
}
values := []string{" review ", "", "\t"}
result := app.RedisListPush(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "tasks", RedisListPushOptions{
Values: values,
Position: "right",
})
if !result.Success {
t.Fatalf("RedisListPush returned failure: %+v", result)
}
if client.listPushKey != "tasks" || !reflect.DeepEqual(client.listPushValues, values) {
t.Fatalf("unexpected list push call: key=%q values=%#v", client.listPushKey, client.listPushValues)
}
}
func TestRedisListPushPrependsValues(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
}
values := []string{"review", "ship"}
result := app.RedisListPush(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "tasks", RedisListPushOptions{
Values: values,
Position: "left",
})
if !result.Success {
t.Fatalf("RedisListPush returned failure: %+v", result)
}
if client.listPushLeftKey != "tasks" || !reflect.DeepEqual(client.listPushLeftValues, values) {
t.Fatalf("unexpected left list push call: key=%q values=%#v", client.listPushLeftKey, client.listPushLeftValues)
}
if client.listPushKey != "" || client.listPushValues != nil {
t.Fatalf("right list push must not be used: key=%q values=%#v", client.listPushKey, client.listPushValues)
}
}
func TestRedisListPushRejectsEmptyValues(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.RedisListPush(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "tasks", RedisListPushOptions{Position: "right"})
if result.Success {
t.Fatalf("RedisListPush should reject empty values: %+v", result)
}
want := app.appText("redis.backend.error.argument_required", map[string]any{"name": "values"})
if result.Message != want {
t.Fatalf("unexpected empty values error: got %q want %q", result.Message, want)
}
if client.listPushKey != "" || client.listPushLeftKey != "" {
t.Fatalf("empty values must not be pushed: right=%q left=%q", client.listPushKey, client.listPushLeftKey)
}
}
func TestRedisListPushRejectsInvalidPosition(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.RedisListPush(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "tasks", RedisListPushOptions{
Values: []string{"review"},
Position: "LEFT",
})
if result.Success {
t.Fatalf("RedisListPush should reject an invalid position: %+v", result)
}
want := app.appText("redis.backend.error.list_position_invalid", nil)
if result.Message != want {
t.Fatalf("unexpected invalid position error: got %q want %q", result.Message, want)
}
if client.listPushKey != "" || client.listPushLeftKey != "" {
t.Fatalf("invalid position must not be pushed: right=%q left=%q", client.listPushKey, client.listPushLeftKey)
}
}
func TestRedisListRemoveDeletesTheSelectedValue(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -943,12 +1114,48 @@ func TestRedisListRemoveDeletesOneValue(t *testing.T) {
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "tasks", "review")
}, "tasks", 3, "review")
if !result.Success {
t.Fatalf("RedisListRemove returned failure: %+v", result)
}
if client.removedListKey != "tasks" || client.removedListValue != "review" {
t.Fatalf("unexpected list remove call: key=%q value=%q", client.removedListKey, client.removedListValue)
if client.removedListKey != "tasks" || client.removedListIndex != 3 || client.removedListValue != "review" {
t.Fatalf("unexpected list remove call: key=%q index=%d value=%q", client.removedListKey, client.removedListIndex, client.removedListValue)
}
}
func TestRedisListRemoveReportsWhenTheSelectedItemChanged(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
CloseAllRedisClients()
client := &capturingRedisClient{
listRemoveErr: fmt.Errorf("concurrent update: %w", redislib.ErrRedisListItemChanged),
}
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.RedisListRemove(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "tasks", 3, "review")
if result.Success {
t.Fatalf("RedisListRemove should report a changed item: %+v", result)
}
want := app.appText("redis.backend.error.list_item_changed", nil)
if result.Message != want {
t.Fatalf("unexpected changed item error: got %q want %q", result.Message, want)
}
}

View File

@@ -60,8 +60,9 @@ type RedisClient interface {
// List operations
GetList(key string, start, stop int64) ([]string, error)
ListPush(key string, values ...string) error
ListPushLeft(key string, values ...string) error
ListSet(key string, index int64, value string) error
ListRemove(key, value string) error
ListRemoveAt(key string, index int64, expectedValue string) error
// Set operations
GetSet(key string) ([]string, error)

View File

@@ -19,10 +19,14 @@ import (
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/ssh"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
var ErrRedisKeyGone = errors.New("Redis Key 不存在或已过期")
var (
ErrRedisKeyGone = errors.New("Redis Key 不存在或已过期")
ErrRedisListItemChanged = errors.New("Redis 列表项已变化,请刷新后重试")
)
// RedisClientImpl implements RedisClient using go-redis
type RedisClientImpl struct {
@@ -49,6 +53,29 @@ const (
redisSearchMaxStepCount int64 = 1000
redisSearchMaxResultCount = 10000
redisSearchMaxDuration = 3 * time.Second
redisListRemoveAtScript = `
local current = redis.call("LINDEX", KEYS[1], ARGV[1])
if current == false or current ~= ARGV[2] then
return 0
end
redis.call("LSET", KEYS[1], ARGV[1], ARGV[3])
local removed = redis.pcall("LREM", KEYS[1], 1, ARGV[3])
if type(removed) == "table" and removed.err then
local restored = redis.pcall("LSET", KEYS[1], ARGV[1], ARGV[2])
if type(restored) == "table" and restored.err then
return redis.error_reply(removed.err .. "; rollback failed: " .. restored.err)
end
return redis.error_reply(removed.err)
end
if removed ~= 1 then
local restored = redis.pcall("LSET", KEYS[1], ARGV[1], ARGV[2])
if type(restored) == "table" and restored.err then
return redis.error_reply("Redis list delete rollback failed: " .. restored.err)
end
return -1
end
return 1
`
)
var redisDBSwitchConnect = func(client *RedisClientImpl, config connection.ConnectionConfig) error {
@@ -1212,6 +1239,20 @@ func (r *RedisClientImpl) ListPush(key string, values ...string) error {
return r.client.RPush(ctx, r.toPhysicalKey(key), args...).Err()
}
// ListPushLeft pushes values to the start of a list.
func (r *RedisClientImpl) ListPushLeft(key string, values ...string) error {
if r.client == nil {
return fmt.Errorf("Redis 客户端未连接")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
args := make([]interface{}, len(values))
for i, value := range values {
args[i] = value
}
return r.client.LPush(ctx, r.toPhysicalKey(key), args...).Err()
}
// ListSet sets the value at an index in a list
func (r *RedisClientImpl) ListSet(key string, index int64, value string) error {
if r.client == nil {
@@ -1222,14 +1263,39 @@ func (r *RedisClientImpl) ListSet(key string, index int64, value string) error {
return r.client.LSet(ctx, r.toPhysicalKey(key), index, value).Err()
}
// ListRemove removes one matching value from a list.
func (r *RedisClientImpl) ListRemove(key, value string) error {
// ListRemoveAt removes the expected value at an index atomically.
// The operation requires Redis scripting permission in addition to list command permissions.
func (r *RedisClientImpl) ListRemoveAt(key string, index int64, expectedValue string) error {
if r.client == nil {
return fmt.Errorf("Redis 客户端未连接")
}
markerID, err := uuid.NewRandom()
if err != nil {
return fmt.Errorf("生成 Redis 列表删除标记失败: %w", err)
}
marker := "\x00gonavi:list-remove:" + markerID.String()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return r.client.LRem(ctx, r.toPhysicalKey(key), 1, value).Err()
result, err := r.client.Eval(
ctx,
redisListRemoveAtScript,
[]string{r.toPhysicalKey(key)},
strconv.FormatInt(index, 10),
expectedValue,
marker,
).Int64()
if err != nil {
return err
}
switch result {
case 0:
return ErrRedisListItemChanged
case 1:
return nil
default:
return fmt.Errorf("Redis 列表按索引删除返回异常结果: %d", result)
}
}
// GetSet gets all members of a set

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
goredis "github.com/redis/go-redis/v9"
"io"
"math"
"math/big"
@@ -18,7 +19,6 @@ import (
"sync"
"testing"
"time"
goredis "github.com/redis/go-redis/v9"
)
func startRedisProtocolTestServer(t *testing.T, handler func([]string) string) string {
@@ -576,7 +576,6 @@ func TestRedisConnectFailureWrappersUseEnglishPrefixes(t *testing.T) {
}
}
func TestRedisExecuteCommandClusterSelectValidationUsesEnglishMessages(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
@@ -634,7 +633,6 @@ func TestRedisExecuteCommandClusterSelectValidationUsesEnglishMessages(t *testin
}
}
func TestRedisSelectDBClusterRangeUsesEnglishMessage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
@@ -704,17 +702,17 @@ func TestRedisGetDatabasesUsesConfiguredDatabaseCountAboveDefault(t *testing.T)
}
}
func TestListRemoveUsesLRemForOneMatchingValue(t *testing.T) {
var commands [][]string
func TestListPushUsesRPushWithAllValues(t *testing.T) {
commandCh := make(chan []string, 1)
addr := startRedisProtocolTestServer(t, func(args []string) string {
commands = append(commands, append([]string(nil), args...))
switch strings.ToUpper(strings.TrimSpace(args[0])) {
case "HELLO":
return "-ERR unknown command 'HELLO'\r\n"
case "CLIENT":
return "-ERR unknown subcommand\r\n"
case "LREM":
return ":1\r\n"
case "RPUSH":
commandCh <- append([]string(nil), args...)
return ":2\r\n"
}
return "+OK\r\n"
})
@@ -729,19 +727,178 @@ func TestListRemoveUsesLRemForOneMatchingValue(t *testing.T) {
}
defer client.Close()
if err := client.ListRemove("tasks", "review"); err != nil {
t.Fatalf("ListRemove returned error: %v", err)
if err := client.ListPush("tasks", "review", "ship"); err != nil {
t.Fatalf("ListPush returned error: %v", err)
}
for _, command := range commands {
if len(command) == 4 && strings.EqualFold(command[0], "LREM") {
if command[1] != "tasks" || command[2] != "1" || command[3] != "review" {
t.Fatalf("unexpected LREM command: %v", command)
}
return
select {
case command := <-commandCh:
if len(command) != 4 || command[1] != "tasks" || command[2] != "review" || command[3] != "ship" {
t.Fatalf("unexpected RPUSH command: %v", command)
}
case <-time.After(time.Second):
t.Fatal("expected RPUSH command")
}
}
func TestListPushLeftUsesLPushWithAllValues(t *testing.T) {
commandCh := make(chan []string, 1)
addr := startRedisProtocolTestServer(t, func(args []string) string {
switch strings.ToUpper(strings.TrimSpace(args[0])) {
case "HELLO":
return "-ERR unknown command 'HELLO'\r\n"
case "CLIENT":
return "-ERR unknown subcommand\r\n"
case "LPUSH":
commandCh <- append([]string(nil), args...)
return ":2\r\n"
}
return "+OK\r\n"
})
rawClient := goredis.NewClient(&goredis.Options{
Addr: addr,
Protocol: 2,
})
client := &RedisClientImpl{
client: rawClient,
singleClient: rawClient,
}
defer client.Close()
if err := client.ListPushLeft("tasks", "review", "ship"); err != nil {
t.Fatalf("ListPushLeft returned error: %v", err)
}
select {
case command := <-commandCh:
if len(command) != 4 || command[1] != "tasks" || command[2] != "review" || command[3] != "ship" {
t.Fatalf("unexpected LPUSH command: %v", command)
}
case <-time.After(time.Second):
t.Fatal("expected LPUSH command")
}
}
func TestListRemoveAtUsesAtomicEvalWithPhysicalKeyAndExpectedValue(t *testing.T) {
commandCh := make(chan []string, 1)
addr := startRedisProtocolTestServer(t, func(args []string) string {
switch strings.ToUpper(strings.TrimSpace(args[0])) {
case "HELLO":
return "-ERR unknown command 'HELLO'\r\n"
case "CLIENT":
return "-ERR unknown subcommand\r\n"
case "EVAL":
commandCh <- append([]string(nil), args...)
return ":1\r\n"
}
return "+OK\r\n"
})
rawClient := goredis.NewClient(&goredis.Options{
Addr: addr,
Protocol: 2,
})
client := &RedisClientImpl{
client: rawClient,
singleClient: rawClient,
isCluster: true,
currentDB: 3,
}
defer client.Close()
if err := client.ListRemoveAt("tasks", 3, "review"); err != nil {
t.Fatalf("ListRemoveAt returned error: %v", err)
}
select {
case command := <-commandCh:
if len(command) != 7 {
t.Fatalf("unexpected EVAL command length: %v", command)
}
if command[2] != "1" || command[3] != "__gonavi_db_3__:tasks" {
t.Fatalf("expected one physical Redis key, got %v", command)
}
if command[4] != "3" || command[5] != "review" {
t.Fatalf("expected index and value arguments, got %v", command)
}
if !strings.HasPrefix(command[6], "\x00gonavi:list-remove:") {
t.Fatalf("expected collision-resistant marker prefix, got %q", command[6])
}
for _, redisCommand := range []string{"LINDEX", "LSET", "LREM"} {
if !strings.Contains(strings.ToUpper(command[1]), redisCommand) {
t.Fatalf("expected script to contain %s, got %q", redisCommand, command[1])
}
}
if !strings.Contains(command[1], `redis.pcall("LREM"`) ||
!strings.Contains(command[1], `redis.pcall("LSET"`) ||
!strings.Contains(command[1], "rollback failed") {
t.Fatalf("expected script to restore the selected item after a removal error, got %q", command[1])
}
case <-time.After(time.Second):
t.Fatal("expected EVAL command")
}
}
func TestListRemoveAtReturnsItemChangedWhenIndexNoLongerMatches(t *testing.T) {
addr := startRedisProtocolTestServer(t, func(args []string) string {
switch strings.ToUpper(strings.TrimSpace(args[0])) {
case "HELLO":
return "-ERR unknown command 'HELLO'\r\n"
case "CLIENT":
return "-ERR unknown subcommand\r\n"
case "EVAL":
return ":0\r\n"
}
return "+OK\r\n"
})
rawClient := goredis.NewClient(&goredis.Options{
Addr: addr,
Protocol: 2,
})
client := &RedisClientImpl{
client: rawClient,
singleClient: rawClient,
}
defer client.Close()
err := client.ListRemoveAt("tasks", 3, "review")
if !errors.Is(err, ErrRedisListItemChanged) {
t.Fatalf("expected ErrRedisListItemChanged, got %v", err)
}
}
func TestListRemoveAtRejectsUnexpectedScriptResult(t *testing.T) {
addr := startRedisProtocolTestServer(t, func(args []string) string {
switch strings.ToUpper(strings.TrimSpace(args[0])) {
case "HELLO":
return "-ERR unknown command 'HELLO'\r\n"
case "CLIENT":
return "-ERR unknown subcommand\r\n"
case "EVAL":
return ":2\r\n"
}
return "+OK\r\n"
})
rawClient := goredis.NewClient(&goredis.Options{
Addr: addr,
Protocol: 2,
})
client := &RedisClientImpl{
client: rawClient,
singleClient: rawClient,
}
defer client.Close()
err := client.ListRemoveAt("tasks", 3, "review")
if err == nil {
t.Fatal("expected unexpected script result to fail")
}
if errors.Is(err, ErrRedisListItemChanged) {
t.Fatalf("expected protocol error, got item-changed sentinel: %v", err)
}
t.Fatalf("expected LREM command, got %v", commands)
}
func TestRedisSearchScanContinuesPastEmptyMatchedPages(t *testing.T) {