users-reset-all-statistics.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. import { ERROR_CODES } from "@/lib/utils/error-messages";
  3. // Mock getSession
  4. const getSessionMock = vi.fn();
  5. vi.mock("@/lib/auth", () => ({
  6. getSession: getSessionMock,
  7. }));
  8. // Mock next-intl
  9. const getTranslationsMock = vi.fn(async () => (key: string) => key);
  10. vi.mock("next-intl/server", () => ({
  11. getTranslations: getTranslationsMock,
  12. getLocale: vi.fn(async () => "en"),
  13. }));
  14. // Mock next/cache
  15. const revalidatePathMock = vi.fn();
  16. vi.mock("next/cache", () => ({
  17. revalidatePath: revalidatePathMock,
  18. }));
  19. // Mock repository/user
  20. const findUserByIdMock = vi.fn();
  21. const resetUserCostResetAtMock = vi.fn();
  22. vi.mock("@/repository/user", async (importOriginal) => {
  23. const actual = await importOriginal<typeof import("@/repository/user")>();
  24. return {
  25. ...actual,
  26. findUserById: findUserByIdMock,
  27. resetUserCostResetAt: resetUserCostResetAtMock,
  28. };
  29. });
  30. // Mock repository/key
  31. const findKeyListMock = vi.fn();
  32. vi.mock("@/repository/key", async (importOriginal) => {
  33. const actual = await importOriginal<typeof import("@/repository/key")>();
  34. return {
  35. ...actual,
  36. findKeyList: findKeyListMock,
  37. };
  38. });
  39. // Mock drizzle db
  40. const txDeleteWhereMock = vi.fn();
  41. const txDeleteMock = vi.fn(() => ({ where: txDeleteWhereMock }));
  42. const txUpdateSetMock = vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) }));
  43. const txUpdateMock = vi.fn(() => ({ set: txUpdateSetMock }));
  44. const txMock = {
  45. delete: txDeleteMock,
  46. update: txUpdateMock,
  47. };
  48. const dbTransactionMock = vi.fn(async (fn: (tx: typeof txMock) => Promise<void>) => {
  49. await fn(txMock);
  50. });
  51. vi.mock("@/drizzle/db", () => ({
  52. db: {
  53. transaction: dbTransactionMock,
  54. },
  55. }));
  56. // Mock logger
  57. const loggerMock = {
  58. info: vi.fn(),
  59. warn: vi.fn(),
  60. error: vi.fn(),
  61. };
  62. vi.mock("@/lib/logger", () => ({
  63. logger: loggerMock,
  64. }));
  65. // Mock invalidateCachedUser (called directly after transaction)
  66. const invalidateCachedUserMock = vi.fn();
  67. vi.mock("@/lib/security/api-key-auth-cache", () => ({
  68. invalidateCachedUser: invalidateCachedUserMock,
  69. }));
  70. // Mock Redis
  71. const redisPipelineMock = {
  72. del: vi.fn().mockReturnThis(),
  73. exec: vi.fn(),
  74. };
  75. const redisMock = {
  76. status: "ready",
  77. pipeline: vi.fn(() => redisPipelineMock),
  78. };
  79. const getRedisClientMock = vi.fn(() => redisMock);
  80. vi.mock("@/lib/redis", () => ({
  81. getRedisClient: getRedisClientMock,
  82. }));
  83. // Mock scanPattern
  84. const scanPatternMock = vi.fn();
  85. vi.mock("@/lib/redis/scan-helper", () => ({
  86. scanPattern: scanPatternMock,
  87. }));
  88. describe("resetUserAllStatistics", () => {
  89. beforeEach(() => {
  90. vi.clearAllMocks();
  91. // Reset redis mock to ready state
  92. redisMock.status = "ready";
  93. redisPipelineMock.exec.mockResolvedValue([]);
  94. // DB delete returns resolved promise
  95. txDeleteWhereMock.mockResolvedValue(undefined);
  96. resetUserCostResetAtMock.mockResolvedValue(true);
  97. invalidateCachedUserMock.mockResolvedValue(undefined);
  98. });
  99. test("should return PERMISSION_DENIED for non-admin user", async () => {
  100. getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } });
  101. const { resetUserAllStatistics } = await import("@/actions/users");
  102. const result = await resetUserAllStatistics(123);
  103. expect(result.ok).toBe(false);
  104. expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
  105. expect(findUserByIdMock).not.toHaveBeenCalled();
  106. });
  107. test("should return PERMISSION_DENIED when no session", async () => {
  108. getSessionMock.mockResolvedValue(null);
  109. const { resetUserAllStatistics } = await import("@/actions/users");
  110. const result = await resetUserAllStatistics(123);
  111. expect(result.ok).toBe(false);
  112. expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
  113. });
  114. test("should return NOT_FOUND for non-existent user", async () => {
  115. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  116. findUserByIdMock.mockResolvedValue(null);
  117. const { resetUserAllStatistics } = await import("@/actions/users");
  118. const result = await resetUserAllStatistics(999);
  119. expect(result.ok).toBe(false);
  120. expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND);
  121. expect(dbTransactionMock).not.toHaveBeenCalled();
  122. });
  123. test("should successfully reset all user statistics", async () => {
  124. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  125. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  126. findKeyListMock.mockResolvedValue([{ id: 1 }, { id: 2 }]);
  127. scanPatternMock.mockResolvedValue(["key:1:cost_daily", "key:2:cost_weekly"]);
  128. redisPipelineMock.exec.mockResolvedValue([]);
  129. const { resetUserAllStatistics } = await import("@/actions/users");
  130. const result = await resetUserAllStatistics(123);
  131. expect(result.ok).toBe(true);
  132. // DB transaction called (delete + update wrapped in transaction)
  133. expect(dbTransactionMock).toHaveBeenCalled();
  134. expect(txDeleteMock).toHaveBeenCalled();
  135. expect(txDeleteWhereMock).toHaveBeenCalled();
  136. // Redis operations
  137. expect(redisMock.pipeline).toHaveBeenCalled();
  138. expect(redisPipelineMock.del).toHaveBeenCalled();
  139. expect(redisPipelineMock.exec).toHaveBeenCalled();
  140. // Revalidate path
  141. expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users");
  142. // Logging
  143. expect(loggerMock.info).toHaveBeenCalled();
  144. });
  145. test("should succeed even when Redis is not ready", async () => {
  146. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  147. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  148. findKeyListMock.mockResolvedValue([{ id: 1 }]);
  149. redisMock.status = "connecting";
  150. const { resetUserAllStatistics } = await import("@/actions/users");
  151. const result = await resetUserAllStatistics(123);
  152. expect(result.ok).toBe(true);
  153. // DB transaction still called
  154. expect(dbTransactionMock).toHaveBeenCalled();
  155. // Redis pipeline NOT called (status not ready)
  156. expect(redisMock.pipeline).not.toHaveBeenCalled();
  157. });
  158. test("should succeed with warning when Redis has partial failures", async () => {
  159. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  160. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  161. findKeyListMock.mockResolvedValue([{ id: 1 }]);
  162. scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
  163. // Simulate partial failure - some commands return errors
  164. redisPipelineMock.exec.mockResolvedValue([
  165. [null, 1], // success
  166. [new Error("Connection reset"), null], // failure
  167. ]);
  168. const { resetUserAllStatistics } = await import("@/actions/users");
  169. const result = await resetUserAllStatistics(123);
  170. expect(result.ok).toBe(true);
  171. // Pipeline partial failures logged as warn inside clearUserCostCache
  172. expect(loggerMock.warn).toHaveBeenCalledWith(
  173. "Some Redis deletes failed during cost cache cleanup",
  174. expect.objectContaining({ errorCount: 1, userId: 123 })
  175. );
  176. });
  177. test("should succeed with warning when scanPattern fails", async () => {
  178. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  179. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  180. findKeyListMock.mockResolvedValue([{ id: 1 }]);
  181. // scanPattern fails but is caught by .catch() in Promise.all
  182. scanPatternMock.mockRejectedValue(new Error("Redis connection lost"));
  183. redisPipelineMock.exec.mockResolvedValue([]);
  184. const { resetUserAllStatistics } = await import("@/actions/users");
  185. const result = await resetUserAllStatistics(123);
  186. // Should still succeed - error is caught inside Promise.all
  187. expect(result.ok).toBe(true);
  188. expect(loggerMock.warn).toHaveBeenCalled();
  189. });
  190. test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => {
  191. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  192. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  193. findKeyListMock.mockResolvedValue([{ id: 1 }]);
  194. scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
  195. // pipeline.exec throws - caught inside clearUserCostCache (never-throws contract)
  196. redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed"));
  197. const { resetUserAllStatistics } = await import("@/actions/users");
  198. const result = await resetUserAllStatistics(123);
  199. // clearUserCostCache catches pipeline.exec throw internally, logs warn
  200. expect(result.ok).toBe(true);
  201. expect(loggerMock.warn).toHaveBeenCalledWith(
  202. "Redis pipeline.exec() failed during cost cache cleanup",
  203. expect.objectContaining({ userId: 123 })
  204. );
  205. });
  206. test("should return OPERATION_FAILED on unexpected error", async () => {
  207. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  208. findUserByIdMock.mockRejectedValue(new Error("Database connection failed"));
  209. const { resetUserAllStatistics } = await import("@/actions/users");
  210. const result = await resetUserAllStatistics(123);
  211. expect(result.ok).toBe(false);
  212. expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED);
  213. expect(loggerMock.error).toHaveBeenCalled();
  214. });
  215. test("should handle user with no keys", async () => {
  216. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  217. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  218. findKeyListMock.mockResolvedValue([]); // No keys
  219. scanPatternMock.mockResolvedValue([]);
  220. redisPipelineMock.exec.mockResolvedValue([]);
  221. const { resetUserAllStatistics } = await import("@/actions/users");
  222. const result = await resetUserAllStatistics(123);
  223. expect(result.ok).toBe(true);
  224. expect(dbTransactionMock).toHaveBeenCalled();
  225. });
  226. });