Files
MyGoNavi/internal/redis/redis_impl_test.go
Syngnat 32d51f3c25 🐛 fix(redis): 修复 HGETALL 在命令行下程序闪退
- 根因:go-redis v9 默认 RESP3 协议,HGETALL 返回 map[interface{}]interface{},encoding/json 不支持非 string key 的 map,原值穿透到 Wails RPC 导致 Windows 进程退出
- formatCommandResult 新增 map[interface{}]interface{} 分支,平展为交错 [k1, v1, k2, v2, ...] array,与 RESP2 输出形式一致,前端 array 渲染零改动
- 递归调用确保 XINFO STREAM 等嵌套 RESP3 map 也被平展
- 在函数 doc comment 固化"为什么这样做"防止后人删除
- 新增 3 个单元测试:扁平化 + JSON 可序列化、嵌套 map 递归处理、scalar 与 []byte 不被改变
2026-05-15 15:25:38 +08:00

320 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package redis
import (
"encoding/json"
"errors"
"sort"
"testing"
)
// 回归保护HGETALL 在 RESP3 下返回 map[interface{}]interface{}go-redis v9 默认 RESP3
// 这种类型 encoding/json 无法序列化,原值穿透到 Wails RPC 会让 Windows 进程退出(用户感知闪退)。
// formatCommandResult 必须把 map 平展成交错 [k1, v1, k2, v2, ...] array前端按 array 渲染。
func TestFormatCommandResultFlattensRESP3MapForJSONMarshal(t *testing.T) {
input := map[interface{}]interface{}{
"name": "alice",
"age": "30",
}
got := formatCommandResult(input)
arr, ok := got.([]interface{})
if !ok {
t.Fatalf("expected flattened []interface{}, got %T (%#v)", got, got)
}
if len(arr) != 4 {
t.Fatalf("expected 4 elements (2 pairs flattened), got %d: %#v", len(arr), arr)
}
// 平展后必须能被 encoding/json 序列化——这是修复的根本目的
if _, err := json.Marshal(arr); err != nil {
t.Fatalf("flattened result must be JSON-marshalable, got error: %v", err)
}
// 验证 key+value 都保留下来了(顺序由 map 遍历决定,不强断言顺序)
collected := make([]string, 0, 4)
for _, item := range arr {
s, ok := item.(string)
if !ok {
t.Fatalf("expected string element, got %T", item)
}
collected = append(collected, s)
}
sort.Strings(collected)
want := []string{"30", "age", "alice", "name"}
for i, w := range want {
if collected[i] != w {
t.Fatalf("flattened content mismatch at %d: got %q want %q (all=%v)", i, collected[i], w, collected)
}
}
}
// 嵌套 array 内含 map[interface{}]interface{} 也要递归平展,
// 保证 XINFO STREAM 等返回嵌套结构的命令不会卡在序列化阶段。
func TestFormatCommandResultRecursivelyFlattensNestedMap(t *testing.T) {
input := []interface{}{
"stream-id",
map[interface{}]interface{}{"k": "v"},
}
got := formatCommandResult(input)
arr, ok := got.([]interface{})
if !ok || len(arr) != 2 {
t.Fatalf("expected []interface{} of length 2, got %#v", got)
}
nested, ok := arr[1].([]interface{})
if !ok {
t.Fatalf("expected nested map to be flattened to []interface{}, got %T", arr[1])
}
if _, err := json.Marshal(arr); err != nil {
t.Fatalf("recursively flattened result must be JSON-marshalable, got error: %v", err)
}
_ = nested
}
// 已经是 string-key 简单类型的命令HGET、SET 之类)不应被改变。
func TestFormatCommandResultPreservesScalarAndByteSlice(t *testing.T) {
if got := formatCommandResult("ok"); got != "ok" {
t.Fatalf("string scalar should pass through, got %v", got)
}
if got := formatCommandResult([]byte("ok")); got != "ok" {
t.Fatalf("[]byte should be converted to string, got %v", got)
}
if got := formatCommandResult(int64(42)); got != int64(42) {
t.Fatalf("int64 scalar should pass through, got %v", got)
}
}
func TestSanitizeRedisPassword(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty password",
input: "",
expected: "",
},
{
name: "plain password without special chars",
input: "mypassword123",
expected: "mypassword123",
},
{
name: "password with @ not encoded",
input: "p@ssword",
expected: "p@ssword",
},
{
name: "password with @ URL-encoded as %40",
input: "p%40ssword",
expected: "p@ssword",
},
{
name: "password with multiple encoded chars",
input: "p%40ss%23word",
expected: "p@ss#word",
},
{
name: "password with + encoded as %2B",
input: "p%2Bss",
expected: "p+ss",
},
{
name: "password that is purely encoded",
input: "%40%23%24",
expected: "@#$",
},
{
name: "password with invalid percent encoding",
input: "p%ZZssword",
expected: "p%ZZssword",
},
{
name: "password with trailing percent",
input: "password%",
expected: "password%",
},
{
name: "password with literal percent not encoding anything",
input: "100%safe",
expected: "100%safe",
},
{
name: "password with space encoded as %20",
input: "my%20pass",
expected: "my pass",
},
{
name: "complex password with mixed content",
input: "P%40ss%23w0rd!",
expected: "P@ss#w0rd!",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizeRedisPassword(tt.input)
if result != tt.expected {
t.Errorf("sanitizeRedisPassword(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestIsRedisKeyGone(t *testing.T) {
tests := []struct {
name string
keyType string
ttl int64
want bool
}{
{name: "type none", keyType: "none", ttl: -2, want: true},
{name: "type none without ttl", keyType: "none", ttl: -1, want: true},
{name: "missing by ttl", keyType: "string", ttl: -2, want: true},
{name: "normal string", keyType: "string", ttl: 30, want: false},
{name: "permanent hash", keyType: "hash", ttl: -1, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRedisKeyGone(tt.keyType, tt.ttl); got != tt.want {
t.Fatalf("isRedisKeyGone(%q, %d)=%v, want %v", tt.keyType, tt.ttl, got, tt.want)
}
})
}
}
func TestNormalizeRedisGetValueError(t *testing.T) {
err := normalizeRedisGetValueError("none", -2)
if !errors.Is(err, ErrRedisKeyGone) {
t.Fatalf("expected ErrRedisKeyGone, got %v", err)
}
if err == nil || err.Error() != "Redis Key 不存在或已过期" {
t.Fatalf("unexpected error text: %v", err)
}
if normalizeRedisGetValueError("hash", -1) != nil {
t.Fatal("expected nil for supported existing key")
}
}
func TestRedisGlobPatternLiteralKey(t *testing.T) {
tests := []struct {
name string
pattern string
wantKey string
wantExact bool
}{
{name: "plain exact key", pattern: "Agent", wantKey: "Agent", wantExact: true},
{name: "escaped glob characters stay literal", pattern: `user:\*:\[id\]\?\\raw`, wantKey: `user:*:[id]?\raw`, wantExact: true},
{name: "fuzzy wildcard is not exact", pattern: "*[aA][gG][eE][nN][tT]*", wantExact: false},
{name: "unescaped suffix wildcard is not exact", pattern: "Agent*", wantExact: false},
{name: "unescaped single character wildcard is not exact", pattern: "Agent?", wantExact: false},
{name: "unescaped character class is not exact", pattern: "Agent[0-9]", wantExact: false},
{name: "empty pattern is not exact", pattern: "", wantExact: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotKey, gotExact := redisGlobPatternLiteralKey(tt.pattern)
if gotExact != tt.wantExact {
t.Fatalf("redisGlobPatternLiteralKey(%q) exact=%v, want %v", tt.pattern, gotExact, tt.wantExact)
}
if gotKey != tt.wantKey {
t.Fatalf("redisGlobPatternLiteralKey(%q) key=%q, want %q", tt.pattern, gotKey, tt.wantKey)
}
})
}
}
func TestRedisExactSearchPattern(t *testing.T) {
tests := []struct {
name string
literalKey string
wantExactKey string
wantNamespace string
}{
{
name: "plain namespace folder",
literalKey: "Agent",
wantExactKey: "Agent",
wantNamespace: "Agent:*",
},
{
name: "escaped namespace keeps glob chars literal",
literalKey: `user:*:[id]?\raw`,
wantExactKey: `user:*:[id]?\raw`,
wantNamespace: `user:\*:\[id\]\?\\raw:*`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExactKey, gotNamespace := redisExactSearchPattern(tt.literalKey)
if gotExactKey != tt.wantExactKey {
t.Fatalf("redisExactSearchPattern(%q) exactKey=%q, want %q", tt.literalKey, gotExactKey, tt.wantExactKey)
}
if gotNamespace != tt.wantNamespace {
t.Fatalf("redisExactSearchPattern(%q) namespace=%q, want %q", tt.literalKey, gotNamespace, tt.wantNamespace)
}
})
}
}
func TestReadRedisHashEntriesWithFallbackUsesHScanWhenHGetAllForbidden(t *testing.T) {
scanCalls := 0
values, length, err := readRedisHashEntriesWithFallback(
func() (map[string]string, error) {
return nil, errors.New("ERR command 'HGETALL' not support for normal user")
},
func() (int64, error) {
return 2, nil
},
func(cursor uint64, count int64) ([]string, uint64, error) {
scanCalls++
if cursor != 0 {
t.Fatalf("expected first scan cursor to be 0, got %d", cursor)
}
if count <= 0 {
t.Fatalf("expected positive scan count, got %d", count)
}
return []string{"field-a", "value-a", "field-b", "value-b"}, 0, nil
},
)
if err != nil {
t.Fatalf("readRedisHashEntriesWithFallback() unexpected error: %v", err)
}
if scanCalls != 1 {
t.Fatalf("expected exactly one HSCAN fallback, got %d", scanCalls)
}
if length != 2 {
t.Fatalf("expected hash length 2, got %d", length)
}
if got := values["field-a"]; got != "value-a" {
t.Fatalf("expected field-a=value-a, got %q", got)
}
if got := values["field-b"]; got != "value-b" {
t.Fatalf("expected field-b=value-b, got %q", got)
}
}
func TestReadRedisHashEntriesWithFallbackReturnsOriginalErrorForNonPermissionFailure(t *testing.T) {
expectedErr := errors.New("ERR wrong type")
_, _, err := readRedisHashEntriesWithFallback(
func() (map[string]string, error) {
return nil, expectedErr
},
func() (int64, error) {
t.Fatal("expected HLEN not to run for non-permission failure")
return 0, nil
},
func(cursor uint64, count int64) ([]string, uint64, error) {
t.Fatal("expected HSCAN not to run for non-permission failure")
return nil, 0, nil
},
)
if !errors.Is(err, expectedErr) {
t.Fatalf("expected original error %v, got %v", expectedErr, err)
}
}