package loaders import ( "context" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 M-3 修复 —— 对不存在/已删除用户的 load 结果必须写入短 TTL 负缓存哨兵, // 使后续同 userId/productCode 的 Load 在 TTL 内直接命中哨兵返回空 UserDetails, // 不再重复穿透到 DB,阻断离职用户残余 token 对 DB 的 DoS 放大。 // --------------------------------------------------------------------------- // TC-0821: M-3 —— Load 不存在的 userId 第二次必须命中负缓存,不再触发 DB FindOne。 func TestUserDetailsLoader_NegativeCache_HitsOnSecondCall(t *testing.T) { ctx := context.Background() loader := newTestLoader() // 随便选一个几乎肯定不存在的 id(避免与真实测试数据冲突)。 nonExistId := int64(900_000_000 + time.Now().UnixNano()%100_000) productCode := "pc_neg_" + uniqueId() // 确保无残留缓存。 loader.Del(ctx, nonExistId, productCode) // 第 1 次 Load:预期回写负缓存哨兵。 ud1 := loader.Load(ctx, nonExistId, productCode) require.NotNil(t, ud1) assert.Empty(t, ud1.Username, "不存在的用户 Load 后 Username 必须为空") // 直接读 Redis,验证哨兵值真的写进去了。 key := loader.cacheKey(nonExistId, productCode) val, err := loader.rds.GetCtx(ctx, key) require.NoError(t, err) assert.Equal(t, negativeCacheMarker, val, "M-3:不存在的用户必须写入负缓存哨兵 %q,以便后续命中直接返回空 UserDetails", negativeCacheMarker) // 第 2 次 Load:必须命中哨兵分支;哨兵应当返回空 UserDetails(Username 依然为空), // 且不得再做 DB 查询(这里没有 mock DB counter,但结果的契约仍然成立)。 ud2 := loader.Load(ctx, nonExistId, productCode) require.NotNil(t, ud2) assert.Empty(t, ud2.Username) assert.Equal(t, nonExistId, ud2.UserId) assert.Equal(t, productCode, ud2.ProductCode) // TTL 必须 > 0 且 <= negativeCacheTTL,说明负缓存是短 TTL,不会长期遮蔽刚刚被重建的用户。 ttl, err := loader.rds.TtlCtx(ctx, key) require.NoError(t, err) assert.Greater(t, ttl, 0, "负缓存必须是带 TTL 的短窗口") assert.LessOrEqual(t, ttl, negativeCacheTTL, "负缓存 TTL 不得超过 %ds,避免误伤刚 createUser 的合法用户", negativeCacheTTL) t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) }) } // TC-0822: M-3 —— 负缓存必须"不挂到 userIndex/productIndex 集合里", // 否则 CleanByProduct / Clean 在 DEL 其它真实 key 的同时会顺带 DEL 哨兵,带来短暂"放穿"。 // 该测试验证:写入负缓存之后,userIndex/productIndex 集合为空。 func TestUserDetailsLoader_NegativeCache_NotIndexed(t *testing.T) { ctx := context.Background() loader := newTestLoader() nonExistId := int64(900_000_123 + time.Now().UnixNano()%10_000) productCode := "pc_idx_" + uniqueId() loader.Del(ctx, nonExistId, productCode) loader.Load(ctx, nonExistId, productCode) uidx, err := loader.rds.SmembersCtx(ctx, loader.userIndexKey(nonExistId)) require.NoError(t, err) assert.Empty(t, uidx, "M-3:负缓存不得注册到 user index,否则 Clean(userId) 会把哨兵一起抹掉导致立刻再次击穿 DB") pidx, err := loader.rds.SmembersCtx(ctx, loader.productIndexKey(productCode)) require.NoError(t, err) assert.Empty(t, pidx, "负缓存同样不得进入 product index") t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) }) } // TC-0823: M-3 —— 多并发同一 nonExistId 只穿透 DB 一次(singleflight + 负缓存联动)。 // 使用 singleflight 组 + 负缓存的组合应保证:N 个并发 Load 对同一个不存在用户在第一次完成后, // 后续都走哨兵命中;即便 singleflight 窗口内共享同一 DB 查询,对 DB 的压力也至多 1 次。 // 这里我们无法直接计数 DB 调用(没有 DB mock 接入 loader),因此用对 key 的最终 GET 值来验证 // 最终状态是哨兵,并且 Load 耗时稳定(不会因每次都查 DB 出现显著抖动)。 func TestUserDetailsLoader_NegativeCache_ConcurrentLoadsStabilize(t *testing.T) { ctx := context.Background() loader := newTestLoader() nonExistId := int64(900_000_456 + time.Now().UnixNano()%10_000) productCode := "pc_conc_" + uniqueId() loader.Del(ctx, nonExistId, productCode) const N = 32 var done int32 ch := make(chan struct{}) for i := 0; i < N; i++ { go func() { defer func() { if atomic.AddInt32(&done, 1) == N { close(ch) } }() _ = loader.Load(ctx, nonExistId, productCode) }() } select { case <-ch: case <-time.After(5 * time.Second): t.Fatal("并发 Load 未在 5s 内收敛,singleflight/负缓存可能失效") } val, err := loader.rds.GetCtx(ctx, loader.cacheKey(nonExistId, productCode)) require.NoError(t, err) assert.Equal(t, negativeCacheMarker, val) t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) }) }