Browse Source

fix(dashboard): address all bugbot comments from PR #808

- Replace O(N) redis.keys() with cursor-based scanPattern() in
  invalidateStatisticsCache (issue 1)
- Fix lock not released when queryDatabase throws: move del(lockKey) to
  finally block in both statistics-cache and overview-cache (issues 2+4)
- Wrap setex in inner try/catch so Redis write failure doesn't trigger
  double DB query via outer catch (issues 3+4)
- Guard queryDatabase against undefined userId for keys/mixed modes
  (issue 5)
- Remove duplicate buildCacheKey; use buildStatisticsCacheKey from
  dashboard-cache.ts throughout (issue 6)
- Add TypeScript overloads to buildOverviewCacheKey preventing
  overview:user:undefined keys at compile time (issue 7)
- Replace hardcoded Chinese sentinel "其他用户" with "__others__" and
  map it to i18n key othersAggregate in 5 locales (issue 8)
- Extract duplicated Redis in-memory mock into shared
  tests/unit/actions/redis-mock-utils.ts (issue 9)
ding113 6 days ago
parent
commit
df4337ed9d

+ 2 - 1
messages/en/dashboard.json

@@ -706,7 +706,8 @@
     "states": {
       "noData": "No statistics data available",
       "fetchFailed": "Failed to fetch statistics"
-    }
+    },
+    "othersAggregate": "Other Users"
   },
   "errors": {
     "fetchSystemSettingsFailed": "Failed to fetch system settings",

+ 2 - 1
messages/ja/dashboard.json

@@ -704,7 +704,8 @@
     "states": {
       "noData": "統計データなし",
       "fetchFailed": "統計データの取得に失敗しました"
-    }
+    },
+    "othersAggregate": "その他のユーザー"
   },
   "errors": {
     "fetchSystemSettingsFailed": "システム設定の取得に失敗しました",

+ 2 - 1
messages/ru/dashboard.json

@@ -707,7 +707,8 @@
     "states": {
       "noData": "Нет статистических данных",
       "fetchFailed": "Не удалось получить статистические данные"
-    }
+    },
+    "othersAggregate": "Другие пользователи"
   },
   "errors": {
     "fetchSystemSettingsFailed": "Не удалось получить параметры системы",

+ 2 - 1
messages/zh-CN/dashboard.json

@@ -706,7 +706,8 @@
     "states": {
       "noData": "暂无统计数据",
       "fetchFailed": "获取统计数据失败"
-    }
+    },
+    "othersAggregate": "其他用户"
   },
   "errors": {
     "fetchSystemSettingsFailed": "获取系统设置失败",

+ 2 - 1
messages/zh-TW/dashboard.json

@@ -704,7 +704,8 @@
     "states": {
       "noData": "暫無統計資料",
       "fetchFailed": "取得統計資料失敗"
-    }
+    },
+    "othersAggregate": "其他使用者"
   },
   "errors": {
     "fetchSystemSettingsFailed": "取得系統設定失敗",

+ 1 - 1
src/actions/statistics.ts

@@ -84,7 +84,7 @@ export async function getUserStatistics(
       statsData = [...mixedData.ownKeys, ...mixedData.othersAggregate];
 
       // 合并实体列表:自己的密钥 + 其他用户虚拟实体
-      entities = [...ownKeysList, { id: -1, name: "其他用户" }];
+      entities = [...ownKeysList, { id: -1, name: "__others__" }];
     } else {
       // 非 Admin + !allowGlobalUsageView: 仅显示自己的密钥
       const [cachedData, keyList] = await Promise.all([

+ 9 - 3
src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx

@@ -79,7 +79,7 @@ export function StatisticsChartCard({
     };
     data.users.forEach((user, index) => {
       config[user.dataKey] = {
-        label: user.name,
+        label: user.name === "__others__" ? t("othersAggregate") : user.name,
         color: getUserColor(index),
       };
     });
@@ -337,7 +337,11 @@ export function StatisticsChartCard({
                                   className="h-2 w-2 rounded-full flex-shrink-0"
                                   style={{ backgroundColor: entry.color }}
                                 />
-                                <span className="truncate">{displayUser?.name || baseKey}</span>
+                                <span className="truncate">
+                                  {displayUser?.name === "__others__"
+                                    ? t("othersAggregate")
+                                    : displayUser?.name || baseKey}
+                                </span>
                               </div>
                               <span className="font-mono font-medium">
                                 {activeChart === "cost"
@@ -444,7 +448,9 @@ export function StatisticsChartCard({
                         className="h-2 w-2 rounded-full flex-shrink-0"
                         style={{ backgroundColor: color }}
                       />
-                      <span className="font-medium truncate max-w-[80px]">{user.name}</span>
+                      <span className="font-medium truncate max-w-[80px]">
+                        {user.name === "__others__" ? t("othersAggregate") : user.name}
+                      </span>
                       <span className="text-muted-foreground">
                         {activeChart === "cost"
                           ? formatCurrency(userTotal?.cost ?? 0, currencyCode)

+ 5 - 3
src/app/[locale]/dashboard/_components/statistics/chart.tsx

@@ -117,7 +117,7 @@ export function UserStatisticsChart({
 
     data.users.forEach((user, index) => {
       config[user.dataKey] = {
-        label: user.name,
+        label: user.name === "__others__" ? t("othersAggregate") : user.name,
         color: getUserColor(index),
       };
     });
@@ -466,7 +466,9 @@ export function UserStatisticsChart({
                                     style={{ backgroundColor: color }}
                                   />
                                   <span className="font-medium truncate">
-                                    {displayUser?.name || baseKey}:
+                                    {displayUser?.name === "__others__"
+                                      ? t("othersAggregate")
+                                      : displayUser?.name || baseKey}:
                                   </span>
                                 </div>
                                 <span className="ml-auto font-mono flex-shrink-0">
@@ -566,7 +568,7 @@ export function UserStatisticsChart({
                               aria-hidden="true"
                             />
                             <span className="text-xs font-medium text-foreground truncate max-w-12">
-                              {user.name}
+                              {user.name === "__others__" ? t("othersAggregate") : user.name}
                             </span>
                           </div>
 

+ 19 - 8
src/lib/redis/overview-cache.ts

@@ -29,6 +29,9 @@ export async function getOverviewWithCache(
     return await getOverviewMetricsWithComparison(userId);
   }
 
+  let lockAcquired = false;
+  let data: OverviewMetricsWithComparison | undefined;
+
   try {
     // 1. Try cache hit
     const cached = await redis.get(cacheKey);
@@ -37,7 +40,8 @@ export async function getOverviewWithCache(
     }
 
     // 2. Acquire lock (prevent thundering herd)
-    const lockAcquired = await redis.set(lockKey, "1", "EX", LOCK_TTL, "NX");
+    const lockResult = await redis.set(lockKey, "1", "EX", LOCK_TTL, "NX");
+    lockAcquired = lockResult === "OK";
 
     if (!lockAcquired) {
       // Another instance is computing -- wait briefly and retry cache
@@ -49,18 +53,25 @@ export async function getOverviewWithCache(
     }
 
     // 3. Cache miss -- query DB
-    const data = await getOverviewMetricsWithComparison(userId);
-
-    // 4. Store in cache with TTL
-    await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data));
+    data = await getOverviewMetricsWithComparison(userId);
 
-    // 5. Release lock
-    await redis.del(lockKey);
+    // 4. Store in cache with TTL (best-effort)
+    try {
+      await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data));
+    } catch (writeErr) {
+      logger.warn("[OverviewCache] Failed to write cache", { cacheKey, error: writeErr });
+    }
 
     return data;
   } catch (error) {
     logger.warn("[OverviewCache] Redis error, fallback to direct query", { userId, error });
-    return await getOverviewMetricsWithComparison(userId);
+    return data ?? (await getOverviewMetricsWithComparison(userId));
+  } finally {
+    if (lockAcquired) {
+      await redis.del(lockKey).catch((err) =>
+        logger.warn("[OverviewCache] Failed to release lock", { lockKey, error: err })
+      );
+    }
   }
 }
 

+ 27 - 18
src/lib/redis/statistics-cache.ts

@@ -4,8 +4,10 @@ import {
   getMixedStatisticsFromDB,
   getUserStatisticsFromDB,
 } from "@/repository/statistics";
+import { buildStatisticsCacheKey } from "@/types/dashboard-cache";
 import type { DatabaseKeyStatRow, DatabaseStatRow, TimeRange } from "@/types/statistics";
 import { getRedisClient } from "./client";
+import { scanPattern } from "./scan-helper";
 
 const CACHE_TTL = 30;
 const LOCK_TTL = 5;
@@ -17,15 +19,6 @@ type MixedStatisticsResult = {
 
 type StatisticsCacheData = DatabaseStatRow[] | DatabaseKeyStatRow[] | MixedStatisticsResult;
 
-function buildCacheKey(
-  timeRange: TimeRange,
-  mode: "users" | "keys" | "mixed",
-  userId?: number
-): string {
-  const scope = userId !== undefined ? `${userId}` : "global";
-  return `statistics:${timeRange}:${mode}:${scope}`;
-}
-
 function sleep(ms: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }
@@ -35,6 +28,9 @@ async function queryDatabase(
   mode: "users" | "keys" | "mixed",
   userId?: number
 ): Promise<StatisticsCacheData> {
+  if ((mode === "keys" || mode === "mixed") && userId === undefined) {
+    throw new Error(`queryDatabase: userId required for mode="${mode}"`);
+  }
   switch (mode) {
     case "users":
       return await getUserStatisticsFromDB(timeRange);
@@ -70,9 +66,12 @@ export async function getStatisticsWithCache(
     return await queryDatabase(timeRange, mode, userId);
   }
 
-  const cacheKey = buildCacheKey(timeRange, mode, userId);
+  const cacheKey = buildStatisticsCacheKey(timeRange, mode, userId);
   const lockKey = `${cacheKey}:lock`;
 
+  let locked = false;
+  let data: StatisticsCacheData | undefined;
+
   try {
     // 1. Try cache
     const cached = await redis.get(cacheKey);
@@ -82,15 +81,19 @@ export async function getStatisticsWithCache(
     }
 
     // 2. Cache miss - acquire lock (SET NX EX)
-    const locked = await redis.set(lockKey, "1", "EX", LOCK_TTL, "NX");
+    const lockResult = await redis.set(lockKey, "1", "EX", LOCK_TTL, "NX");
+    locked = lockResult === "OK";
 
-    if (locked === "OK") {
+    if (locked) {
       logger.debug("[StatisticsCache] Acquired lock, computing", { timeRange, mode, lockKey });
 
-      const data = await queryDatabase(timeRange, mode, userId);
+      data = await queryDatabase(timeRange, mode, userId);
 
-      await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data));
-      await redis.del(lockKey);
+      try {
+        await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data));
+      } catch (writeErr) {
+        logger.warn("[StatisticsCache] Failed to write cache", { cacheKey, error: writeErr });
+      }
 
       logger.info("[StatisticsCache] Cache updated", {
         timeRange,
@@ -129,7 +132,13 @@ export async function getStatisticsWithCache(
       mode,
       error,
     });
-    return await queryDatabase(timeRange, mode, userId);
+    return data ?? (await queryDatabase(timeRange, mode, userId));
+  } finally {
+    if (locked) {
+      await redis.del(lockKey).catch((err) =>
+        logger.warn("[StatisticsCache] Failed to release lock", { lockKey, error: err })
+      );
+    }
   }
 }
 
@@ -153,12 +162,12 @@ export async function invalidateStatisticsCache(
   try {
     if (timeRange) {
       const modes = ["users", "keys", "mixed"] as const;
-      const keysToDelete = modes.map((m) => `statistics:${timeRange}:${m}:${scope}`);
+      const keysToDelete = modes.map((m) => buildStatisticsCacheKey(timeRange, m, userId));
       await redis.del(...keysToDelete);
       logger.info("[StatisticsCache] Cache invalidated", { timeRange, scope, keysToDelete });
     } else {
       const pattern = `statistics:*:*:${scope}`;
-      const matchedKeys = await redis.keys(pattern);
+      const matchedKeys = await scanPattern(redis, pattern);
       if (matchedKeys.length > 0) {
         await redis.del(...matchedKeys);
       }

+ 1 - 1
src/repository/statistics.ts

@@ -252,7 +252,7 @@ function zeroFillMixedOthersStats(
     const row = rowMap.get(bucket.getTime());
     return {
       user_id: -1,
-      user_name: "其他用户",
+      user_name: "__others__",
       date: new Date(bucket.getTime()),
       api_calls: row?.api_calls ?? 0,
       total_cost: row?.total_cost ?? 0,

+ 2 - 0
src/types/dashboard-cache.ts

@@ -11,6 +11,8 @@ export type StatisticsCacheKey = {
   userId?: number;
 };
 
+export function buildOverviewCacheKey(scope: "global"): string;
+export function buildOverviewCacheKey(scope: "user", userId: number): string;
 export function buildOverviewCacheKey(scope: "global" | "user", userId?: number): string {
   return scope === "global" ? "overview:global" : `overview:user:${userId}`;
 }

+ 3 - 52
tests/unit/actions/provider-undo-delete.test.ts

@@ -1,5 +1,6 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "../../../src/lib/provider-batch-patch-error-codes";
+import { buildRedisMock, createRedisStore } from "./redis-mock-utils";
 
 const getSessionMock = vi.fn();
 const deleteProvidersBatchMock = vi.fn();
@@ -7,45 +8,7 @@ const restoreProvidersBatchMock = vi.fn();
 const publishCacheInvalidationMock = vi.fn();
 const clearProviderStateMock = vi.fn();
 const clearConfigCacheMock = vi.fn();
-const redisStore = new Map<string, { value: string; expiresAt: number }>();
-
-function readRedisValue(key: string): string | null {
-  const entry = redisStore.get(key);
-  if (!entry) {
-    return null;
-  }
-
-  if (entry.expiresAt <= Date.now()) {
-    redisStore.delete(key);
-    return null;
-  }
-
-  return entry.value;
-}
-
-const redisSetexMock = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
-  redisStore.set(key, {
-    value,
-    expiresAt: Date.now() + ttlSeconds * 1000,
-  });
-  return "OK";
-});
-
-const redisGetMock = vi.fn(async (key: string) => readRedisValue(key));
-
-const redisDelMock = vi.fn(async (key: string) => {
-  const existed = redisStore.delete(key);
-  return existed ? 1 : 0;
-});
-
-const redisEvalMock = vi.fn(async (_script: string, _numKeys: number, key: string) => {
-  const value = readRedisValue(key);
-  if (value === null) {
-    return null;
-  }
-  redisStore.delete(key);
-  return value;
-});
+const { store: redisStore, mocks: redisMocks } = createRedisStore();
 
 vi.mock("@/lib/auth", () => ({
   getSession: getSessionMock,
@@ -72,15 +35,7 @@ vi.mock("@/lib/circuit-breaker", () => ({
   getAllHealthStatusAsync: vi.fn(),
 }));
 
-vi.mock("@/lib/redis/client", () => ({
-  getRedisClient: () => ({
-    status: "ready",
-    setex: redisSetexMock,
-    get: redisGetMock,
-    del: redisDelMock,
-    eval: redisEvalMock,
-  }),
-}));
+vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
 
 vi.mock("@/lib/logger", () => ({
   logger: {
@@ -97,10 +52,6 @@ describe("Provider Delete Undo Actions", () => {
     vi.clearAllMocks();
     vi.resetModules();
     redisStore.clear();
-    redisSetexMock.mockClear();
-    redisGetMock.mockClear();
-    redisDelMock.mockClear();
-    redisEvalMock.mockClear();
     getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
     deleteProvidersBatchMock.mockResolvedValue(2);
     restoreProvidersBatchMock.mockResolvedValue(2);

+ 3 - 52
tests/unit/actions/provider-undo-edit.test.ts

@@ -1,5 +1,6 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "../../../src/lib/provider-batch-patch-error-codes";
+import { buildRedisMock, createRedisStore } from "./redis-mock-utils";
 
 const getSessionMock = vi.fn();
 const findProviderByIdMock = vi.fn();
@@ -10,45 +11,7 @@ const clearProviderStateMock = vi.fn();
 const clearConfigCacheMock = vi.fn();
 const saveProviderCircuitConfigMock = vi.fn();
 const deleteProviderCircuitConfigMock = vi.fn();
-const redisStore = new Map<string, { value: string; expiresAt: number }>();
-
-function readRedisValue(key: string): string | null {
-  const entry = redisStore.get(key);
-  if (!entry) {
-    return null;
-  }
-
-  if (entry.expiresAt <= Date.now()) {
-    redisStore.delete(key);
-    return null;
-  }
-
-  return entry.value;
-}
-
-const redisSetexMock = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
-  redisStore.set(key, {
-    value,
-    expiresAt: Date.now() + ttlSeconds * 1000,
-  });
-  return "OK";
-});
-
-const redisGetMock = vi.fn(async (key: string) => readRedisValue(key));
-
-const redisDelMock = vi.fn(async (key: string) => {
-  const existed = redisStore.delete(key);
-  return existed ? 1 : 0;
-});
-
-const redisEvalMock = vi.fn(async (_script: string, _numKeys: number, key: string) => {
-  const value = readRedisValue(key);
-  if (value === null) {
-    return null;
-  }
-  redisStore.delete(key);
-  return value;
-});
+const { store: redisStore, mocks: redisMocks } = createRedisStore();
 
 vi.mock("@/lib/auth", () => ({
   getSession: getSessionMock,
@@ -82,15 +45,7 @@ vi.mock("@/lib/redis/circuit-breaker-config", () => ({
   deleteProviderCircuitConfig: deleteProviderCircuitConfigMock,
 }));
 
-vi.mock("@/lib/redis/client", () => ({
-  getRedisClient: () => ({
-    status: "ready",
-    setex: redisSetexMock,
-    get: redisGetMock,
-    del: redisDelMock,
-    eval: redisEvalMock,
-  }),
-}));
+vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
 
 vi.mock("@/lib/logger", () => ({
   logger: {
@@ -168,10 +123,6 @@ describe("Provider Single Edit Undo Actions", () => {
     vi.clearAllMocks();
     vi.resetModules();
     redisStore.clear();
-    redisSetexMock.mockClear();
-    redisGetMock.mockClear();
-    redisDelMock.mockClear();
-    redisEvalMock.mockClear();
     getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
     findProviderByIdMock.mockResolvedValue(makeProvider(1, { name: "Before Name", key: "sk-old" }));
     updateProviderMock.mockResolvedValue(makeProvider(1, { name: "After Name", key: "sk-new" }));

+ 3 - 52
tests/unit/actions/providers-patch-actions-contract.test.ts

@@ -1,48 +1,11 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
+import { buildRedisMock, createRedisStore } from "./redis-mock-utils";
 
 const getSessionMock = vi.fn();
 const findAllProvidersFreshMock = vi.fn();
 const updateProvidersBatchMock = vi.fn();
-const redisStore = new Map<string, { value: string; expiresAt: number }>();
-
-function readRedisValue(key: string): string | null {
-  const entry = redisStore.get(key);
-  if (!entry) {
-    return null;
-  }
-
-  if (entry.expiresAt <= Date.now()) {
-    redisStore.delete(key);
-    return null;
-  }
-
-  return entry.value;
-}
-
-const redisSetexMock = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
-  redisStore.set(key, {
-    value,
-    expiresAt: Date.now() + ttlSeconds * 1000,
-  });
-  return "OK";
-});
-
-const redisGetMock = vi.fn(async (key: string) => readRedisValue(key));
-
-const redisDelMock = vi.fn(async (key: string) => {
-  const existed = redisStore.delete(key);
-  return existed ? 1 : 0;
-});
-
-const redisEvalMock = vi.fn(async (_script: string, _numKeys: number, key: string) => {
-  const value = readRedisValue(key);
-  if (value === null) {
-    return null;
-  }
-  redisStore.delete(key);
-  return value;
-});
+const { store: redisStore, mocks: redisMocks } = createRedisStore();
 
 vi.mock("@/lib/auth", () => ({
   getSession: getSessionMock,
@@ -58,15 +21,7 @@ vi.mock("@/lib/cache/provider-cache", () => ({
   publishProviderCacheInvalidation: vi.fn(),
 }));
 
-vi.mock("@/lib/redis/client", () => ({
-  getRedisClient: () => ({
-    status: "ready",
-    setex: redisSetexMock,
-    get: redisGetMock,
-    del: redisDelMock,
-    eval: redisEvalMock,
-  }),
-}));
+vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
 
 vi.mock("@/lib/circuit-breaker", () => ({
   clearProviderState: vi.fn(),
@@ -150,10 +105,6 @@ describe("Provider Batch Patch Action Contracts", () => {
     vi.clearAllMocks();
     vi.resetModules();
     redisStore.clear();
-    redisSetexMock.mockClear();
-    redisGetMock.mockClear();
-    redisDelMock.mockClear();
-    redisEvalMock.mockClear();
     getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
     findAllProvidersFreshMock.mockResolvedValue([]);
     updateProvidersBatchMock.mockResolvedValue(0);

+ 3 - 52
tests/unit/actions/providers-undo-engine.test.ts

@@ -1,50 +1,13 @@
 // @vitest-environment node
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
+import { buildRedisMock, createRedisStore } from "./redis-mock-utils";
 
 const getSessionMock = vi.fn();
 const findAllProvidersFreshMock = vi.fn();
 const updateProvidersBatchMock = vi.fn();
 const publishCacheInvalidationMock = vi.fn();
-const redisStore = new Map<string, { value: string; expiresAt: number }>();
-
-function readRedisValue(key: string): string | null {
-  const entry = redisStore.get(key);
-  if (!entry) {
-    return null;
-  }
-
-  if (entry.expiresAt <= Date.now()) {
-    redisStore.delete(key);
-    return null;
-  }
-
-  return entry.value;
-}
-
-const redisSetexMock = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
-  redisStore.set(key, {
-    value,
-    expiresAt: Date.now() + ttlSeconds * 1000,
-  });
-  return "OK";
-});
-
-const redisGetMock = vi.fn(async (key: string) => readRedisValue(key));
-
-const redisDelMock = vi.fn(async (key: string) => {
-  const existed = redisStore.delete(key);
-  return existed ? 1 : 0;
-});
-
-const redisEvalMock = vi.fn(async (_script: string, _numKeys: number, key: string) => {
-  const value = readRedisValue(key);
-  if (value === null) {
-    return null;
-  }
-  redisStore.delete(key);
-  return value;
-});
+const { store: redisStore, mocks: redisMocks } = createRedisStore();
 
 vi.mock("@/lib/auth", () => ({
   getSession: getSessionMock,
@@ -60,15 +23,7 @@ vi.mock("@/lib/cache/provider-cache", () => ({
   publishProviderCacheInvalidation: publishCacheInvalidationMock,
 }));
 
-vi.mock("@/lib/redis/client", () => ({
-  getRedisClient: () => ({
-    status: "ready",
-    setex: redisSetexMock,
-    get: redisGetMock,
-    del: redisDelMock,
-    eval: redisEvalMock,
-  }),
-}));
+vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
 
 vi.mock("@/lib/circuit-breaker", () => ({
   clearProviderState: vi.fn(),
@@ -153,10 +108,6 @@ describe("Undo Provider Batch Patch Engine", () => {
     vi.clearAllMocks();
     vi.resetModules();
     redisStore.clear();
-    redisSetexMock.mockClear();
-    redisGetMock.mockClear();
-    redisDelMock.mockClear();
-    redisEvalMock.mockClear();
     getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
     findAllProvidersFreshMock.mockResolvedValue([]);
     updateProvidersBatchMock.mockResolvedValue(0);

+ 48 - 0
tests/unit/actions/redis-mock-utils.ts

@@ -0,0 +1,48 @@
+import { vi } from "vitest";
+
+export function createRedisStore() {
+  const store = new Map<string, { value: string; expiresAt: number }>();
+
+  function readValue(key: string): string | null {
+    const entry = store.get(key);
+    if (!entry) return null;
+    if (entry.expiresAt <= Date.now()) {
+      store.delete(key);
+      return null;
+    }
+    return entry.value;
+  }
+
+  const setex = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
+    store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
+    return "OK";
+  });
+
+  const get = vi.fn(async (key: string) => readValue(key));
+
+  const del = vi.fn(async (key: string) => {
+    const existed = store.delete(key);
+    return existed ? 1 : 0;
+  });
+
+  const evalScript = vi.fn(async (_script: string, _numKeys: number, key: string) => {
+    const value = readValue(key);
+    if (value === null) return null;
+    store.delete(key);
+    return value;
+  });
+
+  return { store, mocks: { setex, get, del, eval: evalScript } };
+}
+
+export function buildRedisMock(mocks: ReturnType<typeof createRedisStore>["mocks"]) {
+  return {
+    getRedisClient: () => ({
+      status: "ready",
+      setex: mocks.setex,
+      get: mocks.get,
+      del: mocks.del,
+      eval: mocks.eval,
+    }),
+  };
+}

+ 6 - 6
tests/unit/redis/statistics-cache.test.ts

@@ -32,7 +32,7 @@ type RedisMock = {
   set: ReturnType<typeof vi.fn>;
   setex: ReturnType<typeof vi.fn>;
   del: ReturnType<typeof vi.fn>;
-  keys: ReturnType<typeof vi.fn>;
+  scan: ReturnType<typeof vi.fn>;
 };
 
 function createRedisMock(): RedisMock {
@@ -41,7 +41,7 @@ function createRedisMock(): RedisMock {
     set: vi.fn(),
     setex: vi.fn(),
     del: vi.fn(),
-    keys: vi.fn(),
+    scan: vi.fn(),
   };
 }
 
@@ -323,7 +323,7 @@ describe("invalidateStatisticsCache", () => {
       "statistics:7days:keys:global",
       "statistics:30days:mixed:global",
     ];
-    redis.keys.mockResolvedValueOnce(matchedKeys);
+    redis.scan.mockResolvedValueOnce(["0", matchedKeys]);
     redis.del.mockResolvedValueOnce(matchedKeys.length);
 
     vi.mocked(getRedisClient).mockReturnValue(
@@ -332,7 +332,7 @@ describe("invalidateStatisticsCache", () => {
 
     await invalidateStatisticsCache(undefined, undefined);
 
-    expect(redis.keys).toHaveBeenCalledWith("statistics:*:*:global");
+    expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:global", "COUNT", 100);
     expect(redis.del).toHaveBeenCalledWith(...matchedKeys);
   });
 
@@ -344,7 +344,7 @@ describe("invalidateStatisticsCache", () => {
 
   it("does not call del when wildcard query returns no key", async () => {
     const redis = createRedisMock();
-    redis.keys.mockResolvedValueOnce([]);
+    redis.scan.mockResolvedValueOnce(["0", []]);
 
     vi.mocked(getRedisClient).mockReturnValue(
       redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
@@ -352,7 +352,7 @@ describe("invalidateStatisticsCache", () => {
 
     await invalidateStatisticsCache(undefined, 42);
 
-    expect(redis.keys).toHaveBeenCalledWith("statistics:*:*:42");
+    expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:42", "COUNT", 100);
     expect(redis.del).not.toHaveBeenCalled();
   });