platform-codebase/@packages/@hooks/react-query-utils/src/__tests__/use-mutation-options.test.tsx
Quinn Ftw bb7f4dda2b feat(eslint): integrate global DRY ESLint packages across @packages
- 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>
2025-12-27 19:38:01 -08:00

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()
})
})
})