cost-cache-cleanup.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. // Mock logger
  3. const loggerMock = {
  4. info: vi.fn(),
  5. warn: vi.fn(),
  6. error: vi.fn(),
  7. };
  8. vi.mock("@/lib/logger", () => ({
  9. logger: loggerMock,
  10. }));
  11. // Mock Redis
  12. const redisPipelineMock = {
  13. del: vi.fn().mockReturnThis(),
  14. exec: vi.fn(),
  15. };
  16. const redisMock = {
  17. status: "ready" as string,
  18. pipeline: vi.fn(() => redisPipelineMock),
  19. };
  20. const getRedisClientMock = vi.fn(() => redisMock);
  21. vi.mock("@/lib/redis", () => ({
  22. getRedisClient: getRedisClientMock,
  23. }));
  24. // Mock scanPattern
  25. const scanPatternMock = vi.fn();
  26. vi.mock("@/lib/redis/scan-helper", () => ({
  27. scanPattern: scanPatternMock,
  28. }));
  29. // Mock active-session-keys
  30. vi.mock("@/lib/redis/active-session-keys", () => ({
  31. getKeyActiveSessionsKey: (keyId: number) => `{active_sessions}:key:${keyId}:active_sessions`,
  32. getUserActiveSessionsKey: (userId: number) => `{active_sessions}:user:${userId}:active_sessions`,
  33. }));
  34. describe("clearUserCostCache", () => {
  35. beforeEach(() => {
  36. vi.resetAllMocks();
  37. // Re-establish default implementations after resetAllMocks
  38. getRedisClientMock.mockReturnValue(redisMock);
  39. redisMock.status = "ready";
  40. redisMock.pipeline.mockReturnValue(redisPipelineMock);
  41. redisPipelineMock.del.mockReturnThis();
  42. redisPipelineMock.exec.mockResolvedValue([]);
  43. scanPatternMock.mockResolvedValue([]);
  44. });
  45. test("scans correct Redis patterns for keyIds, userId, keyHashes", async () => {
  46. scanPatternMock.mockResolvedValue([]);
  47. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  48. await clearUserCostCache({
  49. userId: 10,
  50. keyIds: [1, 2],
  51. keyHashes: ["hash-a", "hash-b"],
  52. });
  53. const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern);
  54. // Per-key cost counters
  55. expect(calls).toContain("key:1:cost_*");
  56. expect(calls).toContain("key:2:cost_*");
  57. // User cost counters
  58. expect(calls).toContain("user:10:cost_*");
  59. // Total cost cache (user)
  60. expect(calls).toContain("total_cost:user:10");
  61. expect(calls).toContain("total_cost:user:10:*");
  62. // Total cost cache (key hashes)
  63. expect(calls).toContain("total_cost:key:hash-a");
  64. expect(calls).toContain("total_cost:key:hash-a:*");
  65. expect(calls).toContain("total_cost:key:hash-b");
  66. expect(calls).toContain("total_cost:key:hash-b:*");
  67. // Lease cache
  68. expect(calls).toContain("lease:key:1:*");
  69. expect(calls).toContain("lease:key:2:*");
  70. expect(calls).toContain("lease:user:10:*");
  71. });
  72. test("pipeline deletes all found keys", async () => {
  73. scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => {
  74. if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"];
  75. if (pattern === "user:10:cost_*") return ["user:10:cost_monthly"];
  76. return [];
  77. });
  78. redisPipelineMock.exec.mockResolvedValue([
  79. [null, 1],
  80. [null, 1],
  81. [null, 1],
  82. ]);
  83. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  84. const result = await clearUserCostCache({
  85. userId: 10,
  86. keyIds: [1],
  87. keyHashes: [],
  88. });
  89. expect(result).not.toBeNull();
  90. expect(result!.costKeysDeleted).toBe(3);
  91. expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_daily");
  92. expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_5h");
  93. expect(redisPipelineMock.del).toHaveBeenCalledWith("user:10:cost_monthly");
  94. expect(redisPipelineMock.exec).toHaveBeenCalled();
  95. });
  96. test("returns metrics (costKeysDeleted, activeSessionsDeleted, durationMs)", async () => {
  97. scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => {
  98. if (pattern === "key:1:cost_*") return ["key:1:cost_daily"];
  99. return [];
  100. });
  101. redisPipelineMock.exec.mockResolvedValue([
  102. [null, 1],
  103. [null, 1],
  104. [null, 1],
  105. ]);
  106. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  107. const result = await clearUserCostCache({
  108. userId: 10,
  109. keyIds: [1],
  110. keyHashes: [],
  111. includeActiveSessions: true,
  112. });
  113. expect(result).not.toBeNull();
  114. expect(result!.costKeysDeleted).toBe(1);
  115. // 1 key session + 1 user session = 2
  116. expect(result!.activeSessionsDeleted).toBe(2);
  117. expect(typeof result!.durationMs).toBe("number");
  118. expect(result!.durationMs).toBeGreaterThanOrEqual(0);
  119. });
  120. test("returns null when Redis not ready", async () => {
  121. redisMock.status = "connecting";
  122. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  123. const result = await clearUserCostCache({
  124. userId: 10,
  125. keyIds: [1],
  126. keyHashes: [],
  127. });
  128. expect(result).toBeNull();
  129. expect(scanPatternMock).not.toHaveBeenCalled();
  130. });
  131. test("returns null when Redis client is null", async () => {
  132. getRedisClientMock.mockReturnValue(null);
  133. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  134. const result = await clearUserCostCache({
  135. userId: 10,
  136. keyIds: [1],
  137. keyHashes: [],
  138. });
  139. expect(result).toBeNull();
  140. });
  141. test("includeActiveSessions=true adds session key DELs", async () => {
  142. scanPatternMock.mockResolvedValue([]);
  143. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  144. const result = await clearUserCostCache({
  145. userId: 10,
  146. keyIds: [1, 2],
  147. keyHashes: [],
  148. includeActiveSessions: true,
  149. });
  150. expect(result).not.toBeNull();
  151. // 2 key sessions + 1 user session
  152. expect(result!.activeSessionsDeleted).toBe(3);
  153. expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:1:active_sessions");
  154. expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:2:active_sessions");
  155. expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:user:10:active_sessions");
  156. });
  157. test("includeActiveSessions=false skips session keys", async () => {
  158. scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => {
  159. if (pattern === "key:1:cost_*") return ["key:1:cost_daily"];
  160. return [];
  161. });
  162. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  163. const result = await clearUserCostCache({
  164. userId: 10,
  165. keyIds: [1],
  166. keyHashes: [],
  167. includeActiveSessions: false,
  168. });
  169. expect(result).not.toBeNull();
  170. expect(result!.activeSessionsDeleted).toBe(0);
  171. // Only cost key deleted, no session keys
  172. const delCalls = redisPipelineMock.del.mock.calls.map(([k]: [string]) => k);
  173. expect(delCalls).not.toContain("{active_sessions}:key:1:active_sessions");
  174. expect(delCalls).not.toContain("{active_sessions}:user:10:active_sessions");
  175. });
  176. test("empty scan results -- no pipeline created, returns zeros", async () => {
  177. scanPatternMock.mockResolvedValue([]);
  178. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  179. const result = await clearUserCostCache({
  180. userId: 10,
  181. keyIds: [1],
  182. keyHashes: [],
  183. includeActiveSessions: false,
  184. });
  185. expect(result).not.toBeNull();
  186. expect(result!.costKeysDeleted).toBe(0);
  187. expect(result!.activeSessionsDeleted).toBe(0);
  188. // No pipeline created when nothing to delete
  189. expect(redisMock.pipeline).not.toHaveBeenCalled();
  190. });
  191. test("pipeline partial failures -- logged, does not throw", async () => {
  192. scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => {
  193. if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"];
  194. return [];
  195. });
  196. redisPipelineMock.exec.mockResolvedValue([
  197. [null, 1],
  198. [new Error("Connection reset"), null],
  199. ]);
  200. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  201. const result = await clearUserCostCache({
  202. userId: 10,
  203. keyIds: [1],
  204. keyHashes: [],
  205. });
  206. expect(result).not.toBeNull();
  207. expect(result!.costKeysDeleted).toBe(2);
  208. expect(loggerMock.warn).toHaveBeenCalledWith(
  209. "Some Redis deletes failed during cost cache cleanup",
  210. expect.objectContaining({ errorCount: 1, userId: 10 })
  211. );
  212. });
  213. test("no keys (empty keyIds/keyHashes) -- only user patterns scanned", async () => {
  214. scanPatternMock.mockResolvedValue([]);
  215. const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup");
  216. await clearUserCostCache({
  217. userId: 10,
  218. keyIds: [],
  219. keyHashes: [],
  220. });
  221. const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern);
  222. // Only user-level patterns (no key:* or total_cost:key:* patterns)
  223. expect(calls).toContain("user:10:cost_*");
  224. expect(calls).toContain("total_cost:user:10");
  225. expect(calls).toContain("total_cost:user:10:*");
  226. expect(calls).toContain("lease:user:10:*");
  227. // No key-specific patterns
  228. expect(calls.filter((p: string) => p.startsWith("key:"))).toHaveLength(0);
  229. expect(calls.filter((p: string) => p.startsWith("total_cost:key:"))).toHaveLength(0);
  230. expect(calls.filter((p: string) => p.startsWith("lease:key:"))).toHaveLength(0);
  231. });
  232. });