فهرست منبع

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

ding113 1 هفته پیش
والد
کامیت
9d31ceb563

+ 36 - 0
tests/unit/dashboard/dashboard-cache-keys.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { buildOverviewCacheKey, buildStatisticsCacheKey } from "@/types/dashboard-cache";
+import type { TimeRange } from "@/types/statistics";
+
+describe("buildOverviewCacheKey", () => {
+  it("returns 'overview:global' for global scope", () => {
+    expect(buildOverviewCacheKey("global")).toBe("overview:global");
+  });
+
+  it("returns 'overview:user:42' for user scope with userId=42", () => {
+    expect(buildOverviewCacheKey("user", 42)).toBe("overview:user:42");
+  });
+});
+
+describe("buildStatisticsCacheKey", () => {
+  it("returns correct key for today/users/global", () => {
+    expect(buildStatisticsCacheKey("today", "users")).toBe("statistics:today:users:global");
+  });
+
+  it("returns correct key with userId", () => {
+    expect(buildStatisticsCacheKey("7days", "keys", 42)).toBe("statistics:7days:keys:42");
+  });
+
+  it("handles all TimeRange values", () => {
+    const timeRanges: TimeRange[] = ["today", "7days", "30days", "thisMonth"];
+    const keys = timeRanges.map((timeRange) => buildStatisticsCacheKey(timeRange, "users"));
+
+    expect(keys).toEqual([
+      "statistics:today:users:global",
+      "statistics:7days:users:global",
+      "statistics:30days:users:global",
+      "statistics:thisMonth:users:global",
+    ]);
+    expect(new Set(keys).size).toBe(timeRanges.length);
+  });
+});

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

@@ -0,0 +1,172 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { getRedisClient } from "@/lib/redis/client";
+import { getOverviewWithCache, invalidateOverviewCache } from "@/lib/redis/overview-cache";
+import {
+  getOverviewMetricsWithComparison,
+  type OverviewMetricsWithComparison,
+} from "@/repository/overview";
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis/client", () => ({
+  getRedisClient: vi.fn(),
+}));
+
+vi.mock("@/repository/overview", () => ({
+  getOverviewMetricsWithComparison: vi.fn(),
+}));
+
+type RedisMock = {
+  get: ReturnType<typeof vi.fn>;
+  set: ReturnType<typeof vi.fn>;
+  setex: ReturnType<typeof vi.fn>;
+  del: ReturnType<typeof vi.fn>;
+};
+
+function createRedisMock(): RedisMock {
+  return {
+    get: vi.fn(),
+    set: vi.fn(),
+    setex: vi.fn(),
+    del: vi.fn(),
+  };
+}
+
+function createOverviewData(): OverviewMetricsWithComparison {
+  return {
+    todayRequests: 100,
+    todayCost: 12.34,
+    avgResponseTime: 210,
+    todayErrorRate: 1.25,
+    yesterdaySamePeriodRequests: 80,
+    yesterdaySamePeriodCost: 10.1,
+    yesterdaySamePeriodAvgResponseTime: 230,
+    recentMinuteRequests: 3,
+  };
+}
+
+describe("getOverviewWithCache", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("returns cached data on cache hit (no DB call)", async () => {
+    const data = createOverviewData();
+    const redis = createRedisMock();
+    redis.get.mockResolvedValueOnce(JSON.stringify(data));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    const result = await getOverviewWithCache();
+
+    expect(result).toEqual(data);
+    expect(redis.get).toHaveBeenCalledWith("overview:global");
+    expect(getOverviewMetricsWithComparison).not.toHaveBeenCalled();
+  });
+
+  it("calls DB on cache miss, stores in Redis with 10s TTL", async () => {
+    const data = createOverviewData();
+    const redis = createRedisMock();
+    redis.get.mockResolvedValueOnce(null);
+    redis.set.mockResolvedValueOnce("OK");
+    redis.setex.mockResolvedValueOnce("OK");
+    redis.del.mockResolvedValueOnce(1);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+    vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
+
+    const result = await getOverviewWithCache(42);
+
+    expect(result).toEqual(data);
+    expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(42);
+    expect(redis.set).toHaveBeenCalledWith("overview:user:42:lock", "1", "EX", 5, "NX");
+    expect(redis.setex).toHaveBeenCalledWith("overview:user:42", 10, JSON.stringify(data));
+    expect(redis.del).toHaveBeenCalledWith("overview:user:42:lock");
+  });
+
+  it("falls back to direct DB query when Redis is unavailable (null client)", async () => {
+    const data = createOverviewData();
+    vi.mocked(getRedisClient).mockReturnValue(null);
+    vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
+
+    const result = await getOverviewWithCache(7);
+
+    expect(result).toEqual(data);
+    expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(7);
+  });
+
+  it("falls back to direct DB query on Redis error", async () => {
+    const data = createOverviewData();
+    const redis = createRedisMock();
+    redis.get.mockRejectedValueOnce(new Error("redis read failed"));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+    vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
+
+    const result = await getOverviewWithCache();
+
+    expect(result).toEqual(data);
+    expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(undefined);
+  });
+
+  it("uses different cache keys for global vs user scope", async () => {
+    const redis = createRedisMock();
+    const data = createOverviewData();
+
+    redis.get.mockResolvedValue(null);
+    redis.set.mockResolvedValue("OK");
+    redis.setex.mockResolvedValue("OK");
+    redis.del.mockResolvedValue(1);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+    vi.mocked(getOverviewMetricsWithComparison).mockResolvedValue(data);
+
+    await getOverviewWithCache();
+    await getOverviewWithCache(42);
+
+    expect(redis.get).toHaveBeenNthCalledWith(1, "overview:global");
+    expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:42");
+    expect(redis.setex).toHaveBeenNthCalledWith(1, "overview:global", 10, JSON.stringify(data));
+    expect(redis.setex).toHaveBeenNthCalledWith(2, "overview:user:42", 10, JSON.stringify(data));
+  });
+});
+
+describe("invalidateOverviewCache", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("deletes the correct cache key", async () => {
+    const redis = createRedisMock();
+    redis.del.mockResolvedValueOnce(1);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await invalidateOverviewCache(42);
+
+    expect(redis.del).toHaveBeenCalledWith("overview:user:42");
+  });
+
+  it("does nothing when Redis is unavailable", async () => {
+    vi.mocked(getRedisClient).mockReturnValue(null);
+
+    await expect(invalidateOverviewCache(42)).resolves.toBeUndefined();
+  });
+});

+ 268 - 0
tests/unit/redis/statistics-cache.test.ts

@@ -0,0 +1,268 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { getRedisClient } from "@/lib/redis/client";
+import { getStatisticsWithCache, invalidateStatisticsCache } from "@/lib/redis/statistics-cache";
+import {
+  getKeyStatisticsFromDB,
+  getMixedStatisticsFromDB,
+  getUserStatisticsFromDB,
+} from "@/repository/statistics";
+import type { DatabaseKeyStatRow, DatabaseStatRow, TimeRange } from "@/types/statistics";
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis/client", () => ({
+  getRedisClient: vi.fn(),
+}));
+
+vi.mock("@/repository/statistics", () => ({
+  getUserStatisticsFromDB: vi.fn(),
+  getKeyStatisticsFromDB: vi.fn(),
+  getMixedStatisticsFromDB: vi.fn(),
+}));
+
+type RedisMock = {
+  get: ReturnType<typeof vi.fn>;
+  set: ReturnType<typeof vi.fn>;
+  setex: ReturnType<typeof vi.fn>;
+  del: ReturnType<typeof vi.fn>;
+  keys: ReturnType<typeof vi.fn>;
+};
+
+function createRedisMock(): RedisMock {
+  return {
+    get: vi.fn(),
+    set: vi.fn(),
+    setex: vi.fn(),
+    del: vi.fn(),
+    keys: vi.fn(),
+  };
+}
+
+function createUserStats(): DatabaseStatRow[] {
+  return [
+    {
+      user_id: 1,
+      user_name: "alice",
+      date: "2026-02-19",
+      api_calls: 10,
+      total_cost: "1.23",
+    },
+  ];
+}
+
+function createKeyStats(): DatabaseKeyStatRow[] {
+  return [
+    {
+      key_id: 100,
+      key_name: "test-key",
+      date: "2026-02-19",
+      api_calls: 6,
+      total_cost: "0.56",
+    },
+  ];
+}
+
+describe("getStatisticsWithCache", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("returns cached data on cache hit", async () => {
+    const redis = createRedisMock();
+    const cached = createUserStats();
+    redis.get.mockResolvedValueOnce(JSON.stringify(cached));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    const result = await getStatisticsWithCache("today", "users");
+
+    expect(result).toEqual(cached);
+    expect(redis.get).toHaveBeenCalledWith("statistics:today:users:global");
+    expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
+    expect(getKeyStatisticsFromDB).not.toHaveBeenCalled();
+    expect(getMixedStatisticsFromDB).not.toHaveBeenCalled();
+  });
+
+  it("calls getUserStatisticsFromDB for mode=users on cache miss", async () => {
+    const redis = createRedisMock();
+    const rows = createUserStats();
+    redis.get.mockResolvedValueOnce(null);
+    redis.set.mockResolvedValueOnce("OK");
+    redis.setex.mockResolvedValueOnce("OK");
+    redis.del.mockResolvedValueOnce(1);
+
+    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");
+    expect(getKeyStatisticsFromDB).not.toHaveBeenCalled();
+    expect(getMixedStatisticsFromDB).not.toHaveBeenCalled();
+  });
+
+  it("calls getKeyStatisticsFromDB for mode=keys on cache miss", async () => {
+    const redis = createRedisMock();
+    const rows = createKeyStats();
+    redis.get.mockResolvedValueOnce(null);
+    redis.set.mockResolvedValueOnce("OK");
+    redis.setex.mockResolvedValueOnce("OK");
+    redis.del.mockResolvedValueOnce(1);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+    vi.mocked(getKeyStatisticsFromDB).mockResolvedValueOnce(rows);
+
+    const result = await getStatisticsWithCache("7days", "keys", 42);
+
+    expect(result).toEqual(rows);
+    expect(getKeyStatisticsFromDB).toHaveBeenCalledWith(42, "7days");
+    expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
+    expect(getMixedStatisticsFromDB).not.toHaveBeenCalled();
+  });
+
+  it("calls getMixedStatisticsFromDB for mode=mixed on cache miss", async () => {
+    const redis = createRedisMock();
+    const mixedResult = {
+      ownKeys: createKeyStats(),
+      othersAggregate: createUserStats(),
+    };
+    redis.get.mockResolvedValueOnce(null);
+    redis.set.mockResolvedValueOnce("OK");
+    redis.setex.mockResolvedValueOnce("OK");
+    redis.del.mockResolvedValueOnce(1);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+    vi.mocked(getMixedStatisticsFromDB).mockResolvedValueOnce(mixedResult);
+
+    const result = await getStatisticsWithCache("30days", "mixed", 42);
+
+    expect(result).toEqual(mixedResult);
+    expect(getMixedStatisticsFromDB).toHaveBeenCalledWith(42, "30days");
+    expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
+    expect(getKeyStatisticsFromDB).not.toHaveBeenCalled();
+  });
+
+  it("stores result with 30s TTL", async () => {
+    const redis = createRedisMock();
+    const rows = createUserStats();
+    redis.get.mockResolvedValueOnce(null);
+    redis.set.mockResolvedValueOnce("OK");
+    redis.setex.mockResolvedValueOnce("OK");
+    redis.del.mockResolvedValueOnce(1);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+    vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
+
+    await getStatisticsWithCache("today", "users");
+
+    expect(redis.setex).toHaveBeenCalledWith(
+      "statistics:today:users:global",
+      30,
+      JSON.stringify(rows)
+    );
+  });
+
+  it("falls back to direct DB on Redis unavailable", async () => {
+    const rows = createUserStats();
+    vi.mocked(getRedisClient).mockReturnValue(null);
+    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();
+    redis.get.mockResolvedValue(JSON.stringify(rows));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await getStatisticsWithCache("today", "users");
+    await getStatisticsWithCache("7days", "users");
+
+    expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global");
+    expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:7days:users:global");
+  });
+
+  it("uses different cache keys for global vs user scope", async () => {
+    const redis = createRedisMock();
+    const rows = createUserStats();
+    redis.get.mockResolvedValue(JSON.stringify(rows));
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await getStatisticsWithCache("today", "users");
+    await getStatisticsWithCache("today", "users", 42);
+
+    expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global");
+    expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:today:users:42");
+  });
+});
+
+describe("invalidateStatisticsCache", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("deletes all mode keys for a given timeRange", async () => {
+    const redis = createRedisMock();
+    redis.del.mockResolvedValueOnce(3);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await invalidateStatisticsCache("today", 42);
+
+    expect(redis.del).toHaveBeenCalledWith(
+      "statistics:today:users:42",
+      "statistics:today:keys:42",
+      "statistics:today:mixed:42"
+    );
+  });
+
+  it("deletes all keys for scope when timeRange is undefined", async () => {
+    const redis = createRedisMock();
+    const matchedKeys = [
+      "statistics:today:users:global",
+      "statistics:7days:keys:global",
+      "statistics:30days:mixed:global",
+    ];
+    redis.keys.mockResolvedValueOnce(matchedKeys);
+    redis.del.mockResolvedValueOnce(matchedKeys.length);
+
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    await invalidateStatisticsCache(undefined, undefined);
+
+    expect(redis.keys).toHaveBeenCalledWith("statistics:*:*:global");
+    expect(redis.del).toHaveBeenCalledWith(...matchedKeys);
+  });
+});