This commit is contained in:
Stefan Pejcic
2024-11-07 19:03:37 +01:00
parent c6df945ed5
commit 09f9f9502d
2472 changed files with 620417 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
export * from "./useLog";
export * from "./useLogList";

View File

@@ -0,0 +1,424 @@
import { renderHook, waitFor } from "@testing-library/react";
import { TestWrapper, mockAuthProvider, mockLegacyAuthProvider } from "@test";
import { useLog } from ".";
import type { LogParams } from "../../../contexts/auditLog/types";
import type { ResourceProps } from "../../../contexts/resource/types";
import * as hasPermission from "../../../definitions/helpers/hasPermission";
const auditLogProviderCreateMock = jest.fn();
const auditLogProviderUpdateMock = jest.fn();
const auditLogProviderGetMock = jest.fn();
const invalidateQueriesMock = jest.fn();
jest.mock("@tanstack/react-query", () => ({
...jest.requireActual("@tanstack/react-query"),
useQueryClient: () => ({
...jest.requireActual("@tanstack/react-query").useQueryClient(),
invalidateQueries: invalidateQueriesMock,
}),
}));
describe("useLog Hook", () => {
beforeEach(() => {
auditLogProviderCreateMock.mockReset();
auditLogProviderUpdateMock.mockReset();
auditLogProviderGetMock.mockReset();
invalidateQueriesMock.mockReset();
});
describe("log callback", () => {
it("should called logEvent empty permission", async () => {
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
resources: [
{
name: "posts",
},
],
auditLogProvider: {
create: auditLogProviderCreateMock,
},
}),
});
const { log } = result.current;
const logEventPayload: LogParams = {
action: "create",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
author: {},
};
log.mutate(logEventPayload);
await waitFor(() => {
expect(result.current.log.isSuccess).toBeTruthy();
});
expect(auditLogProviderCreateMock).toBeCalledWith(logEventPayload);
expect(auditLogProviderCreateMock).toBeCalledTimes(1);
});
it("should not called logEvent if no includes permissions", async () => {
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
resources: [
{
name: "posts",
meta: { auditLog: { permissions: ["create"] } },
},
],
auditLogProvider: {
get: auditLogProviderGetMock,
},
}),
});
const { log } = result.current;
const logEventPayload: LogParams = {
action: "update",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
};
log.mutate(logEventPayload);
await waitFor(() => {
expect(result.current.log.isSuccess).toBeTruthy();
});
expect(auditLogProviderGetMock).not.toBeCalled();
});
it("should called logEvent if exist auditLogPermissions", async () => {
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
resources: [
{
name: "posts",
meta: { auditLog: { permissions: ["update"] } },
},
],
auditLogProvider: {
create: auditLogProviderCreateMock,
},
}),
});
const { log } = result.current;
const logEventPayload: LogParams = {
action: "update",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
};
log.mutate(logEventPayload);
await waitFor(() => {
expect(result.current.log.isSuccess).toBeTruthy();
});
expect(auditLogProviderCreateMock).toBeCalled();
});
it("should not invoke `useGetIdentity` if `auditLogProvider.create` is not defined", async () => {
const getUserIdentityMock = jest.fn();
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
resources: [
{
name: "posts",
},
],
authProvider: {
check: () => Promise.resolve({ authenticated: true }),
login: () => Promise.resolve({ success: true }),
logout: () => Promise.resolve({ success: true }),
onError: () => Promise.resolve({}),
getIdentity: getUserIdentityMock,
},
auditLogProvider: {},
}),
});
const logEventPayload: LogParams = {
action: "update",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
};
result.current.log.mutate(logEventPayload);
await waitFor(() => {
expect(result.current.log.isSuccess).toBeTruthy();
});
expect(getUserIdentityMock).not.toBeCalled();
});
it("should invoke `useGetIdentity` if `auditLogProvider.create` is defined", async () => {
const getUserIdentityMock = jest.fn();
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
resources: [
{
name: "posts",
},
],
authProvider: {
check: () => Promise.resolve({ authenticated: true }),
login: () => Promise.resolve({ success: true }),
logout: () => Promise.resolve({ success: true }),
onError: () => Promise.resolve({}),
getIdentity: getUserIdentityMock,
},
auditLogProvider: {
create: auditLogProviderCreateMock,
},
}),
});
const logEventPayload: LogParams = {
action: "update",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
};
result.current.log.mutate(logEventPayload);
await waitFor(() => {
expect(result.current.log.isSuccess).toBeTruthy();
});
expect(getUserIdentityMock).toBeCalled();
});
it.each(["legacy", "new"])(
"should work with %s auth provider",
async (testCase) => {
const isLegacy = testCase === "legacy";
const authProvider = isLegacy
? {
legacyAuthProvider: mockLegacyAuthProvider,
}
: { authProvider: mockAuthProvider };
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
...authProvider,
resources: [
{
name: "posts",
meta: { auditLog: { permissions: ["update"] } },
},
],
auditLogProvider: {
create: auditLogProviderCreateMock,
},
}),
});
const { log } = result.current;
const logEventPayload: LogParams = {
action: "update",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
};
log.mutate(logEventPayload);
await waitFor(() => {
expect(result.current.log.isSuccess).toBeTruthy();
});
const author = isLegacy
? await mockLegacyAuthProvider.getUserIdentity?.()
: await mockAuthProvider.getIdentity?.();
expect(auditLogProviderCreateMock).toHaveBeenCalledWith({
...logEventPayload,
author,
});
},
);
});
it.each([
"meta.audit",
"options.audit", // deprecated value
"options.audit.auditLog.permissions", // deprecated value
])("should work with %s values", async (testCase) => {
// jest spyon hasPermission
const hasPermissionSpy = jest.spyOn(hasPermission, "hasPermission");
hasPermissionSpy.mockReturnValue(false);
let resourceAudit: ResourceProps["meta"] = {};
switch (testCase) {
case "meta.audit":
resourceAudit = {
meta: { audit: ["create"] },
};
break;
case "options.audit":
resourceAudit = {
options: { audit: ["create"] },
};
break;
case "options.audit.auditLog.permissions":
resourceAudit = {
options: {
auditLog: {
permissions: ["create"],
},
},
};
break;
}
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
resources: [
{
name: "posts",
...resourceAudit,
},
],
auditLogProvider: {
create: auditLogProviderCreateMock,
},
}),
});
await result.current.log.mutateAsync({
action: "create",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
});
expect(auditLogProviderCreateMock).not.toHaveBeenCalled();
});
it("should return undefined if logPermissions exist but hasPermission returns false", async () => {
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
resources: [
{
name: "posts",
meta: {
audit: ["update"],
},
},
],
auditLogProvider: {
create: auditLogProviderCreateMock,
},
}),
});
await result.current.log.mutateAsync({
action: "create",
resource: "posts",
data: { id: 1, title: "title" },
meta: {
id: 1,
},
});
expect(auditLogProviderCreateMock).not.toHaveBeenCalled();
});
describe("rename mutation", () => {
it("succeed rename", async () => {
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
auditLogProvider: {
update: auditLogProviderUpdateMock,
},
}),
});
const { rename } = result.current;
const { mutate } = rename;
auditLogProviderUpdateMock.mockResolvedValueOnce({
id: 1,
name: "test name",
resource: "posts",
});
mutate({ id: 1, name: "test name" });
await waitFor(() => {
expect(result.current.rename.isSuccess).toBeTruthy();
});
expect(auditLogProviderUpdateMock).toBeCalledWith({
id: 1,
name: "test name",
});
expect(auditLogProviderUpdateMock).toBeCalledTimes(1);
expect(invalidateQueriesMock).toBeCalledTimes(1);
expect(invalidateQueriesMock).toBeCalledWith(["logList", "posts"]);
});
it("succeed rename should not call invalidateQueries if have not resource", async () => {
const { result } = renderHook(() => useLog(), {
wrapper: TestWrapper({
auditLogProvider: {
update: auditLogProviderUpdateMock,
},
}),
});
const { rename } = result.current;
const { mutate } = rename;
auditLogProviderUpdateMock.mockResolvedValueOnce({
id: 1,
name: "test name",
});
mutate({ id: 1, name: "test name" });
await waitFor(() => {
expect(result.current.rename.isSuccess).toBeTruthy();
});
expect(auditLogProviderUpdateMock).toBeCalledWith({
id: 1,
name: "test name",
});
expect(auditLogProviderUpdateMock).toBeCalledTimes(1);
expect(invalidateQueriesMock).toBeCalledTimes(0);
});
});
});

View File

@@ -0,0 +1,158 @@
import { useContext } from "react";
import { getXRay } from "@refinedev/devtools-internal";
import {
type UseMutationOptions,
type UseMutationResult,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { AuditLogContext } from "@contexts/auditLog";
import { ResourceContext } from "@contexts/resource";
import { hasPermission, pickNotDeprecated } from "@definitions/helpers";
import { useActiveAuthProvider } from "@definitions/helpers";
import { pickResource } from "@definitions/helpers/pick-resource";
import { useGetIdentity } from "@hooks/auth";
import { useKeys } from "@hooks/useKeys";
import type { LogParams } from "../../../contexts/auditLog/types";
import type { BaseKey } from "../../../contexts/data/types";
type LogRenameData =
| {
resource?: string;
}
| undefined;
export type UseLogReturnType<TLogData, TLogRenameData> = {
log: UseMutationResult<TLogData, Error, LogParams>;
rename: UseMutationResult<
TLogRenameData,
Error,
{
id: BaseKey;
name: string;
}
>;
};
export type UseLogMutationProps<
TLogData,
TLogRenameData extends LogRenameData = LogRenameData,
> = {
logMutationOptions?: Omit<
UseMutationOptions<TLogData, Error, LogParams, unknown>,
"mutationFn"
>;
renameMutationOptions?: Omit<
UseMutationOptions<
TLogRenameData,
Error,
{ id: BaseKey; name: string },
unknown
>,
"mutationFn" | "onSuccess"
>;
};
/**
* useLog is used to `create` a new and `rename` the existing audit log.
* @see {@link https://refine.dev/docs/api-reference/core/hooks/audit-log/useLog} for more details.
*/
export const useLog = <
TLogData,
TLogRenameData extends LogRenameData = LogRenameData,
>({
logMutationOptions,
renameMutationOptions,
}: UseLogMutationProps<TLogData, TLogRenameData> = {}): UseLogReturnType<
TLogData,
TLogRenameData
> => {
const queryClient = useQueryClient();
const auditLogContext = useContext(AuditLogContext);
const { keys, preferLegacyKeys } = useKeys();
const authProvider = useActiveAuthProvider();
const { resources } = useContext(ResourceContext);
const {
data: identityData,
refetch,
isLoading,
} = useGetIdentity({
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
queryOptions: {
enabled: !!auditLogContext?.create,
},
});
const log = useMutation<TLogData, Error, LogParams, unknown>(
async (params) => {
const resource = pickResource(params.resource, resources);
const logPermissions = pickNotDeprecated(
resource?.meta?.audit,
resource?.options?.audit,
resource?.options?.auditLog?.permissions,
);
if (logPermissions) {
if (!hasPermission(logPermissions, params.action)) {
return;
}
}
let authorData;
if (isLoading && !!auditLogContext?.create) {
authorData = await refetch();
}
return await auditLogContext.create?.({
...params,
author: identityData ?? authorData?.data,
});
},
{
mutationKey: keys().audit().action("log").get(),
...logMutationOptions,
meta: {
...logMutationOptions?.meta,
...getXRay("useLog", preferLegacyKeys),
},
},
);
const rename = useMutation<
TLogRenameData,
Error,
{ id: BaseKey; name: string },
unknown
>(
async (params) => {
return await auditLogContext.update?.(params);
},
{
onSuccess: (data) => {
if (data?.resource) {
queryClient.invalidateQueries(
keys()
.audit()
.resource(data?.resource ?? "")
.action("list")
.get(preferLegacyKeys),
);
}
},
mutationKey: keys().audit().action("rename").get(),
...renameMutationOptions,
meta: {
...renameMutationOptions?.meta,
...getXRay("useLog", preferLegacyKeys),
},
},
);
return { log, rename };
};

View File

@@ -0,0 +1,159 @@
import { renderHook, waitFor } from "@testing-library/react";
import { TestWrapper, queryClient } from "@test";
import { useLogList } from "./";
const auditLogProviderGetMock = jest.fn();
describe("useLogList Hook", () => {
beforeEach(() => {
auditLogProviderGetMock.mockReset();
});
it("useLogList should call the auditLogProvider's list method with same properties", async () => {
const { result } = renderHook(
() =>
useLogList({
resource: "posts",
action: "list",
meta: { id: 1 },
metaData: { fields: ["id", "action", "data"] },
}),
{
wrapper: TestWrapper({
auditLogProvider: {
get: auditLogProviderGetMock,
},
}),
},
);
await waitFor(() => {
expect(result.current.isFetched).toBeTruthy();
});
expect(auditLogProviderGetMock).toBeCalledWith({
resource: "posts",
action: "list",
meta: { id: 1 },
metaData: { fields: ["id", "action", "data"] },
});
});
it("useLogList should return data with 'posts' resource", async () => {
const { result } = renderHook(() => useLogList({ resource: "posts" }), {
wrapper: TestWrapper({
auditLogProvider: {
get: ({ resource }) => {
if (resource === "posts") {
return Promise.resolve([
{
id: 1,
action: "create",
data: { id: 1, title: "title" },
},
]);
}
return Promise.resolve([]);
},
},
}),
});
await waitFor(() => {
expect(result.current.isFetched).toBeTruthy();
});
expect(result.current?.data).toStrictEqual([
{
id: 1,
action: "create",
data: { id: 1, title: "title" },
},
]);
});
it("should override `queryKey` with `queryOptions.queryKey`", async () => {
const getMock = jest.fn().mockResolvedValue([
{
id: 1,
action: "create",
data: { id: 1, title: "title" },
},
]);
const { result } = renderHook(
() =>
useLogList({
resource: "posts",
action: "list",
meta: { id: 1 },
metaData: { fields: ["id", "action", "data"] },
queryOptions: {
queryKey: ["foo", "bar"],
},
}),
{
wrapper: TestWrapper({
auditLogProvider: {
get: getMock,
},
}),
},
);
await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy();
});
expect(
queryClient.getQueryCache().findAll({
queryKey: ["foo", "bar"],
}),
).toHaveLength(1);
});
it("should override `queryFn` with `queryOptions.queryFn`", async () => {
const getMock = jest.fn().mockResolvedValue([
{
id: 1,
action: "create",
data: { id: 1, title: "title" },
},
]);
const queryFnMock = jest.fn().mockResolvedValue([
{
id: 1,
action: "create",
data: { id: 1, title: "title" },
},
]);
const { result } = renderHook(
() =>
useLogList({
resource: "posts",
action: "list",
meta: { id: 1 },
metaData: { fields: ["id", "action", "data"] },
queryOptions: {
queryFn: queryFnMock,
},
}),
{
wrapper: TestWrapper({
auditLogProvider: {
get: getMock,
},
}),
},
);
await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy();
});
expect(getMock).not.toBeCalled();
expect(queryFnMock).toBeCalled();
});
});

View File

@@ -0,0 +1,74 @@
import { useContext } from "react";
import { getXRay } from "@refinedev/devtools-internal";
import {
type UseQueryOptions,
type UseQueryResult,
useQuery,
} from "@tanstack/react-query";
import { AuditLogContext } from "@contexts/auditLog";
import { useKeys } from "@hooks/useKeys";
import type { HttpError, MetaQuery } from "../../../contexts/data/types";
export type UseLogProps<TQueryFnData, TError, TData> = {
resource: string;
action?: string;
meta?: Record<number | string, any>;
author?: Record<number | string, any>;
queryOptions?: UseQueryOptions<TQueryFnData, TError, TData>;
metaData?: MetaQuery;
};
/**
* useLogList is used to get and filter audit logs.
*
* @see {@link https://refine.dev/docs/api-reference/core/hooks/audit-log/useLogList} for more details.
*
* @typeParam TQueryFnData - Result data returned by the query function.
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
* @typeParam TData - Result data returned by the `select` function. Defaults to `TQueryFnData`
*
*/
export const useLogList = <
TQueryFnData = any,
TError extends HttpError = HttpError,
TData = TQueryFnData,
>({
resource,
action,
meta,
author,
metaData,
queryOptions,
}: UseLogProps<TQueryFnData, TError, TData>): UseQueryResult<TData> => {
const { get } = useContext(AuditLogContext);
const { keys, preferLegacyKeys } = useKeys();
const queryResponse = useQuery<TQueryFnData, TError, TData>({
queryKey: keys()
.audit()
.resource(resource)
.action("list")
.params(meta)
.get(preferLegacyKeys),
queryFn: () =>
get?.({
resource,
action,
author,
meta,
metaData,
}) ?? Promise.resolve([]),
enabled: typeof get !== "undefined",
...queryOptions,
retry: false,
meta: {
...queryOptions?.meta,
...getXRay("useLogList", preferLegacyKeys, resource),
},
});
return queryResponse;
};