diff --git a/src/store/middleware/createHyperStorage/index.test.ts b/src/store/middleware/createHyperStorage/index.test.ts new file mode 100644 index 000000000000..a99572ab6120 --- /dev/null +++ b/src/store/middleware/createHyperStorage/index.test.ts @@ -0,0 +1,341 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createHyperStorage } from './index'; +import { createIndexedDB } from './indexedDB'; +import { createKeyMapper } from './keyMapper'; +import { createLocalStorage } from './localStorage'; +import { creatUrlStorage } from './urlStorage'; + +// Mock the dependent modules +vi.mock('./indexedDB', () => { + return { + createIndexedDB: vi.fn(), + }; +}); + +vi.mock('./localStorage', () => ({ + createLocalStorage: vi.fn(), +})); + +vi.mock('./urlStorage', () => ({ + creatUrlStorage: vi.fn(), +})); + +vi.mock('./keyMapper', () => ({ + createKeyMapper: vi.fn().mockReturnValue({ + mapStateKeyToStorageKey: vi.fn((k) => k), + getStateKeyFromStorageKey: vi.fn((k) => k), + }), +})); + +afterEach(() => { + vi.mocked(createKeyMapper).mockClear(); +}); + +describe('createHyperStorage', () => { + it('should create storage with default configuration if no options provided', async () => { + const storage = createHyperStorage({}); + expect(storage).toBeDefined(); + // Add more assertions to verify default behavior + }); + + it('should skip local storage if explicitly disabled', async () => { + const storage = createHyperStorage({ localStorage: false }); + await storage.setItem('key', { state: {}, version: 1 }); + + // The setItem should not call the local storage functions + const indexDBSetItemMock = vi.fn(); + + vi.mocked(createIndexedDB).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: indexDBSetItemMock, + })); + + const localStorageSetItemMock = vi.fn(); + + vi.mocked(createLocalStorage).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: localStorageSetItemMock, + })); + + expect(indexDBSetItemMock).not.toHaveBeenCalled(); + expect(localStorageSetItemMock).not.toHaveBeenCalled(); + }); + + it('should use indexedDB mode if set in local storage options', async () => { + // The setItem should call the indexedDB functions + // The setItem should not call the local storage functions + const indexDBSetItemMock = vi.fn(); + + vi.mocked(createIndexedDB).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: indexDBSetItemMock, + })); + + const storage = createHyperStorage({ + localStorage: { mode: 'indexedDB', dbName: 'testDB', selectors: [] }, + }); + + await storage.setItem('key', { state: {}, version: 1 }); + + expect(indexDBSetItemMock).toHaveBeenCalled(); + }); + + it('should use the provided dbName for indexedDB', async () => { + const dbName = 'customDB'; + createHyperStorage({ localStorage: { mode: 'indexedDB', dbName, selectors: [] } }); + + expect(vi.mocked(createIndexedDB)).toHaveBeenCalledWith(dbName); + }); + + it('should handle URL storage if URL options are provided', async () => { + const urlMode = 'hash'; + const setItemMock = vi.fn(); + + vi.mocked(creatUrlStorage).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: setItemMock, + })); + + const storage = createHyperStorage({ url: { mode: urlMode, selectors: [] } }); + + await storage.setItem('key', { state: {}, version: 1 }); + + expect(creatUrlStorage).toHaveBeenCalledWith(urlMode); + // The setItem should call the urlStorage functions + expect(setItemMock).toHaveBeenCalled(); + }); + + describe('getItem method', () => { + it('should retrieve item from indexedDB when useIndexedDB is true', async () => { + const indexedDBGetItemMock = vi + .fn() + .mockResolvedValue({ state: { key: 'value' }, version: 1 }); + vi.mocked(createIndexedDB).mockImplementation(() => ({ + getItem: indexedDBGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + const storage = createHyperStorage({ localStorage: { mode: 'indexedDB', selectors: [] } }); + const item = await storage.getItem('key'); + + expect(indexedDBGetItemMock).toHaveBeenCalledWith('key'); + expect(item).toEqual({ state: { key: 'value' }, version: 1 }); + }); + + it('should fallback to localStorage if item not found in indexedDB', async () => { + const indexedDBGetItemMock = vi.fn().mockResolvedValue(undefined); + const localStorageGetItemMock = vi + .fn() + .mockReturnValue({ state: { key: 'value' }, version: 1 }); + + vi.mocked(createIndexedDB).mockImplementation(() => ({ + getItem: indexedDBGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + vi.mocked(createLocalStorage).mockImplementation(() => ({ + getItem: localStorageGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + const storage = createHyperStorage({ localStorage: { mode: 'indexedDB', selectors: [] } }); + const item = await storage.getItem('key'); + + expect(indexedDBGetItemMock).toHaveBeenCalledWith('key'); + expect(localStorageGetItemMock).toHaveBeenCalledWith('key'); + expect(item).toEqual({ state: { key: 'value' }, version: 1 }); + }); + + it('should not attempt to retrieve from any storage if skipLocalStorage is true', async () => { + const storage = createHyperStorage({ localStorage: false }); + const item = await storage.getItem('key'); + + const indexedDBGetItemMock = vi.fn().mockResolvedValue(undefined); + const localStorageGetItemMock = vi + .fn() + .mockReturnValue({ state: { key: 'value' }, version: 1 }); + + vi.mocked(createIndexedDB).mockImplementation(() => ({ + getItem: indexedDBGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + vi.mocked(createLocalStorage).mockImplementation(() => ({ + getItem: localStorageGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + expect(indexedDBGetItemMock).not.toHaveBeenCalled(); + expect(localStorageGetItemMock).not.toHaveBeenCalled(); + expect(item).toEqual({ state: {}, version: undefined }); + }); + + describe('getItem method with URL storage', () => { + it('should override state from URL storage if hasUrl is true', async () => { + const urlStorageGetItemMock = vi.fn().mockReturnValue({ state: { urlKey: 'urlValue' } }); + vi.mocked(creatUrlStorage).mockImplementation(() => ({ + getItem: urlStorageGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + // Mock createKeyMapper to simulate state key mapping from URL storage keys + vi.mocked(createKeyMapper).mockReturnValue({ + mapStateKeyToStorageKey: vi.fn((k) => k), + getStateKeyFromStorageKey: vi.fn((k) => (k === 'urlKey' ? 'mappedKey' : undefined)), + }); + + const storage = createHyperStorage({ + url: { mode: 'hash', selectors: [] }, + localStorage: false, + }); + const item = await storage.getItem('key'); + + expect(urlStorageGetItemMock).toHaveBeenCalled(); + expect(item?.state).toEqual({ mappedKey: 'urlValue' }); + }); + + it('should not include URL storage state if key mapping is undefined', async () => { + const urlStorageGetItemMock = vi.fn().mockReturnValue({ state: { urlKey: 'urlValue' } }); + vi.mocked(creatUrlStorage).mockImplementation(() => ({ + getItem: urlStorageGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + // Mock createKeyMapper to simulate state key mapping from URL storage keys + vi.mocked(createKeyMapper).mockReturnValue({ + mapStateKeyToStorageKey: vi.fn((k) => k), + getStateKeyFromStorageKey: vi.fn(() => undefined), // No key will be mapped + }); + + const storage = createHyperStorage({ + url: { mode: 'hash', selectors: [] }, + localStorage: false, + }); + const item = await storage.getItem('key'); + + expect(urlStorageGetItemMock).toHaveBeenCalled(); + expect(item?.state).toEqual({}); // No state from URL storage should be included + }); + + it('should not attempt to retrieve from URL storage if hasUrl is false', async () => { + const urlStorageGetItemMock = vi.fn(); + vi.mocked(creatUrlStorage).mockImplementation(() => ({ + getItem: urlStorageGetItemMock, + removeItem: vi.fn(), + setItem: vi.fn(), + })); + + const storage = createHyperStorage({ localStorage: false }); // No URL options provided + await storage.getItem('key'); + + expect(urlStorageGetItemMock).not.toHaveBeenCalled(); + }); + }); + }); + + describe('removeItem method', () => { + it('should remove item from indexedDB when useIndexedDB is true', async () => { + const indexedDBRemoveItemMock = vi.fn().mockResolvedValue(undefined); + vi.mocked(createIndexedDB).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: indexedDBRemoveItemMock, + setItem: vi.fn(), + })); + + const storage = createHyperStorage({ localStorage: { mode: 'indexedDB', selectors: [] } }); + await storage.removeItem('key'); + + expect(indexedDBRemoveItemMock).toHaveBeenCalledWith('key'); + }); + + it('should remove item from localStorage when useIndexedDB is false', async () => { + const localStorageRemoveItemMock = vi.fn().mockResolvedValue(undefined); + vi.mocked(createLocalStorage).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: localStorageRemoveItemMock, + setItem: vi.fn(), + })); + + const storage = createHyperStorage({}); + await storage.removeItem('key'); + + expect(localStorageRemoveItemMock).toHaveBeenCalledWith('key'); + }); + + it('should remove item from URL storage if URL options provided', async () => { + const urlStorageRemoveItemMock = vi.fn(); + + vi.mocked(creatUrlStorage).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: urlStorageRemoveItemMock, + setItem: vi.fn(), + })); + // Mock createKeyMapper to simulate state key mapping from URL storage keys + vi.mocked(createKeyMapper).mockReturnValue({ + mapStateKeyToStorageKey: vi.fn((k) => k), + getStateKeyFromStorageKey: vi.fn((k) => k), // No key will be mapped + }); + + const storage = createHyperStorage({ url: { mode: 'hash', selectors: ['key'] } }); + await storage.removeItem('key'); + + expect(urlStorageRemoveItemMock).toHaveBeenCalledWith('key'); + }); + }); + + describe('setItem method', () => { + it('should save item to indexedDB when useIndexedDB is true', async () => { + const indexedDBSetItemMock = vi.fn().mockResolvedValue(undefined); + vi.mocked(createIndexedDB).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: indexedDBSetItemMock, + })); + + const storage = createHyperStorage({ localStorage: { mode: 'indexedDB', selectors: [] } }); + await storage.setItem('key', { state: { key: 'value' }, version: 1 }); + + expect(indexedDBSetItemMock).toHaveBeenCalledWith('key', { key: 'value' }, 1); + }); + + it('should save item to localStorage when useIndexedDB is false', async () => { + const localStorageSetItemMock = vi.fn().mockResolvedValue(undefined); + vi.mocked(createLocalStorage).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: localStorageSetItemMock, + })); + + const storage = createHyperStorage({}); + await storage.setItem('key', { state: { key: 'value' }, version: 1 }); + + expect(localStorageSetItemMock).toHaveBeenCalledWith('key', { key: 'value' }, 1); + }); + + it('should save state to URL storage if URL options provided', async () => { + const urlStorageSetItemMock = vi.fn().mockResolvedValue(undefined); + vi.mocked(creatUrlStorage).mockImplementation(() => ({ + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: urlStorageSetItemMock, + })); + + const storage = createHyperStorage({ url: { mode: 'hash', selectors: [] } }); + await storage.setItem('key', { state: { key: 'value' }, version: 1 }); + + expect(urlStorageSetItemMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/store/middleware/createHyperStorage/indexedDB.test.ts b/src/store/middleware/createHyperStorage/indexedDB.test.ts new file mode 100644 index 000000000000..3b6147bbff8f --- /dev/null +++ b/src/store/middleware/createHyperStorage/indexedDB.test.ts @@ -0,0 +1,64 @@ +import { delMany, getMany, setMany } from 'idb-keyval'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createIndexedDB } from './indexedDB'; + +// Mock idb-keyval methods +vi.mock('idb-keyval', () => ({ + createStore: vi.fn().mockImplementation(() => 'abc'), + getMany: vi.fn(), + setMany: vi.fn(), + delMany: vi.fn(), +})); + +describe('createIndexedDB', () => { + const dbName = 'testDB'; + const storeName = 'testStore'; + const indexedDB = createIndexedDB(dbName); + + beforeEach(() => { + // Reset all mocks before each test + vi.resetAllMocks(); + }); + + it('getItem should return the correct state and version', async () => { + const mockState = { key: 'value' }; + const mockVersion = 1; + + // Setup mock behavior for getMany + vi.mocked(getMany).mockResolvedValue([mockVersion, mockState]); + + const result = await indexedDB.getItem(storeName); + + expect(vi.mocked(getMany).mock.calls[0][0]).toEqual(['version', 'state']); + expect(result).toEqual({ state: mockState, version: mockVersion }); + }); + + it('getItem should return undefined if state does not exist', async () => { + // Setup mock behavior for getMany + vi.mocked(getMany).mockResolvedValue([undefined, undefined]); + + const result = await indexedDB.getItem(storeName); + + expect(vi.mocked(getMany).mock.calls[0][0]).toEqual(['version', 'state']); + expect(result).toBeUndefined(); + }); + + it('removeItem should call delMany with the correct keys', async () => { + await indexedDB.removeItem(storeName); + + expect(vi.mocked(delMany).mock.calls[0][0]).toEqual(['version', 'state']); + }); + + it('setItem should call setMany with the correct keys and values', async () => { + const mockState = { key: 'value' }; + const mockVersion = 1; + + await indexedDB.setItem(storeName, mockState, mockVersion); + + expect(vi.mocked(setMany).mock.calls[0][0]).toEqual([ + ['version', mockVersion], + ['state', mockState], + ]); + }); +}); diff --git a/src/store/middleware/createHyperStorage/urlStorage.test.ts b/src/store/middleware/createHyperStorage/urlStorage.test.ts new file mode 100644 index 000000000000..4602bd23f285 --- /dev/null +++ b/src/store/middleware/createHyperStorage/urlStorage.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { creatUrlStorage } from './urlStorage'; + +// Mock global location and history objects +const mockLocation = { + hash: '', + search: '', +}; +const mockHistory = { + replaceState: vi.fn(), +}; + +global.location = mockLocation as Location; +global.history = mockHistory as any as History; + +describe('creatUrlStorage', () => { + beforeEach(() => { + // Reset all mocks and location properties before each test + vi.resetAllMocks(); + mockLocation.hash = ''; + mockLocation.search = ''; + }); + + it('getItem should return the correct state from URL hash', () => { + mockLocation.hash = '#key1=value1&key2=value2'; + const storage = creatUrlStorage('hash'); + const result = storage.getItem(); + + expect(result).toEqual({ state: { key1: 'value1', key2: 'value2' } }); + }); + + it('getItem should return the correct state from URL search', () => { + mockLocation.search = '?key1=value1&key2=value2'; + const storage = creatUrlStorage('search'); + const result = storage.getItem(); + + expect(result).toEqual({ state: { key1: 'value1', key2: 'value2' } }); + }); + + it('getItem should return undefined if no state in URL', () => { + const storage = creatUrlStorage('hash'); + const result = storage.getItem(); + + expect(result).toBeUndefined(); + }); + + it('removeItem should remove a specific key from URL hash', () => { + mockLocation.hash = '#key1=value1&key2=value2'; + const storage = creatUrlStorage('hash'); + storage.removeItem('key1'); + + expect(location.hash).toEqual('key2=value2'); + }); + + it('removeItem should remove a specific key from URL search', () => { + mockLocation.search = '?key1=value1&key2=value2'; + const storage = creatUrlStorage('search'); + storage.removeItem('key1'); + + expect(mockHistory.replaceState).toHaveBeenCalledWith({}, '', '?key2=value2'); + }); + + it('setItem should set state in URL hash', () => { + const storage = creatUrlStorage('hash'); + storage.setItem('state', { key1: 'value1', key2: 'value2', key3: 1, key4: false }); + + expect(location.hash).toEqual('key1=value1&key2=value2&key3=1&key4=0'); + }); + + it('setItem should set state in URL search', () => { + const storage = creatUrlStorage('search'); + storage.setItem('state', { key1: 'value1', key2: 'value2' }); + + expect(mockHistory.replaceState).toHaveBeenCalledWith({}, '', '?key1=value1&key2=value2'); + }); + + it('setItem should handle non-string values by JSON stringifying them', () => { + const storage = creatUrlStorage('hash'); + storage.setItem('state', { key1: { nested: 'value' }, key2: 123, key4: {} }); + + expect(location.hash).toEqual(`key1=%7B%22nested%22%3A%22value%22%7D&key2=123`); + }); +}); diff --git a/src/store/middleware/createHyperStorage/urlStorage.ts b/src/store/middleware/createHyperStorage/urlStorage.ts index 9bc93607b2c8..950eb8b0a172 100644 --- a/src/store/middleware/createHyperStorage/urlStorage.ts +++ b/src/store/middleware/createHyperStorage/urlStorage.ts @@ -46,15 +46,32 @@ export const creatUrlStorage = (mode: 'hash' | 'search' = const searchParameters = new URLSearchParams(getUrlSearch()); for (const [urlKey, v] of Object.entries(state)) { - if (isEmpty(v)) { - searchParameters.delete(urlKey); - continue; - } + switch (typeof v) { + case 'boolean': { + searchParameters.set(urlKey, (v ? 1 : 0).toString()); + break; + } + + case 'bigint': + case 'number': { + searchParameters.set(urlKey, v.toString()); + break; + } + + case 'string': { + searchParameters.set(urlKey, v); + break; + } + + case 'object': { + if (isEmpty(v)) { + searchParameters.delete(urlKey); + continue; + } - if (typeof v === 'string') { - searchParameters.set(urlKey, v); - } else { - searchParameters.set(urlKey, JSON.stringify(v)); + searchParameters.set(urlKey, JSON.stringify(v)); + break; + } } }