Files
xlgo-core/cache/cache_m10_internal_test.go
杭州明婳科技 9e11f8f007 refactor: 同义可失败 API 收敛(cache/jwt/storage/ratelimit breaking change)
按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,
收敛历史双轨 API。自定义 CacheService/Storage 实现需同步更新签名(编译失败即迁移信号)。

cache:
- CacheService.Get/Exists 改为 (bool, error):命中 (true,nil)、未命中 (false,nil)、
  Redis 未就绪/命令错误/反序列化失败返回 (false,err)
- 删除 cache.GetE/cache.ExistsE/CacheGetter/CacheExistChecker/redisCache.GetE/redisCache.ExistsE
  (不保留 deprecated wrapper)
- 新增包级 cache.Get/cache.Exists(带 error);GetWithPrefix 改为 (bool,error)

jwt:
- TokenBlacklist.IsBlacklisted 改为 (bool, error),删除 IsBlacklistedE
- IsTokenRevoked 改为 (bool, error)
- ParseToken 默认从 fail-open 改为 fail-closed:黑名单后端不可检查时返回
  ErrBlacklistUnavailable 拒绝该 Token(无 Redis 部署不再支持可靠撤销)
- 删除 ParseTokenFailClosed(主 API 已 fail-closed)
- 保留 ParseTokenWithBlacklistPolicy + BlacklistPolicy/BlacklistFailOpen/BlacklistFailClosed
  供显式 fail-open(仅无 Redis 或低安全场景)

storage:
- Storage.Exists 改为 (bool, error):存在 (true,nil)、不存在 (false,nil)、
  未初始化/路径非法/穿越/后端错误返回 (false,err)
- LocalStorage.Exists 区分 os.IsNotExist 与其他 Stat 错误
- OSSStorage.Exists 区分 OSS 404/NoSuchKey 与鉴权/网络错误
- 包级 storage.Exists 同步签名,未初始化返回 ErrStorageNotInitialized
- 新增 ErrReadTooLarge:Local/OSSStorage.Get 读取超 maxReadBytes 上限原误用
  ErrInvalidPath(语义不贴切),改为 ErrReadTooLarge

ratelimit:
- NewRedisRateLimiter 加 opts ...RedisRateLimiterOption,新增 WithFailClosed(bool)
  替代原 NewRedisRateLimiterFailClosed
- RedisRateLimit/CustomRedisRateLimit/RedisRateLimitWithIdentifier 同步加 opts 参数
- 删除 NewRedisRateLimiterFailClosed/RedisRateLimitFailClosed/CustomRedisRateLimitFailClosed
- UploadRedisRateLimit 由 fail-open 改为 fail-closed(资源敏感操作,Redis 故障时拒绝)

测试与文档:
- cache_m10_internal_test.go 重写 4 个 M10 测试为 Get/Exists 契约测试,新增 mock 编译覆盖
- jwt_test.go 改 4 处;jwt_c9c_internal_test.go 改 IsTokenRevoked 2-value
- examples/full/main_test.go 加 miniredis 注入(ParseToken fail-closed 后认证需 Redis)
- middleware_test.go 改 5 处 FailClosed 调用为 WithFailClosed(true);auth_test.go 加 Redis
- storage 测试改 6 处 Exists/Get 断言为新签名/错误类型
- README/GUIDE 同步示例为新签名;CHANGELOG 新增 Breaking 升级说明与迁移示例

验证:go build / go vet / go test -race -count=1 ./... 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:22:10 +08:00

200 lines
6.5 KiB
Go

package cache
import (
"context"
"errors"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func setupM10MiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
database.SetTestRedisClient(client)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
return mr
}
func TestM10CacheWritesReturnRedisNotReady(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
c := &redisCache{}
ctx := context.Background()
if err := c.Set(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Set without Redis err = %v, want ErrRedisNotReady", err)
}
if err := c.Delete(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Delete without Redis err = %v, want ErrRedisNotReady", err)
}
if err := c.DeleteByPattern(ctx, "k:*"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("DeleteByPattern without Redis err = %v, want ErrRedisNotReady", err)
}
}
func TestM10RawHelpersReturnRedisNotReady(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
ctx := context.Background()
if _, err := Incr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Incr without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := IncrBy(ctx, "k", 2); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("IncrBy without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := Decr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Decr without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := GetTTL(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("GetTTL without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := SetExpire(ctx, "k", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("SetExpire without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := GetRaw(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("GetRaw without Redis err = %v, want ErrRedisNotReady", err)
}
if err := SetRaw(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("SetRaw without Redis err = %v, want ErrRedisNotReady", err)
}
}
func TestM10ExistsReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
c := &redisCache{}
ctx := context.Background()
exists, err := c.Exists(ctx, "missing")
if err != nil {
t.Fatalf("Exists missing err = %v", err)
}
if exists {
t.Fatal("Exists missing = true, want false")
}
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set present: %v", err)
}
exists, err = c.Exists(ctx, "present")
if err != nil || !exists {
t.Fatalf("Exists present = %v, err=%v; want true,nil", exists, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if exists, err = c.Exists(canceled, "present"); err == nil || exists {
t.Fatalf("Exists canceled = %v, err=%v; want false,error", exists, err)
}
}
func TestM10GetReturnsBackendAndDecodeErrors(t *testing.T) {
setupM10MiniRedis(t)
c := &redisCache{}
ctx := context.Background()
var got string
ok, err := c.Get(ctx, "missing", &got)
if err != nil || ok {
t.Fatalf("Get missing = %v, err=%v; want false,nil", ok, err)
}
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set present: %v", err)
}
ok, err = c.Get(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("Get present = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
if err := c.client().Set(ctx, "bad-json", "{", time.Minute).Err(); err != nil {
t.Fatalf("Set bad-json: %v", err)
}
ok, err = c.Get(ctx, "bad-json", &got)
if err == nil || ok {
t.Fatalf("Get bad-json = %v, err=%v; want false,error", ok, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
ok, err = c.Get(canceled, "present", &got)
if err == nil || ok {
t.Fatalf("Get canceled = %v, err=%v; want false,error", ok, err)
}
}
func TestM10PackageExistsReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
t.Cleanup(func() { SetDefaultCacheManager(orig) })
SetDefaultCacheManager(NewCacheManager())
Init()
ctx := context.Background()
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set through facade: %v", err)
}
exists, err := Exists(ctx, "present")
if err != nil || !exists {
t.Fatalf("facade Exists = %v, err=%v; want true,nil", exists, err)
}
}
func TestM10PackageGetReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
t.Cleanup(func() { SetDefaultCacheManager(orig) })
SetDefaultCacheManager(NewCacheManager())
Init()
ctx := context.Background()
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set through facade: %v", err)
}
var got string
ok, err := Get(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("facade Get = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
}
// TestM10CustomCacheServiceCompile asserts a user-provided CacheService
// implementation compiles against the (bool, error) contract. This is a
// compile-time guard: if the interface changes again, this stops building.
type customCacheSvc struct{}
func (customCacheSvc) Get(ctx context.Context, key string, dest any) (bool, error) { return false, nil }
func (customCacheSvc) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
return nil
}
func (customCacheSvc) Delete(ctx context.Context, key string) error { return nil }
func (customCacheSvc) DeleteByPattern(ctx context.Context, p string) error { return nil }
func (customCacheSvc) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
func TestM10CustomCacheServiceCompiles(t *testing.T) {
var _ CacheService = customCacheSvc{}
}
// TestM10RedisNotReadyOnGetExists asserts the Redis-not-ready error is
// surfaced (not swallowed) when no backend is configured.
func TestM10RedisNotReadyOnGetExists(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
c := &redisCache{}
ctx := context.Background()
if _, err := c.Get(ctx, "k", new(string)); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Get without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := c.Exists(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Exists without Redis err = %v, want ErrRedisNotReady", err)
}
}