users-reset-limits-only.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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 - need update().set().where() chain
  40. const dbUpdateWhereMock = vi.fn();
  41. const dbUpdateSetMock = vi.fn(() => ({ where: dbUpdateWhereMock }));
  42. const dbUpdateMock = vi.fn(() => ({ set: dbUpdateSetMock }));
  43. const dbDeleteWhereMock = vi.fn();
  44. const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock }));
  45. vi.mock("@/drizzle/db", () => ({
  46. db: {
  47. update: dbUpdateMock,
  48. delete: dbDeleteMock,
  49. },
  50. }));
  51. // Mock logger
  52. const loggerMock = {
  53. info: vi.fn(),
  54. warn: vi.fn(),
  55. error: vi.fn(),
  56. };
  57. vi.mock("@/lib/logger", () => ({
  58. logger: loggerMock,
  59. }));
  60. // Mock Redis
  61. const redisPipelineMock = {
  62. del: vi.fn().mockReturnThis(),
  63. exec: vi.fn(),
  64. };
  65. const redisMock = {
  66. status: "ready",
  67. pipeline: vi.fn(() => redisPipelineMock),
  68. };
  69. const getRedisClientMock = vi.fn(() => redisMock);
  70. vi.mock("@/lib/redis", () => ({
  71. getRedisClient: getRedisClientMock,
  72. }));
  73. // Mock scanPattern
  74. const scanPatternMock = vi.fn();
  75. vi.mock("@/lib/redis/scan-helper", () => ({
  76. scanPattern: scanPatternMock,
  77. }));
  78. describe("resetUserLimitsOnly", () => {
  79. beforeEach(() => {
  80. vi.clearAllMocks();
  81. redisMock.status = "ready";
  82. redisPipelineMock.exec.mockResolvedValue([]);
  83. dbUpdateWhereMock.mockResolvedValue(undefined);
  84. resetUserCostResetAtMock.mockResolvedValue(true);
  85. });
  86. test("should return PERMISSION_DENIED for non-admin user", async () => {
  87. getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } });
  88. const { resetUserLimitsOnly } = await import("@/actions/users");
  89. const result = await resetUserLimitsOnly(123);
  90. expect(result.ok).toBe(false);
  91. expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
  92. expect(findUserByIdMock).not.toHaveBeenCalled();
  93. });
  94. test("should return PERMISSION_DENIED when no session", async () => {
  95. // TODO(#890): Consider returning UNAUTHORIZED for null session (current: PERMISSION_DENIED for both null + non-admin)
  96. getSessionMock.mockResolvedValue(null);
  97. const { resetUserLimitsOnly } = await import("@/actions/users");
  98. const result = await resetUserLimitsOnly(123);
  99. expect(result.ok).toBe(false);
  100. expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
  101. });
  102. test("should return NOT_FOUND for non-existent user", async () => {
  103. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  104. findUserByIdMock.mockResolvedValue(null);
  105. const { resetUserLimitsOnly } = await import("@/actions/users");
  106. const result = await resetUserLimitsOnly(999);
  107. expect(result.ok).toBe(false);
  108. expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND);
  109. expect(resetUserCostResetAtMock).not.toHaveBeenCalled();
  110. });
  111. test("should set costResetAt and clear Redis cost cache", async () => {
  112. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  113. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  114. findKeyListMock.mockResolvedValue([
  115. { id: 1, key: "sk-hash-1" },
  116. { id: 2, key: "sk-hash-2" },
  117. ]);
  118. scanPatternMock.mockResolvedValue(["key:1:cost_daily", "user:123:cost_weekly"]);
  119. redisPipelineMock.exec.mockResolvedValue([]);
  120. const { resetUserLimitsOnly } = await import("@/actions/users");
  121. const result = await resetUserLimitsOnly(123);
  122. expect(result.ok).toBe(true);
  123. // costResetAt set via repository function
  124. expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date));
  125. // Redis cost keys scanned and deleted
  126. expect(scanPatternMock).toHaveBeenCalled();
  127. expect(redisMock.pipeline).toHaveBeenCalled();
  128. expect(redisPipelineMock.del).toHaveBeenCalled();
  129. expect(redisPipelineMock.exec).toHaveBeenCalled();
  130. // Revalidate path
  131. expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users");
  132. // No DB deletes (messageRequest/usageLedger must NOT be deleted)
  133. expect(dbDeleteMock).not.toHaveBeenCalled();
  134. });
  135. test("should NOT delete messageRequest or usageLedger rows", async () => {
  136. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  137. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  138. findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
  139. scanPatternMock.mockResolvedValue([]);
  140. const { resetUserLimitsOnly } = await import("@/actions/users");
  141. await resetUserLimitsOnly(123);
  142. // Core assertion: db.delete must never be called
  143. expect(dbDeleteMock).not.toHaveBeenCalled();
  144. expect(dbDeleteWhereMock).not.toHaveBeenCalled();
  145. });
  146. test("should succeed when Redis is not ready", async () => {
  147. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  148. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  149. findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
  150. redisMock.status = "connecting";
  151. const { resetUserLimitsOnly } = await import("@/actions/users");
  152. const result = await resetUserLimitsOnly(123);
  153. expect(result.ok).toBe(true);
  154. // costResetAt still set via repo function
  155. expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date));
  156. // Redis pipeline NOT called
  157. expect(redisMock.pipeline).not.toHaveBeenCalled();
  158. });
  159. test("should succeed with warning when Redis has partial failures", async () => {
  160. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  161. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  162. findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
  163. scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
  164. redisPipelineMock.exec.mockResolvedValue([
  165. [null, 1],
  166. [new Error("Connection reset"), null],
  167. ]);
  168. const { resetUserLimitsOnly } = await import("@/actions/users");
  169. const result = await resetUserLimitsOnly(123);
  170. expect(result.ok).toBe(true);
  171. expect(loggerMock.warn).toHaveBeenCalledWith(
  172. "Some Redis deletes failed during cost cache cleanup",
  173. expect.objectContaining({ errorCount: 1, userId: 123 })
  174. );
  175. });
  176. test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => {
  177. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  178. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  179. findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
  180. scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
  181. redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed"));
  182. const { resetUserLimitsOnly } = await import("@/actions/users");
  183. const result = await resetUserLimitsOnly(123);
  184. // pipeline.exec throw is now caught inside clearUserCostCache (never-throws contract)
  185. // so resetUserLimitsOnly still succeeds without hitting its own catch block
  186. expect(result.ok).toBe(true);
  187. expect(loggerMock.warn).toHaveBeenCalledWith(
  188. "Redis pipeline.exec() failed during cost cache cleanup",
  189. expect.objectContaining({ userId: 123 })
  190. );
  191. });
  192. test("should return OPERATION_FAILED on unexpected error", async () => {
  193. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  194. findUserByIdMock.mockRejectedValue(new Error("Database connection failed"));
  195. const { resetUserLimitsOnly } = await import("@/actions/users");
  196. const result = await resetUserLimitsOnly(123);
  197. expect(result.ok).toBe(false);
  198. expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED);
  199. expect(loggerMock.error).toHaveBeenCalled();
  200. });
  201. test("should handle user with no keys", async () => {
  202. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  203. findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
  204. findKeyListMock.mockResolvedValue([]);
  205. scanPatternMock.mockResolvedValue([]);
  206. const { resetUserLimitsOnly } = await import("@/actions/users");
  207. const result = await resetUserLimitsOnly(123);
  208. expect(result.ok).toBe(true);
  209. expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date));
  210. // No DB deletes
  211. expect(dbDeleteMock).not.toHaveBeenCalled();
  212. });
  213. });