| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- import { beforeEach, describe, expect, test, vi } from "vitest";
- import { ERROR_CODES } from "@/lib/utils/error-messages";
- // Mock getSession
- const getSessionMock = vi.fn();
- vi.mock("@/lib/auth", () => ({
- getSession: getSessionMock,
- }));
- // Mock next-intl
- const getTranslationsMock = vi.fn(async () => (key: string) => key);
- vi.mock("next-intl/server", () => ({
- getTranslations: getTranslationsMock,
- getLocale: vi.fn(async () => "en"),
- }));
- // Mock next/cache
- const revalidatePathMock = vi.fn();
- vi.mock("next/cache", () => ({
- revalidatePath: revalidatePathMock,
- }));
- // Mock repository/user
- const findUserByIdMock = vi.fn();
- const resetUserCostResetAtMock = vi.fn();
- vi.mock("@/repository/user", async (importOriginal) => {
- const actual = await importOriginal<typeof import("@/repository/user")>();
- return {
- ...actual,
- findUserById: findUserByIdMock,
- resetUserCostResetAt: resetUserCostResetAtMock,
- };
- });
- // Mock repository/key
- const findKeyListMock = vi.fn();
- vi.mock("@/repository/key", async (importOriginal) => {
- const actual = await importOriginal<typeof import("@/repository/key")>();
- return {
- ...actual,
- findKeyList: findKeyListMock,
- };
- });
- // Mock drizzle db - need update().set().where() chain
- const dbUpdateWhereMock = vi.fn();
- const dbUpdateSetMock = vi.fn(() => ({ where: dbUpdateWhereMock }));
- const dbUpdateMock = vi.fn(() => ({ set: dbUpdateSetMock }));
- const dbDeleteWhereMock = vi.fn();
- const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock }));
- vi.mock("@/drizzle/db", () => ({
- db: {
- update: dbUpdateMock,
- delete: dbDeleteMock,
- },
- }));
- // Mock logger
- const loggerMock = {
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- };
- vi.mock("@/lib/logger", () => ({
- logger: loggerMock,
- }));
- // Mock Redis
- const redisPipelineMock = {
- del: vi.fn().mockReturnThis(),
- exec: vi.fn(),
- };
- const redisMock = {
- status: "ready",
- pipeline: vi.fn(() => redisPipelineMock),
- };
- const getRedisClientMock = vi.fn(() => redisMock);
- vi.mock("@/lib/redis", () => ({
- getRedisClient: getRedisClientMock,
- }));
- // Mock scanPattern
- const scanPatternMock = vi.fn();
- vi.mock("@/lib/redis/scan-helper", () => ({
- scanPattern: scanPatternMock,
- }));
- describe("resetUserLimitsOnly", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- redisMock.status = "ready";
- redisPipelineMock.exec.mockResolvedValue([]);
- dbUpdateWhereMock.mockResolvedValue(undefined);
- resetUserCostResetAtMock.mockResolvedValue(true);
- });
- test("should return PERMISSION_DENIED for non-admin user", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } });
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- expect(result.ok).toBe(false);
- expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
- expect(findUserByIdMock).not.toHaveBeenCalled();
- });
- test("should return PERMISSION_DENIED when no session", async () => {
- // TODO(#890): Consider returning UNAUTHORIZED for null session (current: PERMISSION_DENIED for both null + non-admin)
- getSessionMock.mockResolvedValue(null);
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- expect(result.ok).toBe(false);
- expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
- });
- test("should return NOT_FOUND for non-existent user", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockResolvedValue(null);
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(999);
- expect(result.ok).toBe(false);
- expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND);
- expect(resetUserCostResetAtMock).not.toHaveBeenCalled();
- });
- test("should set costResetAt and clear Redis cost cache", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
- findKeyListMock.mockResolvedValue([
- { id: 1, key: "sk-hash-1" },
- { id: 2, key: "sk-hash-2" },
- ]);
- scanPatternMock.mockResolvedValue(["key:1:cost_daily", "user:123:cost_weekly"]);
- redisPipelineMock.exec.mockResolvedValue([]);
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- expect(result.ok).toBe(true);
- // costResetAt set via repository function
- expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date));
- // Redis cost keys scanned and deleted
- expect(scanPatternMock).toHaveBeenCalled();
- expect(redisMock.pipeline).toHaveBeenCalled();
- expect(redisPipelineMock.del).toHaveBeenCalled();
- expect(redisPipelineMock.exec).toHaveBeenCalled();
- // Revalidate path
- expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users");
- // No DB deletes (messageRequest/usageLedger must NOT be deleted)
- expect(dbDeleteMock).not.toHaveBeenCalled();
- });
- test("should NOT delete messageRequest or usageLedger rows", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
- findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
- scanPatternMock.mockResolvedValue([]);
- const { resetUserLimitsOnly } = await import("@/actions/users");
- await resetUserLimitsOnly(123);
- // Core assertion: db.delete must never be called
- expect(dbDeleteMock).not.toHaveBeenCalled();
- expect(dbDeleteWhereMock).not.toHaveBeenCalled();
- });
- test("should succeed when Redis is not ready", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
- findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
- redisMock.status = "connecting";
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- expect(result.ok).toBe(true);
- // costResetAt still set via repo function
- expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date));
- // Redis pipeline NOT called
- expect(redisMock.pipeline).not.toHaveBeenCalled();
- });
- test("should succeed with warning when Redis has partial failures", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
- findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
- scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
- redisPipelineMock.exec.mockResolvedValue([
- [null, 1],
- [new Error("Connection reset"), null],
- ]);
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- expect(result.ok).toBe(true);
- expect(loggerMock.warn).toHaveBeenCalledWith(
- "Some Redis deletes failed during cost cache cleanup",
- expect.objectContaining({ errorCount: 1, userId: 123 })
- );
- });
- test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
- findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]);
- scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
- redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed"));
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- // pipeline.exec throw is now caught inside clearUserCostCache (never-throws contract)
- // so resetUserLimitsOnly still succeeds without hitting its own catch block
- expect(result.ok).toBe(true);
- expect(loggerMock.warn).toHaveBeenCalledWith(
- "Redis pipeline.exec() failed during cost cache cleanup",
- expect.objectContaining({ userId: 123 })
- );
- });
- test("should return OPERATION_FAILED on unexpected error", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockRejectedValue(new Error("Database connection failed"));
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- expect(result.ok).toBe(false);
- expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED);
- expect(loggerMock.error).toHaveBeenCalled();
- });
- test("should handle user with no keys", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
- findKeyListMock.mockResolvedValue([]);
- scanPatternMock.mockResolvedValue([]);
- const { resetUserLimitsOnly } = await import("@/actions/users");
- const result = await resetUserLimitsOnly(123);
- expect(result.ok).toBe(true);
- expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date));
- // No DB deletes
- expect(dbDeleteMock).not.toHaveBeenCalled();
- });
- });
|