Browse Source

test(dashboard): add unit tests for performance optimization modules

ding113 1 week ago
parent
commit
e73a246e76
2 changed files with 140 additions and 1 deletions
  1. 38 0
      tests/unit/redis/overview-cache.test.ts
  2. 102 1
      tests/unit/redis/statistics-cache.test.ts

+ 38 - 0
tests/unit/redis/overview-cache.test.ts

@@ -122,6 +122,33 @@ describe("getOverviewWithCache", () => {
     expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(undefined);
   });
 
+  it("falls back to direct DB query when lock is held and retry is still empty", async () => {
+    vi.useFakeTimers();
+    try {
+      const data = createOverviewData();
+      const redis = createRedisMock();
+      redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
+      redis.set.mockResolvedValueOnce(null);
+
+      vi.mocked(getRedisClient).mockReturnValue(
+        redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+      );
+      vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
+
+      const pending = getOverviewWithCache(99);
+      await vi.advanceTimersByTimeAsync(100);
+      const result = await pending;
+
+      expect(result).toEqual(data);
+      expect(redis.set).toHaveBeenCalledWith("overview:user:99:lock", "1", "EX", 5, "NX");
+      expect(redis.get).toHaveBeenNthCalledWith(1, "overview:user:99");
+      expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:99");
+      expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(99);
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
   it("uses different cache keys for global vs user scope", async () => {
     const redis = createRedisMock();
     const data = createOverviewData();
@@ -169,4 +196,15 @@ describe("invalidateOverviewCache", () => {
 
     await expect(invalidateOverviewCache(42)).resolves.toBeUndefined();
   });
+
+  it("swallows Redis errors during invalidation", async () => {
+    const redis = createRedisMock();
+    redis.del.mockRejectedValueOnce(new Error("delete failed"));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await expect(invalidateOverviewCache()).resolves.toBeUndefined();
+  });
 });

+ 102 - 1
tests/unit/redis/statistics-cache.test.ts

@@ -6,7 +6,7 @@ import {
   getMixedStatisticsFromDB,
   getUserStatisticsFromDB,
 } from "@/repository/statistics";
-import type { DatabaseKeyStatRow, DatabaseStatRow, TimeRange } from "@/types/statistics";
+import type { DatabaseKeyStatRow, DatabaseStatRow } from "@/types/statistics";
 
 vi.mock("@/lib/logger", () => ({
   logger: {
@@ -191,6 +191,76 @@ describe("getStatisticsWithCache", () => {
     expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today");
   });
 
+  it("uses retry path and returns cached data when lock is held", async () => {
+    vi.useFakeTimers();
+    try {
+      const redis = createRedisMock();
+      const rows = createUserStats();
+      redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(JSON.stringify(rows));
+      redis.set.mockResolvedValueOnce(null);
+
+      vi.mocked(getRedisClient).mockReturnValue(
+        redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+      );
+
+      const pending = getStatisticsWithCache("today", "users");
+      await vi.advanceTimersByTimeAsync(100);
+      const result = await pending;
+
+      expect(result).toEqual(rows);
+      expect(redis.set).toHaveBeenCalledWith(
+        "statistics:today:users:global:lock",
+        "1",
+        "EX",
+        5,
+        "NX"
+      );
+      expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
+  it("falls back to direct DB when retry times out", async () => {
+    vi.useFakeTimers();
+    try {
+      const redis = createRedisMock();
+      const rows = createUserStats();
+      redis.get.mockResolvedValue(null);
+      redis.set.mockResolvedValueOnce(null);
+
+      vi.mocked(getRedisClient).mockReturnValue(
+        redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+      );
+      vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
+
+      const pending = getStatisticsWithCache("today", "users");
+      await vi.advanceTimersByTimeAsync(5100);
+      const result = await pending;
+
+      expect(result).toEqual(rows);
+      expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today");
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
+  it("falls back to direct DB on Redis error", async () => {
+    const redis = createRedisMock();
+    const rows = createUserStats();
+    redis.get.mockRejectedValueOnce(new Error("redis get failed"));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+    vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
+
+    const result = await getStatisticsWithCache("today", "users");
+
+    expect(result).toEqual(rows);
+    expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today");
+  });
+
   it("uses different cache keys for different timeRanges", async () => {
     const redis = createRedisMock();
     const rows = createUserStats();
@@ -265,4 +335,35 @@ describe("invalidateStatisticsCache", () => {
     expect(redis.keys).toHaveBeenCalledWith("statistics:*:*:global");
     expect(redis.del).toHaveBeenCalledWith(...matchedKeys);
   });
+
+  it("does nothing when Redis is unavailable", async () => {
+    vi.mocked(getRedisClient).mockReturnValue(null);
+
+    await expect(invalidateStatisticsCache("today", 42)).resolves.toBeUndefined();
+  });
+
+  it("does not call del when wildcard query returns no key", async () => {
+    const redis = createRedisMock();
+    redis.keys.mockResolvedValueOnce([]);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await invalidateStatisticsCache(undefined, 42);
+
+    expect(redis.keys).toHaveBeenCalledWith("statistics:*:*:42");
+    expect(redis.del).not.toHaveBeenCalled();
+  });
+
+  it("swallows Redis errors during invalidation", async () => {
+    const redis = createRedisMock();
+    redis.del.mockRejectedValueOnce(new Error("delete failed"));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await expect(invalidateStatisticsCache("today", 42)).resolves.toBeUndefined();
+  });
 });