import { when } from 'mobx'; import { MutationApi } from '../mutation.api'; import { QueryClientStore } from '../query-client.store'; import { getTypeName } from '../client-helpers'; describe('MutationApi', () => { let mutationApi: MutationApi; let mockClientStore: QueryClientStore; beforeEach(() => { mockClientStore = new QueryClientStore(); const optionsFn = () => ({ mutationFn: (variables: string) => Promise.resolve(`Result: ${variables}`), }); mutationApi = new MutationApi(optionsFn); }); afterEach(() => { mutationApi.dispose(); mockClientStore.dispose(); window.queryClients?.clear(); jest.restoreAllMocks(); }); describe('constructor', () => { it('should create an instance with default values', () => { expect(mutationApi).toBeDefined(); expect(mutationApi.isPending).toBe(false); expect(mutationApi.isSuccess).toBe(false); expect(mutationApi.isError).toBe(false); expect(mutationApi.error).toBeNull(); }); it('should create an instance with options function', () => { const optionsFn = () => ({ mutationFn: (data: any) => Promise.resolve(data), }); const api = new MutationApi(optionsFn); expect(api).toBeDefined(); api.dispose(); }); }); describe('setup', () => { it('should initialize with client store and enable mutation functionality', async () => { mutationApi.setup(mockClientStore); expect(mutationApi).toBeDefined(); expect(mutationApi.clientStore).toBe(mockClientStore); expect(mutationApi.runMutation).toBeDefined(); // Test that the client store is working by executing a mutation const result = await mutationApi.runMutation('test-data'); expect(result).toBe('Result: test-data'); }); it('should handle setup without errors', () => { expect(() => { mutationApi.setup(mockClientStore); }).not.toThrow(); }); it('should have undefined clientStore when no globalClient and no setup', () => { const apiWithoutOptions = new MutationApi(() => ({ mutationFn: (data: string) => Promise.resolve(`Result: ${data}`), })); // Should be undefined before setup expect(apiWithoutOptions.clientStore).toBeUndefined(); // Don't call setup() since it would fail without a client store apiWithoutOptions.dispose(); }); }); describe('dispose', () => { it('should dispose without errors when not initialized', () => { expect(mutationApi.clientStore).toBeUndefined(); expect(() => mutationApi.dispose()).not.toThrow(); }); it('should dispose properly when initialized', () => { mutationApi.setup(mockClientStore); expect(mutationApi.clientStore).toBe(mockClientStore); expect(() => mutationApi.dispose()).not.toThrow(); // clientStore should still be there after dispose (we don't clear it, just dispose observers) expect(mutationApi.clientStore).toBe(mockClientStore); }); }); describe('runMutation', () => { beforeEach(() => { mutationApi.setup(mockClientStore); }); it('should execute mutation successfully', async () => { const variables = 'test input'; const result = await mutationApi.runMutation(variables); expect(result).toBe(`Result: ${variables}`); }); it('should handle mutation with options', async () => { const variables = 'test input'; const onSuccess = jest.fn(); const options = { onSuccess }; await mutationApi.runMutation(variables, options); expect(onSuccess).toHaveBeenCalled(); }); it('should handle mutation errors', async () => { const errorApi = new MutationApi(() => ({ mutationFn: () => Promise.reject(new Error('Test error')), throwOnError: true, })); errorApi.setup(mockClientStore); try { await errorApi.runMutation('test'); fail('Expected mutation to throw'); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe('Test error'); } errorApi.dispose(); }); it('should handle errors without throwing when throwOnError is false', async () => { const errorApi = new MutationApi(() => ({ mutationFn: () => Promise.reject(new Error('Test error')), throwOnError: false, })); errorApi.setup(mockClientStore); const result = await errorApi.runMutation('test'); expect(result).toBeUndefined(); errorApi.dispose(); }); it('should merge runMutation options with original mutation options', async () => { // Create mock functions to track original and runtime options const originalOnSuccessMock = jest.fn(); const runtimeOnSuccessMock = jest.fn(); const runtimeOnErrorMock = jest.fn(); // Create a successful mutation with original onSuccess callback const testApiService = { mutateFn: jest.fn().mockResolvedValue('success result'), }; const optionsMergeApi = new MutationApi(() => ({ mutationFn: (data: string) => testApiService.mutateFn(data), onSuccess: originalOnSuccessMock, })); optionsMergeApi.setup(mockClientStore); // Run mutation with additional runtime options const result = await optionsMergeApi.runMutation('test data', { onSuccess: runtimeOnSuccessMock, onError: runtimeOnErrorMock, }); expect(result).toBe('success result'); // Wait a bit for callbacks to be processed await new Promise(resolve => setTimeout(resolve, 10)); // Verify both original and runtime onSuccess callbacks were called expect(originalOnSuccessMock).toHaveBeenCalledWith( 'success result', 'test data', undefined ); expect(runtimeOnSuccessMock).toHaveBeenCalledWith( 'success result', 'test data', undefined ); // Verify runtime onError was NOT called since mutation succeeded expect(runtimeOnErrorMock).not.toHaveBeenCalled(); optionsMergeApi.dispose(); }); it('should merge runMutation error options with original mutation options', async () => { // Create mock functions to track original and runtime error options const originalOnErrorMock = jest.fn(); const runtimeOnErrorMock = jest.fn(); const runtimeOnSuccessMock = jest.fn(); // Create a failing mutation with original onError callback const testError = new Error('Test mutation failure'); const testApiService = { mutateFn: jest.fn().mockRejectedValue(testError), }; const errorMergeApi = new MutationApi(() => ({ mutationFn: (data: string) => testApiService.mutateFn(data), onError: originalOnErrorMock, throwOnError: false, // Don't throw so we can test callbacks })); errorMergeApi.setup(mockClientStore); // Run mutation with additional runtime options const result = await errorMergeApi.runMutation('test data', { onError: runtimeOnErrorMock, onSuccess: runtimeOnSuccessMock, }); expect(result).toBeUndefined(); // Should return undefined when error and not throwing // Wait a bit for callbacks to be processed await new Promise(resolve => setTimeout(resolve, 10)); // Verify both original and runtime onError callbacks were called expect(originalOnErrorMock).toHaveBeenCalledWith(testError, 'test data', undefined); expect(runtimeOnErrorMock).toHaveBeenCalledWith(testError, 'test data', undefined); // Verify runtime onSuccess was NOT called since mutation failed expect(runtimeOnSuccessMock).not.toHaveBeenCalled(); errorMergeApi.dispose(); }); it('should handle mutation errors and update state', async () => { // Create a mutation that will fail const errorMessage = 'Test mutation error'; const testApiService = { mutateFn: jest.fn().mockRejectedValue(new Error(errorMessage)), }; const errorApi = new MutationApi(() => ({ mutationFn: (data: string) => testApiService.mutateFn(data), throwOnError: false, // Don't throw so we can test state })); errorApi.setup(mockClientStore); // Run mutation and let it fail silently await errorApi.runMutation('test data'); // Wait a bit for state to update await new Promise(resolve => setTimeout(resolve, 10)); expect(errorApi.isError).toBe(true); expect(errorApi.isSuccess).toBe(false); expect(errorApi.error).toBeDefined(); expect(errorApi.error?.message).toBe(errorMessage); errorApi.dispose(); }); it('should call onError callback when provided', async () => { // Create an onError mock function const onErrorMock = jest.fn(); // Create a mutation that will fail with onError callback const errorMessage = 'Test mutation onError callback'; const testApiService = { mutateFn: jest.fn().mockRejectedValue(new Error(errorMessage)), }; const errorCallbackApi = new MutationApi(() => ({ mutationFn: (data: string) => testApiService.mutateFn(data), onError: onErrorMock, throwOnError: false, // Don't throw so we can test callback })); errorCallbackApi.setup(mockClientStore); // Run mutation await errorCallbackApi.runMutation('test data'); // Wait a bit for callback to be processed await new Promise(resolve => setTimeout(resolve, 10)); expect(errorCallbackApi.isError).toBe(true); expect(errorCallbackApi.isSuccess).toBe(false); expect(errorCallbackApi.error).toBeDefined(); expect(errorCallbackApi.error?.message).toBe(errorMessage); // Verify onError was called with the error, variables, and context expect(onErrorMock).toHaveBeenCalledWith( errorCallbackApi.error, 'test data', undefined ); errorCallbackApi.dispose(); }); it('should throw error when throwOnError is true', async () => { // Create a mutation that will fail with throwOnError enabled const errorMessage = 'Test mutation throwOnError'; const testApiService = { mutateFn: jest.fn().mockRejectedValue(new Error(errorMessage)), }; const throwOnErrorApi = new MutationApi(() => ({ mutationFn: (data: string) => testApiService.mutateFn(data), throwOnError: true, })); throwOnErrorApi.setup(mockClientStore); // Try to mutate and expect it to throw try { await throwOnErrorApi.runMutation('test data'); // If we reach here, the mutation didn't throw as expected expect(true).toBe(false); } catch (error) { // This is expected - the mutation should throw when throwOnError is true expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe(errorMessage); } // Wait a bit for state to update await new Promise(resolve => setTimeout(resolve, 10)); expect(throwOnErrorApi.isError).toBe(true); expect(throwOnErrorApi.isSuccess).toBe(false); expect(throwOnErrorApi.error).toBeDefined(); expect(throwOnErrorApi.error?.message).toBe(errorMessage); throwOnErrorApi.dispose(); }); }); describe('observable properties', () => { beforeEach(() => { mutationApi.setup(mockClientStore); }); it('should update success state after successful mutation', async () => { await mutationApi.runMutation('test'); return new Promise(resolve => { when( () => mutationApi.isSuccess, () => { expect(mutationApi.isSuccess).toBe(true); expect(mutationApi.isError).toBe(false); resolve(); } ); }); }); it('should update error state after failed mutation', async () => { const errorApi = new MutationApi(() => ({ mutationFn: () => Promise.reject(new Error('Test error')), throwOnError: false, })); errorApi.setup(mockClientStore); await errorApi.runMutation('test'); return new Promise(resolve => { when( () => errorApi.isError, () => { expect(errorApi.isError).toBe(true); expect(errorApi.error).toBeInstanceOf(Error); resolve(); errorApi.dispose(); } ); }); }); }); describe('invalidation', () => { beforeEach(() => { mutationApi.setup(mockClientStore); }); it('should handle mutations with invalidated queries', async () => { const apiWithInvalidation = new MutationApi(() => ({ mutationFn: (data: string) => Promise.resolve(data), invalidatedQueries: [['query1'], ['query2']], })); apiWithInvalidation.setup(mockClientStore); const invalidateSpy = jest.spyOn(mockClientStore, 'invalidate'); await apiWithInvalidation.runMutation('test'); expect(invalidateSpy).toHaveBeenCalledWith([['query1'], ['query2']]); apiWithInvalidation.dispose(); }); }); describe('global client handling', () => { it('should create its own QueryClientStore when globalClient is configured', () => { const apiWithGlobalClient = new MutationApi(() => ({ mutationFn: (data: string) => Promise.resolve(data), globalClient: { type: 'app' }, })); /* * Pass the normal mockClientStore, but it should create its own instead * because globalClient check now happens AFTER options are set in setup() */ apiWithGlobalClient.setup(mockClientStore); // Should have created its own QueryClientStore, not use the passed one expect(apiWithGlobalClient.clientStore).toBeDefined(); expect(apiWithGlobalClient.clientStore).not.toBe(mockClientStore); expect(apiWithGlobalClient.clientStore).toBeInstanceOf(QueryClientStore); // And the global client options should be available in mutationOptions expect(apiWithGlobalClient.mutationOptions.globalClient).toEqual({ type: 'app' }); apiWithGlobalClient.dispose(); }); it('should use passed client store when no globalClient is configured', () => { const apiWithoutGlobalClient = new MutationApi(() => ({ mutationFn: (data: string) => Promise.resolve(data), // No globalClient configured })); // Setup with mockClientStore - should use it since no globalClient apiWithoutGlobalClient.setup(mockClientStore); // Should use the passed client store expect(apiWithoutGlobalClient.clientStore).toBeDefined(); expect(apiWithoutGlobalClient.clientStore).toBe(mockClientStore); apiWithoutGlobalClient.dispose(); }); it('should use injected client when isolateFromWindowClients is true', () => { mockClientStore.isolateFromWindowClients = true; const api = new MutationApi(() => ({ mutationFn: (data: string) => Promise.resolve(data), globalClient: { type: 'app' }, })); api.setup(mockClientStore); expect(api.clientStore).toBe(mockClientStore); expect(window.queryClients?.has(getTypeName('app'))).toBeFalsy(); api.dispose(); expect(() => mockClientStore.queryClient.getDefaultOptions()).not.toThrow(); }); }); });