statistics-cache.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { getRedisClient } from "@/lib/redis/client";
  3. import { getStatisticsWithCache, invalidateStatisticsCache } from "@/lib/redis/statistics-cache";
  4. import {
  5. getKeyStatisticsFromDB,
  6. getMixedStatisticsFromDB,
  7. getUserStatisticsFromDB,
  8. } from "@/repository/statistics";
  9. import type { DatabaseKeyStatRow, DatabaseStatRow } from "@/types/statistics";
  10. vi.mock("@/lib/logger", () => ({
  11. logger: {
  12. debug: vi.fn(),
  13. info: vi.fn(),
  14. warn: vi.fn(),
  15. error: vi.fn(),
  16. },
  17. }));
  18. vi.mock("@/lib/redis/client", () => ({
  19. getRedisClient: vi.fn(),
  20. }));
  21. vi.mock("@/repository/statistics", () => ({
  22. getUserStatisticsFromDB: vi.fn(),
  23. getKeyStatisticsFromDB: vi.fn(),
  24. getMixedStatisticsFromDB: vi.fn(),
  25. }));
  26. type RedisMock = {
  27. get: ReturnType<typeof vi.fn>;
  28. set: ReturnType<typeof vi.fn>;
  29. setex: ReturnType<typeof vi.fn>;
  30. del: ReturnType<typeof vi.fn>;
  31. scan: ReturnType<typeof vi.fn>;
  32. };
  33. function createRedisMock(): RedisMock {
  34. return {
  35. get: vi.fn(),
  36. set: vi.fn(),
  37. setex: vi.fn(),
  38. del: vi.fn(),
  39. scan: vi.fn(),
  40. };
  41. }
  42. function createUserStats(): DatabaseStatRow[] {
  43. return [
  44. {
  45. user_id: 1,
  46. user_name: "alice",
  47. date: "2026-02-19",
  48. api_calls: 10,
  49. total_cost: "1.23",
  50. },
  51. ];
  52. }
  53. function createKeyStats(): DatabaseKeyStatRow[] {
  54. return [
  55. {
  56. key_id: 100,
  57. key_name: "test-key",
  58. date: "2026-02-19",
  59. api_calls: 6,
  60. total_cost: "0.56",
  61. },
  62. ];
  63. }
  64. describe("getStatisticsWithCache", () => {
  65. beforeEach(() => {
  66. vi.clearAllMocks();
  67. });
  68. it("returns cached data on cache hit", async () => {
  69. const redis = createRedisMock();
  70. const cached = createUserStats();
  71. redis.get.mockResolvedValueOnce(JSON.stringify(cached));
  72. vi.mocked(getRedisClient).mockReturnValue(
  73. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  74. );
  75. const result = await getStatisticsWithCache("today", "users");
  76. expect(result).toEqual(cached);
  77. expect(redis.get).toHaveBeenCalledWith("statistics:today:users:global");
  78. expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
  79. expect(getKeyStatisticsFromDB).not.toHaveBeenCalled();
  80. expect(getMixedStatisticsFromDB).not.toHaveBeenCalled();
  81. });
  82. it("calls getUserStatisticsFromDB for mode=users on cache miss", async () => {
  83. const redis = createRedisMock();
  84. const rows = createUserStats();
  85. redis.get.mockResolvedValueOnce(null);
  86. redis.set.mockResolvedValueOnce("OK");
  87. redis.setex.mockResolvedValueOnce("OK");
  88. redis.del.mockResolvedValueOnce(1);
  89. vi.mocked(getRedisClient).mockReturnValue(
  90. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  91. );
  92. vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
  93. const result = await getStatisticsWithCache("today", "users");
  94. expect(result).toEqual(rows);
  95. expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today");
  96. expect(getKeyStatisticsFromDB).not.toHaveBeenCalled();
  97. expect(getMixedStatisticsFromDB).not.toHaveBeenCalled();
  98. });
  99. it("calls getKeyStatisticsFromDB for mode=keys on cache miss", async () => {
  100. const redis = createRedisMock();
  101. const rows = createKeyStats();
  102. redis.get.mockResolvedValueOnce(null);
  103. redis.set.mockResolvedValueOnce("OK");
  104. redis.setex.mockResolvedValueOnce("OK");
  105. redis.del.mockResolvedValueOnce(1);
  106. vi.mocked(getRedisClient).mockReturnValue(
  107. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  108. );
  109. vi.mocked(getKeyStatisticsFromDB).mockResolvedValueOnce(rows);
  110. const result = await getStatisticsWithCache("7days", "keys", 42);
  111. expect(result).toEqual(rows);
  112. expect(getKeyStatisticsFromDB).toHaveBeenCalledWith(42, "7days");
  113. expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
  114. expect(getMixedStatisticsFromDB).not.toHaveBeenCalled();
  115. });
  116. it("calls getMixedStatisticsFromDB for mode=mixed on cache miss", async () => {
  117. const redis = createRedisMock();
  118. const mixedResult = {
  119. ownKeys: createKeyStats(),
  120. othersAggregate: createUserStats(),
  121. };
  122. redis.get.mockResolvedValueOnce(null);
  123. redis.set.mockResolvedValueOnce("OK");
  124. redis.setex.mockResolvedValueOnce("OK");
  125. redis.del.mockResolvedValueOnce(1);
  126. vi.mocked(getRedisClient).mockReturnValue(
  127. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  128. );
  129. vi.mocked(getMixedStatisticsFromDB).mockResolvedValueOnce(mixedResult);
  130. const result = await getStatisticsWithCache("30days", "mixed", 42);
  131. expect(result).toEqual(mixedResult);
  132. expect(getMixedStatisticsFromDB).toHaveBeenCalledWith(42, "30days");
  133. expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
  134. expect(getKeyStatisticsFromDB).not.toHaveBeenCalled();
  135. });
  136. it("stores result with 30s TTL", async () => {
  137. const redis = createRedisMock();
  138. const rows = createUserStats();
  139. redis.get.mockResolvedValueOnce(null);
  140. redis.set.mockResolvedValueOnce("OK");
  141. redis.setex.mockResolvedValueOnce("OK");
  142. redis.del.mockResolvedValueOnce(1);
  143. vi.mocked(getRedisClient).mockReturnValue(
  144. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  145. );
  146. vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
  147. await getStatisticsWithCache("today", "users");
  148. expect(redis.setex).toHaveBeenCalledWith(
  149. "statistics:today:users:global",
  150. 30,
  151. JSON.stringify(rows)
  152. );
  153. });
  154. it("falls back to direct DB on Redis unavailable", async () => {
  155. const rows = createUserStats();
  156. vi.mocked(getRedisClient).mockReturnValue(null);
  157. vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
  158. const result = await getStatisticsWithCache("today", "users");
  159. expect(result).toEqual(rows);
  160. expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today");
  161. });
  162. it("uses retry path and returns cached data when lock is held", async () => {
  163. vi.useFakeTimers();
  164. try {
  165. const redis = createRedisMock();
  166. const rows = createUserStats();
  167. redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(JSON.stringify(rows));
  168. redis.set.mockResolvedValueOnce(null);
  169. vi.mocked(getRedisClient).mockReturnValue(
  170. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  171. );
  172. const pending = getStatisticsWithCache("today", "users");
  173. await vi.advanceTimersByTimeAsync(100);
  174. const result = await pending;
  175. expect(result).toEqual(rows);
  176. expect(redis.set).toHaveBeenCalledWith(
  177. "statistics:today:users:global:lock",
  178. "1",
  179. "EX",
  180. 5,
  181. "NX"
  182. );
  183. expect(getUserStatisticsFromDB).not.toHaveBeenCalled();
  184. } finally {
  185. vi.useRealTimers();
  186. }
  187. });
  188. it("falls back to direct DB when retry times out", async () => {
  189. vi.useFakeTimers();
  190. try {
  191. const redis = createRedisMock();
  192. const rows = createUserStats();
  193. redis.get.mockResolvedValue(null);
  194. redis.set.mockResolvedValueOnce(null);
  195. vi.mocked(getRedisClient).mockReturnValue(
  196. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  197. );
  198. vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
  199. const pending = getStatisticsWithCache("today", "users");
  200. await vi.advanceTimersByTimeAsync(5100);
  201. const result = await pending;
  202. expect(result).toEqual(rows);
  203. expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today");
  204. } finally {
  205. vi.useRealTimers();
  206. }
  207. });
  208. it("falls back to direct DB on Redis error", async () => {
  209. const redis = createRedisMock();
  210. const rows = createUserStats();
  211. redis.get.mockRejectedValueOnce(new Error("redis get failed"));
  212. vi.mocked(getRedisClient).mockReturnValue(
  213. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  214. );
  215. vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows);
  216. const result = await getStatisticsWithCache("today", "users");
  217. expect(result).toEqual(rows);
  218. expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today");
  219. });
  220. it("uses different cache keys for different timeRanges", async () => {
  221. const redis = createRedisMock();
  222. const rows = createUserStats();
  223. redis.get.mockResolvedValue(JSON.stringify(rows));
  224. vi.mocked(getRedisClient).mockReturnValue(
  225. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  226. );
  227. await getStatisticsWithCache("today", "users");
  228. await getStatisticsWithCache("7days", "users");
  229. expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global");
  230. expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:7days:users:global");
  231. });
  232. it("uses different cache keys for global vs user scope", async () => {
  233. const redis = createRedisMock();
  234. const rows = createUserStats();
  235. redis.get.mockResolvedValue(JSON.stringify(rows));
  236. vi.mocked(getRedisClient).mockReturnValue(
  237. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  238. );
  239. await getStatisticsWithCache("today", "users");
  240. await getStatisticsWithCache("today", "users", 42);
  241. expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global");
  242. expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:today:users:42");
  243. });
  244. });
  245. describe("invalidateStatisticsCache", () => {
  246. beforeEach(() => {
  247. vi.clearAllMocks();
  248. });
  249. it("deletes all mode keys for a given timeRange", async () => {
  250. const redis = createRedisMock();
  251. redis.del.mockResolvedValueOnce(3);
  252. vi.mocked(getRedisClient).mockReturnValue(
  253. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  254. );
  255. await invalidateStatisticsCache("today", 42);
  256. expect(redis.del).toHaveBeenCalledWith(
  257. "statistics:today:users:42",
  258. "statistics:today:keys:42",
  259. "statistics:today:mixed:42"
  260. );
  261. });
  262. it("deletes all keys for scope when timeRange is undefined", async () => {
  263. const redis = createRedisMock();
  264. const matchedKeys = [
  265. "statistics:today:users:global",
  266. "statistics:7days:keys:global",
  267. "statistics:30days:mixed:global",
  268. ];
  269. redis.scan.mockResolvedValueOnce(["0", matchedKeys]);
  270. redis.del.mockResolvedValueOnce(matchedKeys.length);
  271. vi.mocked(getRedisClient).mockReturnValue(
  272. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  273. );
  274. await invalidateStatisticsCache(undefined, undefined);
  275. expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:global", "COUNT", 100);
  276. expect(redis.del).toHaveBeenCalledWith(...matchedKeys);
  277. });
  278. it("does nothing when Redis is unavailable", async () => {
  279. vi.mocked(getRedisClient).mockReturnValue(null);
  280. await expect(invalidateStatisticsCache("today", 42)).resolves.toBeUndefined();
  281. });
  282. it("does not call del when wildcard query returns no key", async () => {
  283. const redis = createRedisMock();
  284. redis.scan.mockResolvedValueOnce(["0", []]);
  285. vi.mocked(getRedisClient).mockReturnValue(
  286. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  287. );
  288. await invalidateStatisticsCache(undefined, 42);
  289. expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:42", "COUNT", 100);
  290. expect(redis.del).not.toHaveBeenCalled();
  291. });
  292. it("swallows Redis errors during invalidation", async () => {
  293. const redis = createRedisMock();
  294. redis.del.mockRejectedValueOnce(new Error("delete failed"));
  295. vi.mocked(getRedisClient).mockReturnValue(
  296. redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
  297. );
  298. await expect(invalidateStatisticsCache("today", 42)).resolves.toBeUndefined();
  299. });
  300. });