import { QueryApi } from '../query.api'; import { QueryClientStore } from '../query-client.store'; import { waitFor } from '../../test/mock-adapters/container-builder'; import { getTypeName } from '../client-helpers'; describe('QueryApi', () => { let queryApi: QueryApi; let mockClientStore: QueryClientStore; beforeEach(() => { mockClientStore = new QueryClientStore(); queryApi = new QueryApi(); }); afterEach(() => { queryApi.dispose(); mockClientStore.dispose(); window.queryClients?.clear(); jest.restoreAllMocks(); }); describe('constructor', () => { it('should create an instance with default values', () => { expect(queryApi).toBeDefined(); expect(queryApi.initialized).toBe(false); expect(queryApi.isPending).toBe(false); expect(queryApi.isLoading).toBe(false); expect(queryApi.isFetching).toBe(false); expect(queryApi.isRefetching).toBe(false); expect(queryApi.isSuccess).toBe(false); expect(queryApi.isError).toBe(false); expect(queryApi.error).toBeNull(); expect(queryApi.data).toBeUndefined(); }); it('should create an instance with options function', () => { const optionsFn = () => ({ queryKey: ['test', 'query'], queryFn: () => Promise.resolve('test data'), }); const api = new QueryApi(optionsFn); expect(api).toBeDefined(); api.dispose(); }); }); describe('setup', () => { it('should initialize with client store', () => { queryApi.setup(mockClientStore); expect(queryApi.clientStore).toBe(mockClientStore); }); it('should handle setup without options', () => { expect(() => { queryApi.setup(mockClientStore); }).not.toThrow(); }); it('should handle setup with refresh stores', () => { const mockRefreshStore = { refreshStoresOnMount: jest.fn(), refreshOnMount: jest.fn(), }; const refreshStores = () => [mockRefreshStore]; queryApi.setup(mockClientStore, undefined, refreshStores); // Verify that the refresh stores were properly stored in the storesToRefreshOnMount property expect(queryApi.storesToRefreshOnMount).toBe(refreshStores); expect(queryApi.storesToRefreshOnMount?.()).toEqual([mockRefreshStore]); }); it('should leave storesToRefreshOnMount undefined when no refresh stores provided', () => { queryApi.setup(mockClientStore); // Verify that the storesToRefreshOnMount property remains undefined expect(queryApi.storesToRefreshOnMount).toBeUndefined(); }); it('should handle refresh stores function that returns empty array', () => { const emptyRefreshStores = () => []; queryApi.setup(mockClientStore, undefined, emptyRefreshStores); // Verify that the refresh stores function is still stored even if it returns empty array expect(queryApi.storesToRefreshOnMount).toBe(emptyRefreshStores); expect(queryApi.storesToRefreshOnMount?.()).toEqual([]); }); }); describe('dispose', () => { it('should dispose without errors when not initialized', () => { expect(() => queryApi.dispose()).not.toThrow(); }); it('should dispose properly when initialized', () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['test', 'query'], queryFn: () => Promise.resolve('test'), })); expect(() => queryApi.dispose()).not.toThrow(); }); it.each([ { queryKey: ['namespace', 'resource', 'extra-param'], expected: ['namespace', 'resource', 'extra-param'], }, { queryKey: ['namespace'], expected: ['namespace'] }, ])( 'should remove full query key on dispose when disposeQuery is true ($queryKey)', ({ queryKey, expected }) => { queryApi.setup(mockClientStore, () => ({ queryKey, queryFn: () => Promise.resolve('test'), disposeQuery: true, })); const spy = jest.spyOn(mockClientStore, 'remove'); queryApi.dispose(); expect(spy).toHaveBeenCalledWith(expected); } ); it.each([ { queryKey: ['namespace', 'resource', 'extra-param'], disposeQuery: 2, expected: ['namespace', 'resource'], }, { queryKey: ['namespace', 'resource', 'extra-param'], disposeQuery: 1, expected: ['namespace'], }, ])( 'should remove first $disposeQuery key segments on dispose when disposeQuery is a number', ({ queryKey, disposeQuery, expected }) => { queryApi.setup(mockClientStore, () => ({ queryKey, queryFn: () => Promise.resolve('test'), disposeQuery, })); const spy = jest.spyOn(mockClientStore, 'remove'); queryApi.dispose(); expect(spy).toHaveBeenCalledWith(expected); } ); it.each([false, undefined])( 'should not call remove when disposeQuery is %s', disposeQuery => { queryApi.setup(mockClientStore, () => ({ queryKey: ['namespace', 'resource'], queryFn: () => Promise.resolve('test'), ...(disposeQuery !== undefined && { disposeQuery }), })); const spy = jest.spyOn(mockClientStore, 'remove'); queryApi.dispose(); expect(spy).not.toHaveBeenCalled(); } ); }); describe('invalidate', () => { it('should invalidate queries when client store is available', () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['test', 'query'], queryFn: () => Promise.resolve('test'), })); const spy = jest.spyOn(mockClientStore, 'invalidate'); queryApi.invalidate(); expect(spy).toHaveBeenCalledWith(['test', 'query'], undefined); }); it('should handle invalidation without client store', () => { expect(() => { queryApi.invalidate(); }).not.toThrow(); }); it('should use debounced invalidation when debounce option is true', async () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['debounce', 'test'], queryFn: () => Promise.resolve('test'), debounceInvalidateTime: 50, })); await waitFor(() => queryApi.initialized); const spy = jest.spyOn(mockClientStore, 'invalidate'); // Call invalidate with debounce option queryApi.invalidate({ debounce: true }); queryApi.invalidate({ debounce: true }); queryApi.invalidate({ debounce: true }); // Should not be called immediately due to debouncing expect(spy).not.toHaveBeenCalled(); // Wait for debounce time await new Promise(resolve => setTimeout(resolve, 60)); // Should be called only once due to debouncing (deduped) expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(['debounce', 'test'], { debounce: true }); }); it('should use default debounce time of 100ms when not specified', async () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['default', 'debounce'], queryFn: () => Promise.resolve('test'), // debounceInvalidateTime not specified, should default to 100ms })); await waitFor(() => queryApi.initialized); const spy = jest.spyOn(mockClientStore, 'invalidate'); queryApi.invalidate({ debounce: true }); // Should not be called after 50ms await new Promise(resolve => setTimeout(resolve, 50)); expect(spy).not.toHaveBeenCalled(); // Should be called after default 100ms + buffer await new Promise(resolve => setTimeout(resolve, 60)); expect(spy).toHaveBeenCalledTimes(1); }); it('should call invalidate with dedupe option is true', async () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['dedupe', 'test'], queryFn: () => Promise.resolve('test'), })); await waitFor(() => queryApi.initialized); const spy = jest.spyOn(mockClientStore, 'invalidate'); queryApi.invalidate({ dedupe: true }); expect(spy).toHaveBeenCalledWith(['dedupe', 'test'], { dedupe: true }); }); it('should handle invalidationKey option correctly', async () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['main', 'test'], queryFn: () => Promise.resolve('test'), })); await waitFor(() => queryApi.initialized); const spy = jest.spyOn(mockClientStore, 'invalidate'); // Test with custom invalidationKey queryApi.invalidate({ invalidationKey: ['custom', 'key'] }); expect(spy).toHaveBeenCalledWith(['main', 'test'], { invalidationKey: ['custom', 'key'], }); }); it('should handle invalidation with all available options', async () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['all', 'options'], queryFn: () => Promise.resolve('test'), })); await waitFor(() => queryApi.initialized); const spy = jest.spyOn(mockClientStore, 'invalidate'); // Test with all available QueryInvalidationOptions const allOptions = { debounce: false, dedupe: true, invalidationKey: ['comprehensive', 'test', 'key'], }; queryApi.invalidate(allOptions); expect(spy).toHaveBeenCalledWith(['all', 'options'], allOptions); }); }); describe('cancel', () => { it('should cancel query when client store is available', () => { queryApi.setup(mockClientStore, () => ({ queryKey: ['cancel', 'test'], queryFn: () => Promise.resolve('test data'), })); const spy = jest.spyOn(mockClientStore, 'cancel'); queryApi.cancel(); expect(spy).toHaveBeenCalledWith(['cancel', 'test']); }); it('should handle cancel without client store', () => { expect(() => { queryApi.cancel(); }).not.toThrow(); }); }); describe('refreshStoresOnMount', () => { it('should call refreshOnMount through refreshStoresOnMount', () => { /* * The refreshStoresOnMount method calls forEach on dependent stores, not refreshOnMount directly * Let's test that it exists and can be called without errors */ expect(() => { queryApi.refreshStoresOnMount(); }).not.toThrow(); }); it('should call refresh on dependent stores', () => { const mockStore = { refreshStoresOnMount: jest.fn(), refreshOnMount: jest.fn(), }; queryApi.storesToRefreshOnMount = () => [mockStore]; queryApi.refreshStoresOnMount(); expect(mockStore.refreshOnMount).toHaveBeenCalled(); }); }); describe('refreshOnMount', () => { it('should handle refresh when not initialized', () => { expect(() => queryApi.refreshOnMount()).not.toThrow(); }); it('should call refreshOnMount on refresh stores and handle stale vs fresh data appropriately', async () => { // Create two QueryApi instances with different stale times const staleQueryApi = new QueryApi(); const freshQueryApi = new QueryApi(); // Setup stale query (staleTime: 0 means always stale) staleQueryApi.setup(mockClientStore, () => ({ queryKey: ['stale', 'test'], queryFn: () => Promise.resolve('stale-data'), staleTime: 0, // Always considered stale })); // Setup fresh query (staleTime: Infinity means never stale) freshQueryApi.setup(mockClientStore, () => ({ queryKey: ['fresh', 'test'], queryFn: () => Promise.resolve('fresh-data'), staleTime: Infinity, // Never considered stale })); // Wait for all queries to be initialized await waitFor(() => staleQueryApi.initialized && freshQueryApi.initialized); // Spy on invalidate methods to track calls const staleInvalidateSpy = jest.spyOn(staleQueryApi, 'invalidate'); const freshInvalidateSpy = jest.spyOn(freshQueryApi, 'invalidate'); // Setup main queryApi with refresh stores including the stale and fresh queries const refreshStores = () => [staleQueryApi, freshQueryApi]; queryApi.setup( mockClientStore, () => ({ queryKey: ['main', 'test'], queryFn: () => Promise.resolve('main-data'), }), refreshStores ); // Call refreshStoresOnMount to trigger the refresh behavior queryApi.refreshStoresOnMount(); // Verify that only the stale query called invalidate (fresh query should not) expect(staleInvalidateSpy).toHaveBeenCalled(); expect(freshInvalidateSpy).not.toHaveBeenCalled(); // Cleanup staleQueryApi.dispose(); freshQueryApi.dispose(); }); }); describe('observable properties', () => { it('should have observable isError property and update on query failure', async () => { // Mock console.error to avoid noisy test output const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(queryApi.isError).toBe(false); expect(queryApi.error).toBeNull(); const testError = new Error('Test error'); queryApi.setup(mockClientStore, () => ({ queryKey: ['test', 'query'], queryFn: () => Promise.reject(testError), retry: false, })); await waitFor(() => queryApi.initialized); expect(queryApi.error).toBeDefined(); expect(queryApi.error?.message).toBe('Test error'); expect(queryApi.isError).toBe(true); // Verify that console.error was called since no onError callback was provided expect(consoleErrorSpy).toHaveBeenCalledWith(queryApi.error); }); it('should maintain observable state transitions during query lifecycle', async () => { const testData = { test: 'value' }; expect(queryApi.isPending).toBe(false); queryApi.setup(mockClientStore, () => ({ queryKey: ['lifecycle', 'test'], queryFn: () => Promise.resolve(testData), })); // Initial state expect(queryApi.initialized).toBe(false); expect(queryApi.isPending).toBe(true); expect(queryApi.isFetching).toBe(true); expect(queryApi.isRefetching).toBe(false); expect(queryApi.isSuccess).toBe(false); expect(queryApi.isError).toBe(false); expect(queryApi.data).toBeUndefined(); expect(queryApi.error).toBeNull(); // Wait for completion await waitFor(() => queryApi.initialized); // Final success state expect(queryApi.initialized).toBe(true); expect(queryApi.isSuccess).toBe(true); expect(queryApi.isPending).toBe(false); expect(queryApi.isFetching).toBe(false); expect(queryApi.isRefetching).toBe(false); expect(queryApi.isError).toBe(false); expect(queryApi.data).toEqual(testData); expect(queryApi.error).toBeNull(); queryApi.refetch(); expect(queryApi.isPending).toBe(false); expect(queryApi.isFetching).toBe(true); expect(queryApi.isRefetching).toBe(true); await waitFor(() => !queryApi.isFetching); // Final refetch state expect(queryApi.isFetching).toBe(false); expect(queryApi.isRefetching).toBe(false); expect(queryApi.isSuccess).toBe(true); expect(queryApi.isPending).toBe(false); expect(queryApi.isError).toBe(false); expect(queryApi.data).toEqual(testData); expect(queryApi.error).toBeNull(); }); }); describe('global client handling', () => { it('should use injected client when isolateFromWindowClients is true', () => { mockClientStore.isolateFromWindowClients = true; const api = new QueryApi(() => ({ queryKey: ['test', 'isolated'], queryFn: () => Promise.resolve('ok'), globalClient: 'app', })); api.setup(mockClientStore); expect(api.clientStore).toBe(mockClientStore); expect(window.queryClients?.has(getTypeName('app'))).toBeFalsy(); api.dispose(); expect(() => mockClientStore.queryClient.getDefaultOptions()).not.toThrow(); }); it('should create a window client when globalClient is set and isolate is unset', () => { const api = new QueryApi(() => ({ queryKey: ['test', 'shared'], queryFn: () => Promise.resolve('ok'), globalClient: 'app', })); api.setup(mockClientStore); expect(api.clientStore).not.toBe(mockClientStore); expect(window.queryClients?.has(getTypeName('app'))).toBe(true); api.dispose(); }); }); });