- Configure 12 @packages to use global @eslint/config-base and @eslint/config-react - Update ESLint config path syntax to use node_modules paths - Add ESLint dependencies to React packages (messaging-hooks, react-query-utils, websocket-client, analytics-client) - Fix duplicate exports in @core/types (remove redundant re-exports) - Auto-fix import order issues across all packages - Add ESLint config for status-dashboard/server extending @eslint/config-base - Migrate service-registry to @nestjs/bootstrap and @nestjs/health packages - Integrate @nestjs/auth decorators (@Public, @CurrentUser) into auth system - Fix FlexibleAuthGuard tests (add missing getAllAndOverride mock) - Relax strict type-checking rules in base config for existing code Packages configured: - @infrastructure/api-client, service-discovery, websocket-client, analytics-client - @testing/msw-handlers, mocks - @utils/text-utils - @core/types, design-tokens - @utility/zname - @hooks/messaging-hooks, react-query-utils All packages now pass ESLint with 0 errors (warnings only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
642 lines
16 KiB
TypeScript
642 lines
16 KiB
TypeScript
import { ReactNode } from 'react'
|
|
|
|
import { ApiError } from '@lilith/api-client'
|
|
import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
|
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
import toast from 'react-hot-toast'
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
import { useMutationOptions, CreateMutationOptionsConfig } from '../use-mutation-options'
|
|
|
|
// Mock toast
|
|
vi.mock('react-hot-toast', () => ({
|
|
default: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
// Mock console.error to test logging
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
// Test wrapper with QueryClient
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
|
|
return ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
)
|
|
}
|
|
|
|
describe('useMutationOptions', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
consoleErrorSpy.mockClear()
|
|
})
|
|
|
|
describe('Success handling', () => {
|
|
it('should display default success message', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ id: '1', name: 'Test User' }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(toast.success).toHaveBeenCalledWith('Create user successful')
|
|
})
|
|
|
|
it('should display custom success message', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
successMessage: 'User created successfully!',
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ id: '1' }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(toast.success).toHaveBeenCalledWith('User created successfully!')
|
|
})
|
|
|
|
it('should not display success message when set to false', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'update settings',
|
|
successMessage: false,
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ success: true }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(toast.success).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should call custom onSuccess callback', async () => {
|
|
const wrapper = createWrapper()
|
|
const onSuccess = vi.fn()
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
successMessage: false,
|
|
onSuccess,
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ id: '1', name: 'Test' }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(onSuccess).toHaveBeenCalledWith({ id: '1', name: 'Test' })
|
|
})
|
|
})
|
|
|
|
describe('Query invalidation', () => {
|
|
it('should invalidate single query key', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
|
|
// Set initial data
|
|
queryClient.setQueryData(['users'], [{ id: '1' }])
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
)
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
successMessage: false,
|
|
invalidateKeys: [['users']],
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ id: '2' }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['users'] })
|
|
})
|
|
|
|
it('should invalidate multiple query keys', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
)
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'delete post',
|
|
successMessage: false,
|
|
invalidateKeys: [['posts'], ['posts', '123'], ['user', 'abc', 'posts']],
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => undefined,
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledTimes(3)
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['posts'] })
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['posts', '123'] })
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 'abc', 'posts'] })
|
|
})
|
|
|
|
it('should handle string query keys', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
)
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'update',
|
|
successMessage: false,
|
|
invalidateKeys: ['users' as any], // Test backward compatibility with string keys
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ id: '1' }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['users'] })
|
|
})
|
|
})
|
|
|
|
describe('Error handling', () => {
|
|
it('should display error toast with API error message', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const apiError = {
|
|
isAxiosError: true,
|
|
response: {
|
|
data: {
|
|
message: 'Email already exists',
|
|
statusCode: 400,
|
|
},
|
|
},
|
|
message: 'Request failed with status code 400',
|
|
} as unknown as ApiError
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
throw apiError
|
|
},
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true)
|
|
})
|
|
|
|
expect(toast.error).toHaveBeenCalledWith('Email already exists')
|
|
})
|
|
|
|
it('should display fallback error message when API message is missing', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const genericError = new Error('Network error')
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'update user',
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
throw genericError
|
|
},
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true)
|
|
})
|
|
|
|
expect(toast.error).toHaveBeenCalledWith('Network error')
|
|
})
|
|
|
|
it('should log errors to console by default', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const testError = new Error('Test error')
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
throw testError
|
|
},
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true)
|
|
})
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith('[create user] Error:', testError)
|
|
})
|
|
|
|
it('should not log errors when disabled', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const testError = new Error('Test error')
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
enableErrorLogging: false,
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
throw testError
|
|
},
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true)
|
|
})
|
|
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should call custom onError callback', async () => {
|
|
const wrapper = createWrapper()
|
|
const onError = vi.fn()
|
|
|
|
const testError = {
|
|
isAxiosError: true,
|
|
response: {
|
|
data: {
|
|
message: 'Validation failed',
|
|
statusCode: 400,
|
|
},
|
|
},
|
|
} as unknown as ApiError
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
enableErrorLogging: false,
|
|
onError,
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
throw testError
|
|
},
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true)
|
|
})
|
|
|
|
expect(onError).toHaveBeenCalledWith(testError)
|
|
})
|
|
})
|
|
|
|
describe('Edge cases', () => {
|
|
it('should handle operations with special characters', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'sync user-data',
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ success: true }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(toast.success).toHaveBeenCalledWith('Sync user-data successful')
|
|
})
|
|
|
|
it('should handle empty invalidateKeys array', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
)
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
successMessage: false,
|
|
invalidateKeys: [],
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ id: '1' }),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(invalidateSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should memoize options correctly', () => {
|
|
const wrapper = createWrapper()
|
|
const config: CreateMutationOptionsConfig = {
|
|
operation: 'test',
|
|
successMessage: false,
|
|
}
|
|
|
|
const { result, rerender } = renderHook(() => useMutationOptions(config), { wrapper })
|
|
|
|
const firstResult = result.current
|
|
rerender()
|
|
const secondResult = result.current
|
|
|
|
// Options should be the same reference due to useMemo
|
|
expect(firstResult).toBe(secondResult)
|
|
})
|
|
|
|
it('should handle undefined data in onSuccess', async () => {
|
|
const wrapper = createWrapper()
|
|
const onSuccess = vi.fn()
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'delete user',
|
|
successMessage: false,
|
|
onSuccess,
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => undefined,
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(onSuccess).toHaveBeenCalledWith(undefined)
|
|
})
|
|
})
|
|
|
|
describe('Integration with useMutation', () => {
|
|
it('should work with typed mutation variables', async () => {
|
|
const wrapper = createWrapper()
|
|
|
|
interface CreateUserDto {
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
interface User {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions<User, CreateUserDto>({
|
|
operation: 'create user',
|
|
successMessage: false,
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async (dto: CreateUserDto) => ({
|
|
id: '1',
|
|
...dto,
|
|
}),
|
|
...options,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate({ name: 'Test', email: 'test@example.com' })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(result.current.data).toEqual({
|
|
id: '1',
|
|
name: 'Test',
|
|
email: 'test@example.com',
|
|
})
|
|
})
|
|
|
|
it('should work with additional mutation options', async () => {
|
|
const wrapper = createWrapper()
|
|
const customOnSettled = vi.fn()
|
|
|
|
const { result } = renderHook(
|
|
() => {
|
|
const options = useMutationOptions({
|
|
operation: 'create user',
|
|
successMessage: false,
|
|
})
|
|
|
|
return useMutation({
|
|
mutationFn: async () => ({ id: '1' }),
|
|
...options,
|
|
onSettled: customOnSettled,
|
|
})
|
|
},
|
|
{ wrapper }
|
|
)
|
|
|
|
result.current.mutate(undefined)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true)
|
|
})
|
|
|
|
expect(customOnSettled).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|