import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ abilities: { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true }, appCode: "fami", catalogError: "", catalogLoading: false, catalogPage: null, catalogQueryOptions: null, confirm: vi.fn(), createExternalAdminUser: vi.fn(), getExternalAdminUserPermissions: vi.fn(), invalidateQueries: vi.fn(), listExternalAdminUsers: vi.fn(), listExternalAdminPermissionCatalog: vi.fn(), lookupExternalAdminUserTarget: vi.fn(), queryOptions: null, queryPage: { items: [], page: 1, pageSize: 50, total: 0 }, reload: vi.fn(), resetExternalAdminUserPassword: vi.fn(), showToast: vi.fn(), updateExternalAdminUserStatus: vi.fn(), updateExternalAdminUserPermissions: vi.fn(), })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), })); vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({ useAppScope: () => ({ appCode: mocks.appCode }), })); vi.mock("@/features/external-admin-users/api", () => ({ createExternalAdminUser: mocks.createExternalAdminUser, getExternalAdminUserPermissions: mocks.getExternalAdminUserPermissions, listExternalAdminUsers: mocks.listExternalAdminUsers, listExternalAdminPermissionCatalog: mocks.listExternalAdminPermissionCatalog, lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget, resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword, updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus, updateExternalAdminUserPermissions: mocks.updateExternalAdminUserPermissions, })); vi.mock("@/features/external-admin-users/permissions.js", () => ({ useExternalAdminUserAbilities: () => mocks.abilities, })); vi.mock("@/shared/hooks/useAdminQuery.js", () => ({ useAdminQuery: (_queryFn, options) => { mocks.queryOptions = options; if (options.queryKey[0] === "external-admin-permission-catalog") { mocks.catalogQueryOptions = options; if (options.enabled) { void _queryFn(); } return { data: mocks.catalogPage, error: mocks.catalogError, loading: mocks.catalogLoading, reload: mocks.reload, }; } return { data: mocks.queryPage, error: "", loading: false, reload: mocks.reload }; }, })); vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({ useConfirm: () => mocks.confirm, })); vi.mock("@/shared/ui/ToastProvider.jsx", () => ({ useToast: () => ({ showToast: mocks.showToast }), })); import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js"; beforeEach(() => { mocks.abilities = { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true }; mocks.appCode = "fami"; mocks.catalogError = ""; mocks.catalogLoading = false; mocks.catalogPage = permissionCatalogFixture(); mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 }; mocks.confirm.mockResolvedValue(true); mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture()); mocks.listExternalAdminPermissionCatalog.mockResolvedValue(permissionCatalogFixture()); mocks.getExternalAdminUserPermissions.mockResolvedValue({ accountId: "71", permissions: ["user:list"], revision: 4, }); mocks.invalidateQueries.mockResolvedValue(undefined); mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture()); mocks.reload.mockResolvedValue(undefined); mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture()); mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" }); mocks.updateExternalAdminUserPermissions.mockResolvedValue(externalUserFixture()); }); afterEach(() => { vi.clearAllMocks(); }); test("creates an App-scoped account with the canonical userId returned by lookup", async () => { const { result } = renderHook(() => useExternalAdminUsersPage()); act(() => result.current.openCreate()); act(() => result.current.setDisplayUserId("VIP-1001")); await act(async () => result.current.lookupTarget()); act(() => result.current.setCreateForm({ confirmPassword: "StrongPass123!", password: "StrongPass123!", username: "ops.fami", }), ); await act(async () => result.current.submitCreate({ preventDefault: vi.fn() })); expect(mocks.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001"); expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", { password: "StrongPass123!", permissions: ["user:list", "user:update"], targetUserId: "internal-user-1001", username: "ops.fami", }); expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["external-admin-users", "fami"] }); expect(result.current.createOpen).toBe(false); }); test("creates an explicit zero-permission account after the operator clears defaults", async () => { const { result } = renderHook(() => useExternalAdminUsersPage()); act(() => result.current.openCreate()); act(() => result.current.setCreateForm({ permissions: [] })); act(() => result.current.setDisplayUserId("VIP-1001")); await act(async () => result.current.lookupTarget()); act(() => result.current.setCreateForm({ confirmPassword: "StrongPass123!", password: "StrongPass123!", username: "ops.zero", }), ); await act(async () => result.current.submitCreate({ preventDefault: vi.fn() })); expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", { password: "StrongPass123!", permissions: [], targetUserId: "internal-user-1001", username: "ops.zero", }); }); test("does not request the permission catalog without the configure-permissions grant", () => { mocks.abilities = { ...mocks.abilities, canCreate: false, canManagePermissions: false }; renderHook(() => useExternalAdminUsersPage()); expect(mocks.catalogQueryOptions.enabled).toBe(false); expect(mocks.listExternalAdminPermissionCatalog).not.toHaveBeenCalled(); }); test("treats an empty successful catalog as unavailable and blocks create and update writes", async () => { mocks.catalogPage = { items: [], total: 0 }; const item = externalUserFixture(); const { result } = renderHook(() => useExternalAdminUsersPage()); expect(result.current.catalogError).toBe("外管权限目录为空,请联系管理员"); act(() => result.current.openCreate()); act(() => result.current.setDisplayUserId("VIP-1001")); await act(async () => result.current.lookupTarget()); act(() => result.current.setCreateForm({ confirmPassword: "StrongPass123!", password: "StrongPass123!", username: "ops.empty", }), ); await act(async () => result.current.submitCreate({ preventDefault: vi.fn() })); expect(mocks.createExternalAdminUser).not.toHaveBeenCalled(); act(() => result.current.closeCreate()); act(() => result.current.openPermissions(item)); await waitFor(() => expect(result.current.permissionLoading).toBe(false)); await act(async () => result.current.submitPermissions()); expect(mocks.updateExternalAdminUserPermissions).not.toHaveBeenCalled(); expect(mocks.showToast).toHaveBeenCalledWith("外管权限目录为空,请联系管理员", "error"); }); test("clears list filters, lookup results and password form when App scope changes", async () => { const { result, rerender } = renderHook(() => useExternalAdminUsersPage()); act(() => result.current.setKeyword("old account")); act(() => result.current.setStatus("active")); act(() => result.current.openCreate()); act(() => result.current.setDisplayUserId("VIP-1001")); await act(async () => result.current.lookupTarget()); act(() => result.current.openPassword(externalUserFixture())); act(() => result.current.setPasswordForm({ confirmPassword: "OldPass123!", password: "OldPass123!" })); mocks.appCode = "lalu"; rerender(); await waitFor(() => expect(result.current.keyword).toBe("")); expect(result.current.status).toBe(""); expect(result.current.createOpen).toBe(false); expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" }); expect(result.current.targetUser).toBeNull(); expect(result.current.passwordUser).toBeNull(); expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" }); expect(mocks.queryOptions.queryKey[1]).toBe("lalu"); }); test("loads and replaces an account permission snapshot, including explicit zero permissions", async () => { const item = externalUserFixture(); const { result } = renderHook(() => useExternalAdminUsersPage()); act(() => result.current.openPermissions(item)); await waitFor(() => expect(result.current.permissionForm.permissions).toEqual(["user:list"])); expect(mocks.getExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71); act(() => result.current.setPermissions([])); await act(async () => result.current.submitPermissions()); expect(mocks.updateExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71, [], 4); expect(mocks.showToast).toHaveBeenCalledWith("权限已更新,该账号需重新登录", "success"); expect(result.current.permissionUser).toBeNull(); }); test("keeps the permission drawer open and offers reload after a concurrent update", async () => { const conflict = Object.assign(new Error("conflict"), { status: 409 }); mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce(conflict); const item = externalUserFixture(); const { result } = renderHook(() => useExternalAdminUsersPage()); act(() => result.current.openPermissions(item)); await waitFor(() => expect(result.current.permissionLoading).toBe(false)); await act(async () => result.current.submitPermissions()); expect(result.current.permissionUser).toEqual(item); expect(result.current.permissionError).toBe("账号权限已被更新,请重新加载"); expect(mocks.showToast).toHaveBeenCalledWith("账号权限已被更新,请重新加载", "error"); mocks.getExternalAdminUserPermissions.mockResolvedValueOnce({ accountId: "71", permissions: ["user:list", "user:update"], revision: 5, }); act(() => result.current.retryPermissions()); await waitFor(() => expect(result.current.permissionForm.expectedRevision).toBe(5)); await act(async () => result.current.submitPermissions()); expect(mocks.updateExternalAdminUserPermissions).toHaveBeenNthCalledWith( 2, "fami", 71, ["user:list", "user:update"], 5, ); expect(result.current.permissionUser).toBeNull(); }); test("does not expose backend permission codes when a non-conflict update fails", async () => { mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce( new Error("privilege:grant requires user-title:grant"), ); const { result } = renderHook(() => useExternalAdminUsersPage()); act(() => result.current.openPermissions(externalUserFixture())); await waitFor(() => expect(result.current.permissionLoading).toBe(false)); await act(async () => result.current.submitPermissions()); expect(result.current.permissionError).toBe("更新外管权限失败"); expect(mocks.showToast).toHaveBeenCalledWith("更新外管权限失败", "error"); }); test("ignores a late user lookup response after switching App", async () => { let resolveLookup; mocks.lookupExternalAdminUserTarget.mockImplementationOnce( () => new Promise((resolve) => { resolveLookup = resolve; }), ); const { result, rerender } = renderHook(() => useExternalAdminUsersPage()); act(() => result.current.openCreate()); act(() => result.current.setDisplayUserId("VIP-1001")); let lookupPromise; act(() => { lookupPromise = result.current.lookupTarget(); }); mocks.appCode = "lalu"; rerender(); await waitFor(() => expect(result.current.createOpen).toBe(false)); await act(async () => { resolveLookup(targetFixture()); await lookupPromise; }); expect(result.current.targetUser).toBeNull(); }); test("refreshes the App-scoped list after status and password changes", async () => { const item = externalUserFixture(); const { result } = renderHook(() => useExternalAdminUsersPage()); await act(async () => result.current.toggleStatus(item)); expect(mocks.updateExternalAdminUserStatus).toHaveBeenCalledWith("fami", 71, "disabled"); act(() => result.current.openPassword(item)); act(() => result.current.setPasswordForm({ confirmPassword: "NextPass123!", password: "NextPass123!" })); await act(async () => result.current.submitPassword({ preventDefault: vi.fn() })); expect(mocks.resetExternalAdminUserPassword).toHaveBeenCalledWith("fami", 71, "NextPass123!"); expect(mocks.invalidateQueries).toHaveBeenCalledTimes(2); expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(1, { queryKey: ["external-admin-users", "fami"] }); expect(mocks.invalidateQueries).toHaveBeenNthCalledWith(2, { queryKey: ["external-admin-users", "fami"] }); }); function targetFixture() { return { avatar: "https://cdn.example.com/user.png", defaultDisplayUserId: "1001", displayUserId: "VIP-1001", prettyDisplayUserId: "VIP-1001", prettyId: "pretty-1", status: "active", userId: "internal-user-1001", username: "Fami user", }; } function externalUserFixture() { return { appCode: "fami", createdAtMs: 1784073600000, createdByAdminId: 9, id: 71, lastLoginAtMs: 1784077200000, linkedUser: targetFixture(), permissions: [], permissionRevision: 4, status: "active", updatedAtMs: 1784077200000, username: "ops.fami", }; } function permissionCatalogFixture() { return { items: [ { capabilities: ["user:list"], code: "user:list", defaultGranted: true, dependencies: [], group: "用户管理", label: "用户列表" }, { capabilities: ["user:list", "user:update"], code: "user:update", defaultGranted: true, dependencies: ["user:list"], group: "用户管理", label: "编辑用户" }, { capabilities: ["room:list"], code: "room:list", defaultGranted: false, dependencies: [], group: "房间管理", label: "房间列表" }, ], total: 3, }; }