import { QueryApiStore } from '../query-api.store'; import { QueryClientStore } from '../query-client.store'; import { ContainerBuilder, waitFor } from '../../test/mock-adapters/container-builder'; import type { QueryApiOptions } from '../query.api'; import { action, makeObservable, observable } from 'mobx'; // Helper service that can be spied on for testing const testApiService = { fetchData: (id?: string): Promise => { return Promise.resolve(`test data${id ? ` for ${id}` : ''}`); }, fetchNewQueryData: (): Promise => { return Promise.resolve('new query data'); }, }; // Create a concrete implementation of the abstract QueryApiStore for testing class TestQueryApiStore extends QueryApiStore { @observable id: string | undefined = undefined; @observable enabled: boolean = false; newQuery = this.addQuery( () => ({ queryKey: ['new', 'query'], queryFn: () => testApiService.fetchNewQueryData(), }), ['new', 'query'] ); newMutation = this.addMutation( () => ({ mutationFn: () => Promise.resolve('new mutation data'), }), ['new', 'mutation'] ); constructor() { super(); makeObservable(this); } get queryOptions(): QueryApiOptions { return { queryKey: this.id ? ['test', 'store', this.id] : ['test', 'store'], queryFn: () => testApiService.fetchData(this.id), enabled: this.enabled, }; } @action setId = (newId: string | undefined) => { this.id = newId; }; @action setEnabled = (newEnabled: boolean) => { this.enabled = newEnabled; }; } const initStore = () => { const { initialize, container } = new ContainerBuilder().add(TestQueryApiStore).build(); const mockClientStoreInit = container.get(QueryClientStore); const queryApiStoreInit = container.get(TestQueryApiStore); return { mockClientStoreInit, queryApiStoreInit, initialize, container }; }; describe('QueryApiStore', () => { let queryApiStore: TestQueryApiStore; let mockClientStore: QueryClientStore; beforeEach(async () => { const { initialize, mockClientStoreInit, queryApiStoreInit } = initStore(); // Set enabled to true before initializing queryApiStoreInit.setEnabled(true); await initialize(); mockClientStore = mockClientStoreInit; queryApiStore = queryApiStoreInit; }); afterEach(() => { queryApiStore?.dispose(); mockClientStore?.dispose(); jest.restoreAllMocks(); }); describe('constructor', () => { it('should create an instance', () => { expect(queryApiStore).toBeDefined(); expect(queryApiStore.query).toBeDefined(); }); }); describe('computed properties', () => { it('should have computed properties that reflect query state', async () => { const { initialize, queryApiStoreInit } = initStore(); queryApiStoreInit.setEnabled(true); expect(queryApiStoreInit.initialized).toBe(false); expect(queryApiStoreInit.isPending).toBe(false); expect(queryApiStoreInit.isLoading).toBe(false); expect(queryApiStoreInit.isFetching).toBe(false); expect(queryApiStoreInit.isRefetching).toBe(false); expect(queryApiStoreInit.isSuccess).toBe(false); expect(queryApiStoreInit.isError).toBe(false); initialize(); expect(queryApiStoreInit.initialized).toBe(false); expect(queryApiStoreInit.isPending).toBe(true); expect(queryApiStoreInit.isLoading).toBe(true); expect(queryApiStoreInit.isFetching).toBe(true); expect(queryApiStoreInit.isRefetching).toBe(false); expect(queryApiStoreInit.isSuccess).toBe(false); expect(queryApiStoreInit.isError).toBe(false); await waitFor(() => queryApiStoreInit.initialized); expect(queryApiStoreInit.initialized).toBe(true); expect(queryApiStoreInit.isPending).toBe(false); expect(queryApiStoreInit.isLoading).toBe(false); expect(queryApiStoreInit.isFetching).toBe(false); expect(queryApiStoreInit.isRefetching).toBe(false); expect(queryApiStoreInit.isSuccess).toBe(true); expect(queryApiStoreInit.isError).toBe(false); }); it('should return query data', () => { expect(queryApiStore.data).toBeDefined(); }); it('should return query error', () => { expect(queryApiStore.error).toBeDefined(); }); it('should have query methods available', () => { expect(typeof queryApiStore.refetch).toBe('function'); expect(typeof queryApiStore.invalidate).toBe('function'); expect(typeof queryApiStore.cancel).toBe('function'); }); }); describe('query management', () => { it('should add additional queries', async () => { const additionalQuery = queryApiStore.addQuery( () => ({ queryKey: ['test', 'additional'], queryFn: () => Promise.resolve('additional data'), }), ['test', 'additional'], true ); const additionalQuery2 = queryApiStore.addQuery( () => ({ queryKey: ['test', 'additional2'], queryFn: () => Promise.resolve('additional data 2'), }), undefined, true ); expect(additionalQuery).toBeDefined(); expect(additionalQuery2).toBeDefined(); expect(queryApiStore.newQuery).toBeDefined(); expect(queryApiStore.getQuery(['new', 'query'])).toBe(queryApiStore.newQuery); expect(queryApiStore.getQuery(['test', 'additional'])).toBe(additionalQuery); expect(queryApiStore.getQuery(['additional', '2'])).toBeUndefined(); expect(queryApiStore.newQuery.data).toBe('new query data'); expect(additionalQuery.data).toBeUndefined(); expect(additionalQuery2.data).toBeUndefined(); await waitFor(() => additionalQuery.initialized && additionalQuery2.initialized); expect(additionalQuery.data).toBe('additional data'); expect(additionalQuery2.data).toBe('additional data 2'); }); it('should return existing query when added with same key', () => { const query1 = queryApiStore.addQuery( () => ({ queryKey: ['test', 'same'], queryFn: () => Promise.resolve('data1'), }), ['test', 'same'] ); const query2 = queryApiStore.addQuery( () => ({ queryKey: ['test', 'same'], queryFn: () => Promise.resolve('data2'), }), ['test', 'same'] ); expect(query1).toBe(query2); }); it('should respect observable enabled property', () => { // queryApiStore is already initialized and enabled, query should have run expect(queryApiStore.enabled).toBe(true); expect(queryApiStore.data).toBe('test data'); // Disable the query after initialization queryApiStore.setEnabled(false); expect(queryApiStore.queryOptions.enabled).toBe(false); // Enable the query again queryApiStore.setEnabled(true); expect(queryApiStore.queryOptions.enabled).toBe(true); }); it('should not auto-fetch when enabled is toggled to false after initialization', async () => { // queryApiStore is already initialized and has data expect(queryApiStore.data).toBe('test data'); // Change ID while enabled to get new data queryApiStore.setId('test-id-1'); await waitFor(() => queryApiStore.data === 'test data for test-id-1'); // Now disable the query queryApiStore.setEnabled(false); expect(queryApiStore.queryOptions.enabled).toBe(false); // Change ID again - this should NOT trigger a fetch because enabled is false const previousData = queryApiStore.data; queryApiStore.setId('test-id-2'); // Wait a bit and verify data didn't change (no fetch occurred) await new Promise(resolve => setTimeout(resolve, 100)); expect(queryApiStore.data).toBe(previousData); // Should still be the old data expect(queryApiStore.queryOptions.queryKey).toEqual(['test', 'store', 'test-id-2']); // But key should be updated }); it('should fetch when enabled changes from false to true after initialization', async () => { // queryApiStore is already initialized and has data expect(queryApiStore.data).toBe('test data'); // Disable and change ID queryApiStore.setEnabled(false); queryApiStore.setId('test-id-disabled'); // Verify no fetch happened while disabled await new Promise(resolve => setTimeout(resolve, 100)); expect(queryApiStore.data).toBe('test data'); // Should still be original data // Re-enable - this should trigger a fetch with the new ID queryApiStore.setEnabled(true); await waitFor(() => queryApiStore.data === 'test data for test-id-disabled'); expect(queryApiStore.data).toBe('test data for test-id-disabled'); }); it('should not fetch data when enabled is false from start', async () => { // Create a fresh store with enabled set to false (default) const { queryApiStoreInit, mockClientStoreInit } = initStore(); expect(queryApiStoreInit.enabled).toBe(false); expect(queryApiStoreInit.queryOptions.enabled).toBe(false); // Initialize without enabling await queryApiStoreInit.initialize(); expect(queryApiStoreInit.isPending).toBe(true); expect(queryApiStoreInit.isFetching).toBe(false); expect(queryApiStoreInit.data).toBeUndefined(); queryApiStoreInit.dispose(); mockClientStoreInit.dispose(); }); }); describe('mutation management', () => { it('should add mutations', () => { const mutation = queryApiStore.addMutation( () => ({ mutationFn: (data: string) => Promise.resolve(data), }), ['test', 'mutation'], true ); const mutation2 = queryApiStore.addMutation( () => ({ mutationFn: (data: string) => Promise.resolve(data), }), undefined, true ); expect(mutation).toBeDefined(); expect(mutation2).toBeDefined(); expect(queryApiStore.newMutation).toBeDefined(); expect(queryApiStore.getMutation(['test', 'mutation'])).toBe(mutation); expect(queryApiStore.getMutation(['test', 'mutation2'])).toBeUndefined(); expect(queryApiStore.getMutation(['new', 'mutation'])).toBe(queryApiStore.newMutation); }); it('should return existing mutation when added with same key', () => { const mutation1 = queryApiStore.addMutation( () => ({ mutationFn: (data: string) => Promise.resolve(data), }), ['test', 'same'] ); const mutation2 = queryApiStore.addMutation( () => ({ mutationFn: (data: string) => Promise.resolve(data), }), ['test', 'same'] ); expect(mutation1).toBe(mutation2); }); }); describe('dynamic query key', () => { it('should update query when observable id changes', async () => { // Initially, no ID is set expect(queryApiStore.id).toBeUndefined(); expect(queryApiStore.data).toBe('test data'); expect(queryApiStore.queryOptions.queryKey).toEqual(['test', 'store']); // Set an ID and verify the query key and data change queryApiStore.setId('123'); await waitFor(() => !queryApiStore.isFetching); expect(queryApiStore.data).toBe('test data for 123'); expect(queryApiStore.queryOptions.queryKey).toEqual(['test', 'store', '123']); // Change the ID to a different value queryApiStore.setId('456'); await waitFor(() => !queryApiStore.isFetching); expect(queryApiStore.data).toBe('test data for 456'); expect(queryApiStore.queryOptions.queryKey).toEqual(['test', 'store', '456']); // Clear the ID queryApiStore.setId(undefined); await waitFor(() => !queryApiStore.isFetching); expect(queryApiStore.data).toBe('test data'); expect(queryApiStore.queryOptions.queryKey).toEqual(['test', 'store']); }); it('should handle rapid id changes correctly', async () => { // Rapidly change IDs queryApiStore.setId('rapid-1'); queryApiStore.setId('rapid-2'); queryApiStore.setId('rapid-3'); // Wait for the final state await waitFor( () => !queryApiStore.isFetching && queryApiStore.data === 'test data for rapid-3' ); expect(queryApiStore.data).toBe('test data for rapid-3'); }); }); describe('query error handling', () => { it('should handle errors during initialization', async () => { const { initialize, queryApiStoreInit, mockClientStoreInit } = initStore(); // Mock console.error to suppress error output and verify it's called const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on the fetchData function to throw an error const errorMessage = 'Test initialization error'; jest.spyOn(testApiService, 'fetchData').mockRejectedValue(new Error(errorMessage)); queryApiStoreInit.setEnabled(true); await initialize(); // Wait for error state await waitFor(() => queryApiStoreInit.isError); expect(queryApiStoreInit.isError).toBe(true); expect(queryApiStoreInit.isSuccess).toBe(false); expect(queryApiStoreInit.isPending).toBe(false); expect(queryApiStoreInit.data).toBeUndefined(); expect(queryApiStoreInit.error).toBeDefined(); expect(queryApiStoreInit.error?.message).toBe(errorMessage); // Verify console.error was called with the error expect(consoleErrorSpy).toHaveBeenCalledWith(queryApiStoreInit.error); queryApiStoreInit.dispose(); mockClientStoreInit.dispose(); }); it('should handle errors during refetch', async () => { // queryApiStore is already initialized with successful data expect(queryApiStore.data).toBe('test data'); expect(queryApiStore.isSuccess).toBe(true); // Mock console.error to suppress error output and verify it's called const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on the fetchData function to throw an error on next fetch const errorMessage = 'Test refetch error'; jest.spyOn(testApiService, 'fetchData').mockRejectedValue(new Error(errorMessage)); // Trigger a refetch queryApiStore.refetch(); // Wait for error state await waitFor(() => queryApiStore.isError); expect(queryApiStore.isError).toBe(true); expect(queryApiStore.isSuccess).toBe(false); expect(queryApiStore.error).toBeDefined(); expect(queryApiStore.error?.message).toBe(errorMessage); // Data should still be available from previous successful fetch expect(queryApiStore.data).toBe('test data'); // Verify console.error was called with the error expect(consoleErrorSpy).toHaveBeenCalledWith(queryApiStore.error); }); it('should call onError callback instead of console.error when provided', async () => { const { initialize, queryApiStoreInit, mockClientStoreInit } = initStore(); // Create an onError mock function const onErrorMock = jest.fn(); // Use overrideOptions to add onError callback queryApiStoreInit.overrideOptions = () => ({ onError: onErrorMock, }); // Mock console.error to verify it's NOT called when onError is provided const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on the fetchData function to throw an error const errorMessage = 'Test onError callback error'; jest.spyOn(testApiService, 'fetchData').mockRejectedValue(new Error(errorMessage)); queryApiStoreInit.setEnabled(true); await initialize(); // Wait for error state await waitFor(() => queryApiStoreInit.isError); expect(queryApiStoreInit.isError).toBe(true); expect(queryApiStoreInit.error).toBeDefined(); expect(queryApiStoreInit.error?.message).toBe(errorMessage); // Verify onError was called with the error expect(onErrorMock).toHaveBeenCalledWith(queryApiStoreInit.error); // Verify console.error was NOT called since onError was provided expect(consoleErrorSpy).not.toHaveBeenCalled(); queryApiStoreInit.dispose(); mockClientStoreInit.dispose(); }); it('should call onSuccess callback with correct arguments', async () => { const { initialize, queryApiStoreInit, mockClientStoreInit } = initStore(); // Create an onSuccess mock function const onSuccessMock = jest.fn(); // Use overrideOptions to add onSuccess callback queryApiStoreInit.overrideOptions = () => ({ onSuccess: onSuccessMock, }); queryApiStoreInit.setEnabled(true); await initialize(); // Wait for success state await waitFor(() => queryApiStoreInit.isSuccess); expect(queryApiStoreInit.isSuccess).toBe(true); expect(queryApiStoreInit.data).toBeDefined(); // Verify onSuccess was called with data only expect(onSuccessMock).toHaveBeenCalledWith(queryApiStoreInit.data); queryApiStoreInit.dispose(); mockClientStoreInit.dispose(); }); }); describe('refreshStoresOnMount', () => { it('should call refreshStoresOnMount on main query', () => { const spy = jest.spyOn(queryApiStore.query, 'refreshStoresOnMount'); queryApiStore.refreshStoresOnMount(); expect(spy).toHaveBeenCalled(); }); }); describe('refreshOnMount', () => { it('should call refreshOnMount on main query and additional queries', () => { const spy = jest.spyOn(queryApiStore.query, 'refreshOnMount'); // Add an additional query const additionalQuery = queryApiStore.addQuery( () => ({ queryKey: ['test', 'additional'], queryFn: () => Promise.resolve('data'), }), ['test', 'additional'] ); const additionalSpy = jest.spyOn(additionalQuery, 'refreshOnMount'); queryApiStore.refreshOnMount(); expect(spy).toHaveBeenCalled(); expect(additionalSpy).toHaveBeenCalled(); }); }); describe('dispose', () => { it('should dispose all resources', () => { const querySpy = jest.spyOn(queryApiStore.query, 'dispose'); // Add some additional queries and mutations const additionalQuery = queryApiStore.addQuery( () => ({ queryKey: ['dispose', 'test'], queryFn: () => Promise.resolve('data'), }), ['dispose', 'test'] ); const queryDisposeSpy = jest.spyOn(additionalQuery, 'dispose'); const mutation = queryApiStore.addMutation( () => ({ mutationFn: (data: string) => Promise.resolve(data), }), ['dispose', 'mutation'] ); const mutationDisposeSpy = jest.spyOn(mutation, 'dispose'); queryApiStore.dispose(); expect(querySpy).toHaveBeenCalled(); expect(queryDisposeSpy).toHaveBeenCalled(); expect(mutationDisposeSpy).toHaveBeenCalled(); }); }); describe('override options', () => { it('should handle override options function', async () => { const { initialize, queryApiStoreInit, mockClientStoreInit } = initStore(); const overrideFn = () => ({ queryKey: ['test', 'override'], queryFn: () => Promise.resolve('override data'), }); queryApiStoreInit.overrideOptions = overrideFn; queryApiStoreInit.setEnabled(true); // Initialize the store await initialize(); expect(queryApiStoreInit.overrideOptions).toBe(overrideFn); expect(queryApiStoreInit.data).toBe('override data'); queryApiStoreInit.dispose(); mockClientStoreInit.dispose(); }); }); describe('updateQueryData', () => { it('should update query data', () => { const updateSpy = jest.spyOn(queryApiStore.query, 'updateQueryData'); const newData = 'updated data'; queryApiStore.updateQueryData(newData); expect(updateSpy).toHaveBeenCalledWith(newData); expect(queryApiStore.data).toBe(newData); }); }); describe('createMapKey', () => { it('should create map key from array', () => { const key = ['test', 'key']; const mapKey = queryApiStore.createMapKey(key); expect(mapKey).toBe('test-key'); }); it('should return empty string for undefined key', () => { const mapKey = queryApiStore.createMapKey(undefined); expect(mapKey).toBe(''); }); }); });