overview-cache.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { getRedisClient } from "@/lib/redis/client";
  3. import { getOverviewWithCache, invalidateOverviewCache } from "@/lib/redis/overview-cache";
  4. import {
  5. getOverviewMetricsWithComparison,
  6. type OverviewMetricsWithComparison,
  7. } from "@/repository/overview";
  8. vi.mock("@/lib/logger", () => ({
  9. logger: {
  10. debug: vi.fn(),
  11. info: vi.fn(),
  12. warn: vi.fn(),
  13. error: vi.fn(),
  14. },
  15. }));
  16. vi.mock("@/lib/redis/client", () => ({
  17. getRedisClient: vi.fn(),
  18. }));
  19. vi.mock("@/repository/overview", () => ({
  20. getOverviewMetricsWithComparison: vi.fn(),
  21. }));
  22. type RedisMock = {
  23. get: ReturnType<typeof vi.fn>;
  24. set: ReturnType<typeof vi.fn>;
  25. setex: ReturnType<typeof vi.fn>;
  26. del: ReturnType<typeof vi.fn>;
  27. };
  28. function createRedisMock(): RedisMock {
  29. return {
  30. get: vi.fn(),
  31. set: vi.fn(),
  32. setex: vi.fn(),
  33. del: vi.fn(),
  34. };
  35. }
  36. function createOverviewData(): OverviewMetricsWithComparison {
  37. return {
  38. todayRequests: 100,
  39. todayCost: 12.34,
  40. avgResponseTime: 210,
  41. todayErrorRate: 1.25,
  42. yesterdaySamePeriodRequests: 80,
  43. yesterdaySamePeriodCost: 10.1,
  44. yesterdaySamePeriodAvgResponseTime: 230,
  45. recentMinuteRequests: 3,
  46. };
  47. }
  48. describe("getOverviewWithCache", () => {
  49. beforeEach(() => {
  50. vi.clearAllMocks();
  51. });
  52. it("returns cached data on cache hit (no DB call)", async () => {
  53. const data = createOverviewData();
  54. const redis = createRedisMock();
  55. redis.get.mockResolvedValueOnce(JSON.stringify(data));
  56. vi.mocked(getRedisClient).mockReturnValue(
  57. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  58. );
  59. const result = await getOverviewWithCache();
  60. expect(result).toEqual(data);
  61. expect(redis.get).toHaveBeenCalledWith("overview:global");
  62. expect(getOverviewMetricsWithComparison).not.toHaveBeenCalled();
  63. });
  64. it("calls DB on cache miss, stores in Redis with 10s TTL", async () => {
  65. const data = createOverviewData();
  66. const redis = createRedisMock();
  67. redis.get.mockResolvedValueOnce(null);
  68. redis.set.mockResolvedValueOnce("OK");
  69. redis.setex.mockResolvedValueOnce("OK");
  70. redis.del.mockResolvedValueOnce(1);
  71. vi.mocked(getRedisClient).mockReturnValue(
  72. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  73. );
  74. vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
  75. const result = await getOverviewWithCache(42);
  76. expect(result).toEqual(data);
  77. expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(42);
  78. expect(redis.set).toHaveBeenCalledWith("overview:user:42:lock", "1", "EX", 5, "NX");
  79. expect(redis.setex).toHaveBeenCalledWith("overview:user:42", 10, JSON.stringify(data));
  80. expect(redis.del).toHaveBeenCalledWith("overview:user:42:lock");
  81. });
  82. it("falls back to direct DB query when Redis is unavailable (null client)", async () => {
  83. const data = createOverviewData();
  84. vi.mocked(getRedisClient).mockReturnValue(null);
  85. vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
  86. const result = await getOverviewWithCache(7);
  87. expect(result).toEqual(data);
  88. expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(7);
  89. });
  90. it("falls back to direct DB query on Redis error", async () => {
  91. const data = createOverviewData();
  92. const redis = createRedisMock();
  93. redis.get.mockRejectedValueOnce(new Error("redis read failed"));
  94. vi.mocked(getRedisClient).mockReturnValue(
  95. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  96. );
  97. vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
  98. const result = await getOverviewWithCache();
  99. expect(result).toEqual(data);
  100. expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(undefined);
  101. });
  102. it("falls back to direct DB query when lock is held and retry is still empty", async () => {
  103. vi.useFakeTimers();
  104. try {
  105. const data = createOverviewData();
  106. const redis = createRedisMock();
  107. redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
  108. redis.set.mockResolvedValueOnce(null);
  109. vi.mocked(getRedisClient).mockReturnValue(
  110. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  111. );
  112. vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data);
  113. const pending = getOverviewWithCache(99);
  114. await vi.advanceTimersByTimeAsync(100);
  115. const result = await pending;
  116. expect(result).toEqual(data);
  117. expect(redis.set).toHaveBeenCalledWith("overview:user:99:lock", "1", "EX", 5, "NX");
  118. expect(redis.get).toHaveBeenNthCalledWith(1, "overview:user:99");
  119. expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:99");
  120. expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(99);
  121. } finally {
  122. vi.useRealTimers();
  123. }
  124. });
  125. it("uses different cache keys for global vs user scope", async () => {
  126. const redis = createRedisMock();
  127. const data = createOverviewData();
  128. redis.get.mockResolvedValue(null);
  129. redis.set.mockResolvedValue("OK");
  130. redis.setex.mockResolvedValue("OK");
  131. redis.del.mockResolvedValue(1);
  132. vi.mocked(getRedisClient).mockReturnValue(
  133. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  134. );
  135. vi.mocked(getOverviewMetricsWithComparison).mockResolvedValue(data);
  136. await getOverviewWithCache();
  137. await getOverviewWithCache(42);
  138. expect(redis.get).toHaveBeenNthCalledWith(1, "overview:global");
  139. expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:42");
  140. expect(redis.setex).toHaveBeenNthCalledWith(1, "overview:global", 10, JSON.stringify(data));
  141. expect(redis.setex).toHaveBeenNthCalledWith(2, "overview:user:42", 10, JSON.stringify(data));
  142. });
  143. });
  144. describe("invalidateOverviewCache", () => {
  145. beforeEach(() => {
  146. vi.clearAllMocks();
  147. });
  148. it("deletes the correct cache key", async () => {
  149. const redis = createRedisMock();
  150. redis.del.mockResolvedValueOnce(1);
  151. vi.mocked(getRedisClient).mockReturnValue(
  152. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  153. );
  154. await invalidateOverviewCache(42);
  155. expect(redis.del).toHaveBeenCalledWith("overview:user:42");
  156. });
  157. it("does nothing when Redis is unavailable", async () => {
  158. vi.mocked(getRedisClient).mockReturnValue(null);
  159. await expect(invalidateOverviewCache(42)).resolves.toBeUndefined();
  160. });
  161. it("swallows Redis errors during invalidation", async () => {
  162. const redis = createRedisMock();
  163. redis.del.mockRejectedValueOnce(new Error("delete failed"));
  164. vi.mocked(getRedisClient).mockReturnValue(
  165. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  166. );
  167. await expect(invalidateOverviewCache()).resolves.toBeUndefined();
  168. });
  169. });