/** * @vitest-environment happy-dom * * 单元测试:用户管理 Dialog 组件 * * 测试对象: * - EditUserDialog * - EditKeyDialog * - AddKeyDialog * - CreateUserDialog */ import type { ReactNode } from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; // ==================== Mocks ==================== // Mock next/navigation vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn(), }), })); // Mock @/i18n/routing vi.mock("@/i18n/routing", () => ({ Link: ({ children }: { children: ReactNode }) => children, useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn(), }), })); // Mock Server Actions const mockEditUser = vi.fn().mockResolvedValue({ ok: true }); const mockRemoveUser = vi.fn().mockResolvedValue({ ok: true }); const mockToggleUserEnabled = vi.fn().mockResolvedValue({ ok: true }); const mockAddKey = vi.fn().mockResolvedValue({ ok: true, data: { key: "sk-test-key" } }); const mockEditKey = vi.fn().mockResolvedValue({ ok: true }); const mockCreateUserOnly = vi.fn().mockResolvedValue({ ok: true, data: { user: { id: 1 } } }); vi.mock("@/actions/users", () => ({ editUser: (...args: unknown[]) => mockEditUser(...args), removeUser: (...args: unknown[]) => mockRemoveUser(...args), toggleUserEnabled: (...args: unknown[]) => mockToggleUserEnabled(...args), createUserOnly: (...args: unknown[]) => mockCreateUserOnly(...args), })); vi.mock("@/actions/keys", () => ({ addKey: (...args: unknown[]) => mockAddKey(...args), editKey: (...args: unknown[]) => mockEditKey(...args), removeKey: vi.fn().mockResolvedValue({ ok: true }), })); vi.mock("@/actions/usage-logs", () => { return { getFilterOptions: () => Promise.resolve({ ok: true, data: { models: [] } }), }; }); // Mock sonner toast vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), }, })); // Mock Dialog components to simplify rendering vi.mock("@/components/ui/dialog", () => { type PropsWithChildren = { children?: ReactNode }; type DialogContentProps = PropsWithChildren & { className?: string }; function Dialog({ children }: PropsWithChildren) { return
{children}
; } function DialogContent({ children, className }: DialogContentProps) { return (
{children}
); } function DialogHeader({ children }: PropsWithChildren) { return
{children}
; } function DialogTitle({ children }: PropsWithChildren) { return

{children}

; } function DialogDescription({ children, className }: PropsWithChildren & { className?: string }) { return (

{children}

); } function DialogFooter({ children, className }: PropsWithChildren & { className?: string }) { return (
{children}
); } return { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter }; }); // Mock form components vi.mock("@/app/[locale]/dashboard/_components/user/forms/user-edit-section", () => ({ UserEditSection: ({ user, onChange, translations: _translations }: any) => (
onChange("name", e.target.value)} />
), })); vi.mock("@/app/[locale]/dashboard/_components/user/forms/key-edit-section", () => ({ KeyEditSection: ({ keyData, onChange, translations: _translations }: any) => (
onChange("name", e.target.value)} />
), })); vi.mock("@/app/[locale]/dashboard/_components/user/forms/danger-zone", () => ({ DangerZone: ({ userId, userName, onDelete }: any) => (
), })); vi.mock("@/app/[locale]/dashboard/_components/user/forms/add-key-form", () => ({ AddKeyForm: ({ userId, onSuccess }: any) => (
), })); vi.mock("@/app/[locale]/dashboard/_components/user/forms/edit-key-form", () => ({ EditKeyForm: ({ keyData, onSuccess }: any) => (
), })); // Import components after mocks import { EditUserDialog } from "@/app/[locale]/dashboard/_components/user/edit-user-dialog"; import { EditKeyDialog } from "@/app/[locale]/dashboard/_components/user/edit-key-dialog"; import { AddKeyDialog } from "@/app/[locale]/dashboard/_components/user/add-key-dialog"; import { CreateUserDialog } from "@/app/[locale]/dashboard/_components/user/create-user-dialog"; import type { UserDisplay } from "@/types/user"; // ==================== Test Utilities ==================== const messages = { common: { save: "Save", cancel: "Cancel", close: "Close", copySuccess: "Copied", copyFailed: "Copy failed", }, ui: { tagInput: { emptyTag: "Empty tag", duplicateTag: "Duplicate tag", tooLong: "Too long", invalidFormat: "Invalid format", maxTags: "Too many tags", }, }, dashboard: { userManagement: { editDialog: { title: "Edit User", description: "Edit user information", saving: "Saving...", saveSuccess: "User saved", saveFailed: "Save failed", operationFailed: "Operation failed", userDisabled: "User disabled", userEnabled: "User enabled", deleteFailed: "Delete failed", userDeleted: "User deleted", }, createDialog: { title: "Create User", description: "Create a new user with API key", creating: "Creating...", create: "Create", saveFailed: "Create failed", successTitle: "User Created", successDescription: "User created successfully", generatedKey: "Generated Key", keyHint: "Save this key, it cannot be recovered", }, userEditSection: { sections: { basicInfo: "Basic Info", expireTime: "Expiration", limitRules: "Limits", accessRestrictions: "Access", }, fields: { username: { label: "Username", placeholder: "Enter username" }, description: { label: "Note", placeholder: "Enter note" }, tags: { label: "Tags", placeholder: "Enter tags" }, providerGroup: { label: "Provider Group", placeholder: "Select group" }, enableStatus: { label: "Status", enabledDescription: "Enabled", disabledDescription: "Disabled", confirmEnable: "Enable", confirmDisable: "Disable", confirmEnableTitle: "Enable User", confirmDisableTitle: "Disable User", confirmEnableDescription: "Enable this user?", confirmDisableDescription: "Disable this user?", cancel: "Cancel", processing: "Processing...", }, allowedClients: { label: "Allowed Clients", description: "Restrict clients", customLabel: "Custom", customPlaceholder: "Custom client", }, allowedModels: { label: "Allowed Models", placeholder: "Select models", description: "Restrict models", }, }, presetClients: { "claude-cli": "Claude CLI", "gemini-cli": "Gemini CLI", "factory-cli": "Factory CLI", "codex-cli": "Codex CLI", }, }, keyEditSection: { sections: { basicInfo: "Basic Information", expireTime: "Expiration Time", limitRules: "Limit Rules", specialFeatures: "Special Features", }, fields: { keyName: { label: "Key Name", placeholder: "Enter key name" }, providerGroup: { label: "Provider Group", placeholder: "Default: default" }, cacheTtl: { label: "Cache TTL Override", options: { inherit: "No override", "5m": "5m", "1h": "1h" }, }, balanceQueryPage: { label: "Independent Personal Usage Page", description: "When enabled, this key can access an independent personal usage page", descriptionEnabled: "Enabled description", descriptionDisabled: "Disabled description", }, enableStatus: { label: "Enable Status", description: "Disabled keys cannot be used", }, }, }, dangerZone: { title: "Danger Zone", deleteUser: "Delete User", deleteUserDescription: "This action cannot be undone", deleteConfirm: "Type username to confirm", deleteButton: "Delete", }, limitRules: { addRule: "Add Rule", ruleTypes: { limitRpm: "RPM", limit5h: "5h Limit", limitDaily: "Daily", limitWeekly: "Weekly", limitMonthly: "Monthly", limitTotal: "Total", limitSessions: "Sessions", }, quickValues: { unlimited: "Unlimited", "10": "$10", "50": "$50", "100": "$100", "500": "$500", }, }, quickExpire: { oneWeek: "1 Week", oneMonth: "1 Month", threeMonths: "3 Months", oneYear: "1 Year", }, providerGroupSelect: { providersSuffix: "providers", loadFailed: "Failed to load provider groups", }, }, addKeyForm: { title: "Add Key", description: "Add a new API key", successTitle: "Key Created", successDescription: "Key created successfully", generatedKey: { label: "Generated Key", hint: "Save this key", }, keyName: { label: "Key Name", }, }, }, quota: { keys: { editKeyForm: { title: "Edit Key", description: "Edit key settings", }, }, }, }; let queryClient: QueryClient; function renderWithProviders(node: ReactNode) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); act(() => { root.render( {node} ); }); return { container, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } // Mock user data const mockUser: UserDisplay = { id: 1, name: "Test User", note: "Test note", role: "user", rpm: 10, dailyQuota: 100, providerGroup: "default", tags: ["test"], keys: [], isEnabled: true, expiresAt: null, }; // ==================== Tests ==================== describe("EditUserDialog", () => { beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); vi.clearAllMocks(); }); afterEach(() => { document.body.innerHTML = ""; }); test("renders dialog with user data when open", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( "Edit User" ); expect(container.querySelector('[data-testid="user-edit-section"]')).not.toBeNull(); expect(container.querySelector('[data-testid="danger-zone"]')).not.toBeNull(); unmount(); }); test("does not render content when closed", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); // Dialog root exists but content should be minimal expect(container.querySelector('[data-testid="user-edit-section"]')).toBeNull(); unmount(); }); test("passes correct user id to UserEditSection", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); const userEditSection = container.querySelector('[data-testid="user-edit-section"]'); expect(userEditSection?.getAttribute("data-user-id")).toBe("1"); unmount(); }); test("passes correct user id to DangerZone", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); const dangerZone = container.querySelector('[data-testid="danger-zone"]'); expect(dangerZone?.getAttribute("data-user-id")).toBe("1"); unmount(); }); test("has save and cancel buttons", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); const buttons = container.querySelectorAll("button"); const buttonTexts = Array.from(buttons).map((b) => b.textContent); expect(buttonTexts).toContain("Save"); expect(buttonTexts).toContain("Cancel"); unmount(); }); }); describe("EditKeyDialog", () => { beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); vi.clearAllMocks(); }); afterEach(() => { document.body.innerHTML = ""; }); const mockKeyData = { id: 1, name: "Test Key", expiresAt: "2025-12-31", canLoginWebUi: false, providerGroup: null, }; test("renders dialog with key data when open", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( "Edit Key" ); expect(container.querySelector('[data-testid="edit-key-form"]')).not.toBeNull(); unmount(); }); test("passes keyData to EditKeyForm", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); const editKeyForm = container.querySelector('[data-testid="edit-key-form"]'); expect(editKeyForm?.getAttribute("data-key-id")).toBe("1"); unmount(); }); test("calls onOpenChange when dialog is closed", () => { const onOpenChange = vi.fn(); const onSuccess = vi.fn(); const { container, unmount } = renderWithProviders( ); // Simulate clicking save in the mocked form const submitButton = container.querySelector('[data-testid="edit-key-submit"]') as HTMLElement; act(() => { submitButton?.click(); }); expect(onSuccess).toHaveBeenCalled(); expect(onOpenChange).toHaveBeenCalledWith(false); unmount(); }); }); describe("AddKeyDialog", () => { beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); vi.clearAllMocks(); }); afterEach(() => { document.body.innerHTML = ""; }); test("renders dialog with add key form when open", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( "Add Key" ); expect(container.querySelector('[data-testid="add-key-form"]')).not.toBeNull(); unmount(); }); test("passes userId to AddKeyForm", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); const addKeyForm = container.querySelector('[data-testid="add-key-form"]'); expect(addKeyForm?.getAttribute("data-user-id")).toBe("42"); unmount(); }); test("calls onSuccess after successful key creation", () => { const onOpenChange = vi.fn(); const onSuccess = vi.fn(); const { container, unmount } = renderWithProviders( ); // Initially shows form expect(container.querySelector('[data-testid="add-key-form"]')).not.toBeNull(); // Simulate successful key creation const submitButton = container.querySelector('[data-testid="add-key-submit"]') as HTMLElement; act(() => { submitButton?.click(); }); // onSuccess should be called expect(onSuccess).toHaveBeenCalled(); // The component should now show the success view with generated key info // (key name "test" from mock result) expect(container.textContent).toContain("Key Created"); unmount(); }); }); describe("CreateUserDialog", () => { beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); vi.clearAllMocks(); mockCreateUserOnly.mockResolvedValue({ ok: true, data: { user: { id: 1 } } }); mockAddKey.mockResolvedValue({ ok: true, data: { key: "sk-new-user-key" } }); }); afterEach(() => { document.body.innerHTML = ""; }); test("renders dialog with user and key sections when open", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( "Create User" ); expect(container.querySelector('[data-testid="user-edit-section"]')).not.toBeNull(); expect(container.querySelector('[data-testid="key-edit-section"]')).not.toBeNull(); unmount(); }); test("does not render content when closed", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); expect(container.querySelector('[data-testid="user-edit-section"]')).toBeNull(); expect(container.querySelector('[data-testid="key-edit-section"]')).toBeNull(); unmount(); }); test("has create and cancel buttons", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); const buttons = container.querySelectorAll("button"); const buttonTexts = Array.from(buttons).map((b) => b.textContent); expect(buttonTexts).toContain("Create"); expect(buttonTexts).toContain("Cancel"); unmount(); }); }); describe("Dialog Component Integration", () => { beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); vi.clearAllMocks(); }); afterEach(() => { document.body.innerHTML = ""; }); test("EditUserDialog re-renders with new user when user prop changes", () => { const onOpenChange = vi.fn(); const { container, unmount } = renderWithProviders( ); // Check initial user let userEditSection = container.querySelector('[data-testid="user-edit-section"]'); expect(userEditSection?.getAttribute("data-user-id")).toBe("1"); unmount(); // Render with different user const newUser = { ...mockUser, id: 2, name: "New User" }; const { container: container2, unmount: unmount2 } = renderWithProviders( ); userEditSection = container2.querySelector('[data-testid="user-edit-section"]'); expect(userEditSection?.getAttribute("data-user-id")).toBe("2"); unmount2(); }); test("all dialogs have accessible title", () => { const onOpenChange = vi.fn(); // EditUserDialog const edit = renderWithProviders( ); expect(edit.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); edit.unmount(); // EditKeyDialog const editKey = renderWithProviders( ); expect(editKey.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); editKey.unmount(); // AddKeyDialog const addKey = renderWithProviders( ); expect(addKey.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); addKey.unmount(); // CreateUserDialog const create = renderWithProviders( ); expect(create.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); create.unmount(); }); });