hyapp-admin-platform/src/features/external-admin-users/hooks/useExternalAdminUsersPage.test.jsx

362 lines
15 KiB
JavaScript

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,
};
}