diff --git a/README.md b/README.md index 07811f6..f58c5d2 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ function MyComponent() { method: 'POST', body: { filter: 'active' }, onResult: (result) => { + // onResult is always called with the response data + // For JSON responses, result is already parsed console.log('Request completed:', result); }, })); @@ -185,7 +187,9 @@ interface CdeebeeRequestOptions { bodyKey?: string; // Override default bodyKey listStrategy?: CdeebeeListStrategy; // Override list strategy for this request normalize?: (storage, result, strategyList) => T; // Override normalization - onResult?: (response: T) => void; // Callback called with response data on success + onResult?: (response: T) => void; // Callback called with response data (always called, even on errors) + ignore?: boolean; // Skip storing result in storage + responseType?: 'json' | 'text' | 'blob'; // Response parsing type (default: 'json') } ``` @@ -236,6 +240,61 @@ dispatch(request({ })); ``` +### Handling Different Response Types + +By default, cdeebee parses responses as JSON. For other response types (CSV, text files, images, etc.), use the `responseType` option: + +```typescript +// CSV/text response +dispatch(request({ + api: '/api/export', + responseType: 'text', + ignore: true, // Don't store in storage + onResult: (csvData) => { + // csvData is a string + downloadCSV(csvData); + }, +})); + +// Binary file (image, PDF, etc.) +dispatch(request({ + api: '/api/image/123', + responseType: 'blob', + ignore: true, + onResult: (blob) => { + // blob is a Blob object + const url = URL.createObjectURL(blob); + setImageUrl(url); + }, +})); + +// JSON (default) +dispatch(request({ + api: '/api/data', + // responseType: 'json' is default + onResult: (data) => { + console.log(data); // Already parsed JSON + }, +})); +``` + +### Ignoring Storage Updates + +Use the `ignore` option to prevent storing the response in storage while still receiving it in the `onResult` callback: + +```typescript +// Export CSV without storing in storage +dispatch(request({ + api: '/api/export', + responseType: 'text', + ignore: true, + onResult: (csvData) => { + // Handle CSV data directly + downloadFile(csvData, 'export.csv'); + }, +})); +``` + ### Custom Headers ```typescript diff --git a/lib/reducer/index.ts b/lib/reducer/index.ts index 1b4e1a2..6079e7d 100644 --- a/lib/reducer/index.ts +++ b/lib/reducer/index.ts @@ -60,11 +60,16 @@ export const factory = (settings: CdeebeeSettings, storage?: T) => { state.request.done[api].push({ api, request: action.payload, requestId }); }); checkModule(state.settings, 'storage', () => { + if (action.meta.arg.ignore) { + return; + } + const strategyList = action.meta.arg.listStrategy ?? state.settings.listStrategy ?? {}; const normalize = action.meta.arg.normalize ?? state.settings.normalize ?? defaultNormalize; const currentState = current(state) as CdeebeeState; - const normalizedData = normalize(currentState, action.payload.result, strategyList); + // Type assertion is safe here because we've already checked isRecord + const normalizedData = normalize(currentState, action.payload.result as Record>, strategyList); // Normalize already handles merge/replace and preserves keys not in response // Simply apply the result @@ -75,7 +80,7 @@ export const factory = (settings: CdeebeeSettings, storage?: T) => { .addCase(request.rejected, (state, action) => { const requestId = action.meta.requestId; const api = action.meta.arg.api; - + checkModule(state.settings, 'listener', () => { state.request.active = state.request.active.filter(q => !(q.api === api && q.requestId === requestId)); }); diff --git a/lib/reducer/request.ts b/lib/reducer/request.ts index ee1d467..7ac79d8 100644 --- a/lib/reducer/request.ts +++ b/lib/reducer/request.ts @@ -10,6 +10,7 @@ export const request = createAsyncThunk( const { cdeebee: { settings } } = getState() as { cdeebee: CdeebeeState }; const abort = abortManager(signal, options.api, requestId); + const withCallback = options.onResult && typeof options.onResult === 'function'; checkModule(settings, 'cancelation', abort.init); @@ -52,25 +53,35 @@ export const request = createAsyncThunk( checkModule(settings, 'cancelation', abort.drop); + let result: unknown; + const responseType = options.responseType || 'json'; + + if (responseType === 'text') { + result = await response.text(); + } else if (responseType === 'blob') { + result = await response.blob(); + } else { + // default: json + result = await response.json(); + } + if (!response.ok) { + if (withCallback) options.onResult!(result); return rejectWithValue(response); } - const result = await response.json(); - if (options.onResult && typeof options.onResult === 'function') { - options.onResult(result); - } + + if (withCallback) options.onResult!(result); return { result, startedAt, endedAt: new Date().toUTCString() }; } catch (error) { checkModule(settings, 'cancelation', abort.drop); + + if (withCallback) options.onResult!(error); + if (error instanceof Error && error.name === 'AbortError') { - return rejectWithValue({ - message: 'Request was cancelled', - cancelled: true, - }); + return rejectWithValue({ message: 'Request was cancelled', cancelled: true }); } - return rejectWithValue({ - message: error instanceof Error ? error.message : 'Unknown error occurred', - }); + + return rejectWithValue({ message: error instanceof Error ? error.message : 'Unknown error occurred' }); } }, ); diff --git a/lib/reducer/types.ts b/lib/reducer/types.ts index 7e9f275..701ba8b 100644 --- a/lib/reducer/types.ts +++ b/lib/reducer/types.ts @@ -43,6 +43,8 @@ export interface CdeebeeRequestOptions extends Partial; onResult?: (response: T) => void; + ignore?: boolean; + responseType?: 'json' | 'text' | 'blob'; } type KeyOf = Extract; diff --git a/package.json b/package.json index ef9f036..2450878 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@recats/cdeebee", - "version": "3.0.0-beta.4", + "version": "3.0.0-beta.5", "description": "React Redux data-logic library", "repository": "git@github.com:recats/cdeebee.git", "author": "recats", diff --git a/tests/lib/reducer/helpers.test.ts b/tests/lib/reducer/helpers.test.ts index 3bca593..9608bd7 100644 --- a/tests/lib/reducer/helpers.test.ts +++ b/tests/lib/reducer/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { checkModule, mergeDeepRight, omit, batchingUpdate, assocPath } from '../../../lib/reducer/helpers'; +import { checkModule, mergeDeepRight, omit, batchingUpdate, assocPath, hasDataProperty, hasProperty } from '../../../lib/reducer/helpers'; import { type CdeebeeSettings, type CdeebeeModule } from '../../../lib/reducer/types'; describe('checkModule', () => { @@ -533,3 +533,49 @@ describe('batchingUpdate', () => { expect(items[0].name).toBe('Updated Item 1'); }); }); + +describe('hasDataProperty', () => { + it('should return true for object with data array property', () => { + const value = { data: [1, 2, 3] }; + expect(hasDataProperty(value)).toBe(true); + }); + + it('should return false for object without data property', () => { + const value = { other: 'value' }; + expect(hasDataProperty(value)).toBe(false); + }); + + it('should return false for object with non-array data', () => { + const value = { data: 'not an array' }; + expect(hasDataProperty(value)).toBe(false); + }); + + it('should return false for non-object values', () => { + expect(hasDataProperty(null)).toBe(false); + expect(hasDataProperty(undefined)).toBe(false); + expect(hasDataProperty('string')).toBe(false); + expect(hasDataProperty(123)).toBe(false); + expect(hasDataProperty([])).toBe(false); + }); +}); + +describe('hasProperty', () => { + it('should return true when object has the property', () => { + const value = { prop: 'value', other: 'data' }; + expect(hasProperty(value, 'prop')).toBe(true); + expect(hasProperty(value, 'other')).toBe(true); + }); + + it('should return false when object does not have the property', () => { + const value = { prop: 'value' }; + expect(hasProperty(value, 'missing')).toBe(false); + }); + + it('should return false for non-object values', () => { + expect(hasProperty(null, 'prop')).toBe(false); + expect(hasProperty(undefined, 'prop')).toBe(false); + expect(hasProperty('string', 'prop')).toBe(false); + expect(hasProperty(123, 'prop')).toBe(false); + expect(hasProperty([], 'prop')).toBe(false); + }); +}); diff --git a/tests/lib/reducer/index.test.ts b/tests/lib/reducer/index.test.ts index b6fe8ba..058534f 100644 --- a/tests/lib/reducer/index.test.ts +++ b/tests/lib/reducer/index.test.ts @@ -1,42 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { configureStore } from '@reduxjs/toolkit'; - import { type CdeebeeSettings, type CdeebeeListStrategy, CdeebeeState } from '../../../lib/reducer/types'; import { factory } from '../../../lib/reducer/index'; import { request } from '../../../lib/reducer/request'; - -// Mock fetch globally -global.fetch = vi.fn(); - -// Helper to create store with proper middleware configuration for tests -const createTestStore = (reducer: ReturnType>>['reducer']) => { - return configureStore({ - reducer: { - cdeebee: reducer as any, - }, - middleware: getDefaultMiddleware => - getDefaultMiddleware({ - serializableCheck: { - ignoredPaths: ['cdeebee.settings.normalize'], - }, - }), - }); -}; +import { createMockResponse, createTestStore, defaultTestSettings, mockFetch, mockFetchAlways } from '../test-helpers'; describe('factory', () => { let settings: CdeebeeSettings>; beforeEach(() => { vi.clearAllMocks(); - - settings = { - modules: ['history', 'listener', 'storage', 'cancelation'], - fileKey: 'file', - bodyKey: 'value', - mergeWithData: {}, - mergeWithHeaders: {}, - listStrategy: {}, - }; + settings = defaultTestSettings(); }); it('should create a slice with correct name', () => { @@ -54,8 +27,7 @@ describe('factory', () => { listStrategy: { list: 'merge' }, }; - const slice = factory(customSettings); - const store = createTestStore(slice.reducer); + const store = createTestStore(customSettings); const state = store.getState().cdeebee as CdeebeeState>; expect(state.settings.fileKey).toBe('customFile'); @@ -63,8 +35,7 @@ describe('factory', () => { }); it('should have correct initial state structure', () => { - const slice = factory(settings); - const store = createTestStore(slice.reducer); + const store = createTestStore(settings); const state = store.getState().cdeebee as CdeebeeState>; expect(state).toHaveProperty('settings'); @@ -80,17 +51,11 @@ describe('factory', () => { describe('listener module', () => { it('should track active requests when listener module is enabled', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: 'test' }), - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => ({ data: 'test' }) }) + ); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -114,17 +79,11 @@ describe('factory', () => { modules: ['history', 'storage', 'cancelation'], }; - const slice = factory(settingsWithoutListener); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithoutListener); - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: 'test' }), - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => ({ data: 'test' }) }) + ); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -138,18 +97,10 @@ describe('factory', () => { describe('history module', () => { it('should track successful requests when history module is enabled', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -165,17 +116,11 @@ describe('factory', () => { }); it('should track failed requests when history module is enabled', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: false, - status: 500, - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ ok: false, status: 500 }) + ); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -195,17 +140,11 @@ describe('factory', () => { modules: ['listener', 'storage', 'cancelation'], }; - const slice = factory(settingsWithoutHistory); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithoutHistory); - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: 'test' }), - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => ({ data: 'test' }) }) + ); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -217,17 +156,9 @@ describe('factory', () => { }); it('should accumulate multiple requests for the same API', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); - (global.fetch as ReturnType).mockResolvedValue({ - ok: true, - json: async () => ({ data: 'test' }), - } as Response); + mockFetchAlways(createMockResponse({ json: async () => ({ data: 'test' }) })); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -243,12 +174,7 @@ describe('factory', () => { describe('cancelation module', () => { it('should abort previous requests when cancelation module is enabled', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); let resolveFirstRequest: (value: Response) => void; const firstRequestPromise = new Promise(resolve => { @@ -262,17 +188,15 @@ describe('factory', () => { const dispatch = store.dispatch as any; const firstRequest = dispatch(request(options)); - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: 'second' }), - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => ({ data: 'second' }) }) + ); const secondRequest = dispatch(request(options)); - resolveFirstRequest!({ - ok: true, - json: async () => ({ data: 'first' }), - } as Response); + resolveFirstRequest!( + createMockResponse({ json: async () => ({ data: 'first' }) }) + ); await Promise.allSettled([firstRequest, secondRequest]); }); @@ -283,17 +207,9 @@ describe('factory', () => { modules: ['history', 'listener', 'storage'], }; - const slice = factory(settingsWithoutCancelation); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithoutCancelation); - (global.fetch as ReturnType).mockResolvedValue({ - ok: true, - json: async () => ({ data: 'test' }), - } as Response); + mockFetchAlways(createMockResponse({ json: async () => ({ data: 'test' }) })); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -308,12 +224,7 @@ describe('factory', () => { describe('storage module', () => { it('should normalize and store data when storage module is enabled', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); const mockResponse = { userList: { @@ -322,10 +233,7 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/userList' }; await store.dispatch(request(options)); @@ -345,12 +253,7 @@ describe('factory', () => { }, }; - const slice = factory(settingsWithStrategy); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithStrategy); const mockResponse = { userList: { @@ -359,10 +262,7 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/userList' }; await store.dispatch(request(options)); @@ -383,12 +283,7 @@ describe('factory', () => { }, }; - const slice = factory(settingsWithStrategy); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithStrategy); // First request - initial data const firstResponse = { @@ -398,10 +293,9 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => firstResponse, - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => firstResponse }) + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -415,10 +309,9 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => secondResponse, - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => secondResponse }) + ); await dispatch(request({ api: '/api/userList' })); @@ -441,12 +334,7 @@ describe('factory', () => { }, }; - const slice = factory(settingsWithStrategy); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithStrategy); const mockResponse = { userList: { @@ -454,10 +342,7 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/userList', @@ -486,18 +371,10 @@ describe('factory', () => { normalize: settingsNormalize, }; - const slice = factory(settingsWithNormalize); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithNormalize); const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/test', @@ -519,12 +396,7 @@ describe('factory', () => { modules: ['history', 'listener', 'cancelation'], }; - const slice = factory(settingsWithoutStorage); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settingsWithoutStorage); const mockResponse = { userList: { @@ -532,10 +404,7 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/userList' }; await store.dispatch(request(options)); @@ -553,8 +422,7 @@ describe('factory', () => { }, }; - const slice = factory(settingsWithStrategies); - const store = createTestStore(slice.reducer); + const store = createTestStore(settingsWithStrategies); // First request for userList const userListResponse = { @@ -563,7 +431,9 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: async () => userListResponse } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => userListResponse }) + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -575,7 +445,9 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: async () => postListResponse, } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ json: async () => postListResponse }) + ); await dispatch(request({ api: '/api/postList' })); @@ -589,18 +461,10 @@ describe('factory', () => { }); it('should handle empty response', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); const mockResponse = {}; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -612,22 +476,14 @@ describe('factory', () => { }); it('should handle response with invalid data structure', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); const mockResponse = { invalid: null, message: 'some string', }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const options = { api: '/api/test' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -642,12 +498,7 @@ describe('factory', () => { }); it('should use defaultNormalize when no custom normalize is provided', async () => { - const slice = factory(settings); - const store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + const store = createTestStore(settings); const mockResponse = { userList: { @@ -656,13 +507,17 @@ describe('factory', () => { }, }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ + contentType: 'application/json', + json: async () => mockResponse, + }) + ); const options = { api: '/api/userList' }; - await store.dispatch(request(options)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dispatch = store.dispatch as any; + await dispatch(request(options)); const state = store.getState().cdeebee; const userList = (state.storage as Record).userList as Record; @@ -673,12 +528,69 @@ describe('factory', () => { expect((userList['1'] as Record).name).toBe('John'); expect((userList['2'] as Record).name).toBe('Jane'); }); + + it('should not store result in storage when ignore option is true', async () => { + const initialStorage = { existing: 'data' }; + const storeWithStorage = createTestStore(settings, initialStorage); + + const mockResponse = { + newData: { + '1': { id: '1', name: 'New' }, + }, + }; + + mockFetch( + createMockResponse({ + contentType: 'application/json', + json: async () => mockResponse, + }) + ); + + const options = { api: '/api/test', ignore: true }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dispatch = storeWithStorage.dispatch as any; + await dispatch(request(options)); + + const state = storeWithStorage.getState().cdeebee; + const storage = state.storage as Record; + + // Storage should remain unchanged when ignore is true + expect(storage).toEqual(initialStorage); + expect(storage).not.toHaveProperty('newData'); + }); + + it('should not store text/CSV responses in storage', async () => { + const csvData = 'RecordID,DspID,BundleID\n39021,6,27483'; + const initialStorage = { existing: 'data' }; + const storeWithStorage = createTestStore(settings, initialStorage); + + mockFetch( + createMockResponse({ + contentType: 'text/csv', + json: async () => { + throw new Error('Not JSON'); + }, + text: async () => csvData, + }) + ); + + const options = { api: '/api/export' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dispatch = storeWithStorage.dispatch as any; + await dispatch(request(options)); + + const state = storeWithStorage.getState().cdeebee; + const storage = state.storage as Record; + + // Text responses should not be stored (they're not objects) + expect(storage).toEqual(initialStorage); + }); }); describe('set reducer', () => { it('should update a single top-level key in storage', () => { const slice = factory(settings); - const store = createTestStore(slice.reducer); + const store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -693,7 +605,7 @@ describe('factory', () => { it('should update multiple top-level keys in storage', () => { const slice = factory(settings); - const store = createTestStore(slice.reducer); + const store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -709,12 +621,16 @@ describe('factory', () => { }); it('should update nested keys in storage', () => { + const store = createTestStore(settings, { + campaignList: { + '123': { name: 'Old Name', id: '123' }, + }, + }); const slice = factory(settings, { campaignList: { '123': { name: 'Old Name', id: '123' }, }, }); - const store = createTestStore(slice.reducer); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -733,7 +649,7 @@ describe('factory', () => { it('should create nested structure if it does not exist', () => { const slice = factory(settings); - const store = createTestStore(slice.reducer); + const store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -750,13 +666,18 @@ describe('factory', () => { }); it('should update multiple nested keys in different paths', () => { + const store = createTestStore(settings, { + campaignList: { + '123': { name: 'Campaign 1', status: 'draft' }, + '456': { name: 'Campaign 2', status: 'active' }, + }, + }); const slice = factory(settings, { campaignList: { '123': { name: 'Campaign 1', status: 'draft' }, '456': { name: 'Campaign 2', status: 'active' }, }, }); - const store = createTestStore(slice.reducer); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -779,13 +700,18 @@ describe('factory', () => { }); it('should preserve existing storage data when updating', () => { + const store = createTestStore(settings, { + existingKey: 'existingValue', + campaignList: { + '123': { name: 'Old', status: 'active', id: '123' }, + }, + }); const slice = factory(settings, { existingKey: 'existingValue', campaignList: { '123': { name: 'Old', status: 'active', id: '123' }, }, }); - const store = createTestStore(slice.reducer); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -802,10 +728,12 @@ describe('factory', () => { }); it('should handle numeric keys in path', () => { + const store = createTestStore(settings, { + items: [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }], + }); const slice = factory(settings, { items: [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }], }); - const store = createTestStore(slice.reducer); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -820,7 +748,7 @@ describe('factory', () => { it('should handle deep nested paths', () => { const slice = factory(settings); - const store = createTestStore(slice.reducer); + const store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; @@ -836,8 +764,8 @@ describe('factory', () => { }); it('should handle empty value list', () => { + const store = createTestStore(settings, { existing: 'value' }); const slice = factory(settings, { existing: 'value' }); - const store = createTestStore(slice.reducer); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dispatch = store.dispatch as any; diff --git a/tests/lib/reducer/request.test.ts b/tests/lib/reducer/request.test.ts index 6428b54..d650c03 100644 --- a/tests/lib/reducer/request.test.ts +++ b/tests/lib/reducer/request.test.ts @@ -1,35 +1,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { configureStore } from '@reduxjs/toolkit'; -import { request, type CdeebeeRequestOptions } from '../../../lib/reducer/request'; -import { factory } from '../../../lib/reducer/index'; -import { type CdeebeeSettings } from '../../../lib/reducer/types'; - -// Mock fetch globally -global.fetch = vi.fn(); +import { request } from '../../../lib/reducer/request'; +import { type CdeebeeSettings, type CdeebeeRequestOptions } from '../../../lib/reducer/types'; +import { createMockResponse, createTestStore, defaultTestSettings, mockFetch } from '../test-helpers'; describe('request', () => { - let store: ReturnType; + let store: ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any let dispatch: any; - let settings: CdeebeeSettings; + let settings: CdeebeeSettings; beforeEach(() => { vi.clearAllMocks(); - settings = { + settings = defaultTestSettings({ modules: ['history', 'listener', 'cancelation'], - fileKey: 'file', - bodyKey: 'value', mergeWithData: { defaultKey: 'defaultValue' }, - listStrategy: {}, - }; - - const slice = factory(settings); - store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, }); + + store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatch = store.dispatch as any; }); @@ -41,12 +29,9 @@ describe('request', () => { describe('successful requests', () => { it('should handle a successful POST request', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', method: 'POST', body: { test: 'data' }, @@ -68,12 +53,9 @@ describe('request', () => { it('should merge mergeWithData with request body', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', method: 'POST', body: { customKey: 'customValue' }, @@ -90,12 +72,9 @@ describe('request', () => { it('should handle GET request', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', method: 'GET', }; @@ -112,12 +91,9 @@ describe('request', () => { it('should include custom headers', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', headers: { 'Authorization': 'Bearer token' }, }; @@ -130,12 +106,9 @@ describe('request', () => { it('should include requestId in headers', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -149,15 +122,12 @@ describe('request', () => { describe('file uploads', () => { it('should handle file uploads with FormData', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const file1 = new File(['content1'], 'file1.txt', { type: 'text/plain' }); const file2 = new File(['content2'], 'file2.txt', { type: 'text/plain' }); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/upload', files: [file1, file2], body: { metadata: 'test' }, @@ -171,14 +141,11 @@ describe('request', () => { it('should use custom fileKey and bodyKey', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); const file = new File(['content'], 'file.txt', { type: 'text/plain' }); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/upload', files: [file], fileKey: 'customFile', @@ -198,14 +165,14 @@ describe('request', () => { describe('error handling', () => { it('should reject with response when response is not ok', async () => { - const mockResponse = { + const mockResponse = createMockResponse({ ok: false, status: 404, statusText: 'Not Found', - }; - (global.fetch as ReturnType).mockResolvedValueOnce(mockResponse as Response); + }); + (global.fetch as ReturnType).mockResolvedValueOnce(mockResponse); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -221,7 +188,7 @@ describe('request', () => { const networkError = new Error('Network error'); (global.fetch as ReturnType).mockRejectedValueOnce(networkError); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -238,7 +205,7 @@ describe('request', () => { abortError.name = 'AbortError'; (global.fetch as ReturnType).mockRejectedValueOnce(abortError); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -255,7 +222,7 @@ describe('request', () => { const unknownError = 'String error'; (global.fetch as ReturnType).mockRejectedValueOnce(unknownError); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -271,12 +238,9 @@ describe('request', () => { describe('response data', () => { it('should return result with startedAt and endedAt', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -293,12 +257,13 @@ describe('request', () => { const mockResponse = { data: 'test', id: 123 }; const onResult = vi.fn(); - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ + json: async () => mockResponse, + }) + ); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', onResult, }; @@ -312,12 +277,13 @@ describe('request', () => { it('should not call onResult when it is not provided', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ + json: async () => mockResponse, + }) + ); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -327,45 +293,179 @@ describe('request', () => { expect(global.fetch).toHaveBeenCalled(); }); - it('should not call onResult when request fails', async () => { + it('should call onResult with error result when request fails', async () => { const onResult = vi.fn(); + const errorResponse = { error: 'Not found' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: false, - status: 500, - } as Response); + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 404, + contentType: 'application/json', + json: async () => errorResponse, + }) + ); - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', onResult, }; await dispatch(request(options)); - expect(onResult).not.toHaveBeenCalled(); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(errorResponse); + }); + }); + + describe('ignore option', () => { + it('should not store result in storage when ignore is true', async () => { + settings.modules = ['history', 'listener', 'storage', 'cancelation']; + store = createTestStore(settings); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dispatch = store.dispatch as any; + + const mockResponse = { user: { id: 1, name: 'Test' } }; + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ + contentType: 'application/json', + json: async () => mockResponse, + }) + ); + + const options: CdeebeeRequestOptions = { + api: '/api/user', + ignore: true, + }; + + await dispatch(request(options)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const state = (store.getState() as any).cdeebee; + expect(state.storage).toEqual({}); + }); + + it('should still call onResult when ignore is true', async () => { + const onResult = vi.fn(); + const mockResponse = { data: 'test' }; + + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ + contentType: 'application/json', + json: async () => mockResponse, + }) + ); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + ignore: true, + onResult, + }; + + await dispatch(request(options)); + + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(mockResponse); + }); + }); + + describe('responseType option', () => { + it('should use json by default', async () => { + const jsonData = { id: 1, name: 'Test' }; + mockFetch(createMockResponse({ json: async () => jsonData })); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + }; + + const result = await dispatch(request(options)); + + expect(result.type).toBe('cdeebee/request/fulfilled'); + if (result.type === 'cdeebee/request/fulfilled' && 'payload' in result) { + expect(result.payload.result).toEqual(jsonData); + } + }); + + it('should use text when responseType is text', async () => { + const textData = 'CSV data here'; + mockFetch(createMockResponse({ text: async () => textData })); + + const options: CdeebeeRequestOptions = { + api: '/api/export', + responseType: 'text', + }; + + const result = await dispatch(request(options)); + + expect(result.type).toBe('cdeebee/request/fulfilled'); + if (result.type === 'cdeebee/request/fulfilled' && 'payload' in result) { + expect(result.payload.result).toBe(textData); + } + }); + + it('should use blob when responseType is blob', async () => { + const blobData = new Blob(['binary data'], { type: 'image/png' }); + mockFetch(createMockResponse({ blob: async () => blobData })); + + const options: CdeebeeRequestOptions = { + api: '/api/image', + responseType: 'blob', + }; + + const result = await dispatch(request(options)); + + expect(result.type).toBe('cdeebee/request/fulfilled'); + if (result.type === 'cdeebee/request/fulfilled' && 'payload' in result) { + expect(result.payload.result).toBe(blobData); + } + }); + + it('should use json when responseType is json explicitly', async () => { + const jsonData = { data: 'test' }; + mockFetch(createMockResponse({ json: async () => jsonData })); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + responseType: 'json', + }; + + const result = await dispatch(request(options)); + + expect(result.type).toBe('cdeebee/request/fulfilled'); + if (result.type === 'cdeebee/request/fulfilled' && 'payload' in result) { + expect(result.payload.result).toEqual(jsonData); + } + }); + + it('should call onResult with correct type based on responseType', async () => { + const textData = 'CSV content'; + const onResult = vi.fn(); + mockFetch(createMockResponse({ text: async () => textData })); + + const options: CdeebeeRequestOptions = { + api: '/api/export', + responseType: 'text', + onResult, + }; + + await dispatch(request(options)); + + expect(onResult).toHaveBeenCalledWith(textData); }); }); describe('mergeWithHeaders', () => { it('should merge headers from settings with request headers', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); settings.mergeWithHeaders = { 'X-Custom-Header': 'from-settings', 'X-Another': 'settings-value' }; - const slice = factory(settings); - store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatch = store.dispatch as any; - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', headers: { 'Authorization': 'Bearer token', 'X-Another': 'request-value' }, }; @@ -381,23 +481,15 @@ describe('request', () => { it('should use only settings headers when request headers are not provided', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); settings.mergeWithHeaders = { 'X-Settings-Header': 'settings-only' }; - const slice = factory(settings); - store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatch = store.dispatch as any; - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', }; @@ -409,23 +501,15 @@ describe('request', () => { it('should handle empty mergeWithHeaders', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); settings.mergeWithHeaders = {}; - const slice = factory(settings); - store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatch = store.dispatch as any; - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', headers: { 'Authorization': 'Bearer token' }, }; @@ -438,23 +522,15 @@ describe('request', () => { it('should handle undefined mergeWithHeaders', async () => { const mockResponse = { data: 'test' }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + mockFetch(createMockResponse({ json: async () => mockResponse })); settings.mergeWithHeaders = undefined; - const slice = factory(settings); - store = configureStore({ - reducer: { - cdeebee: slice.reducer, - }, - }); + store = createTestStore(settings); // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatch = store.dispatch as any; - const options: CdeebeeRequestOptions = { + const options: CdeebeeRequestOptions = { api: '/api/test', headers: { 'Authorization': 'Bearer token' }, }; @@ -465,5 +541,57 @@ describe('request', () => { expect(callArgs[1].headers).toHaveProperty('Authorization', 'Bearer token'); }); }); + + describe('edge cases', () => { + it('should handle request without body', async () => { + const mockResponse = { data: 'test' }; + mockFetch(createMockResponse({ json: async () => mockResponse })); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + method: 'GET', + }; + + await dispatch(request(options)); + + const callArgs = (global.fetch as ReturnType).mock.calls[0]; + const body = JSON.parse(callArgs[1].body as string); + expect(body).toEqual({ defaultKey: 'defaultValue' }); + }); + + it('should handle response without content-type header', async () => { + const mockResponse = { data: 'test' }; + const response = createMockResponse({ + json: async () => mockResponse, + contentType: '', // Empty content-type + }); + // Override headers.get to return null + response.headers.get = () => null; + mockFetch(response); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + }; + + const result = await dispatch(request(options)); + expect(result.type).toBe('cdeebee/request/fulfilled'); + }); + + it('should call onResult with error in catch block', async () => { + const onResult = vi.fn(); + const networkError = new Error('Network error'); + (global.fetch as ReturnType).mockRejectedValueOnce(networkError); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + onResult, + }; + + await dispatch(request(options)); + + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(networkError); + }); + }); }); diff --git a/tests/lib/reducer/storage.test.ts b/tests/lib/reducer/storage.test.ts index 031a653..e5e3a98 100644 --- a/tests/lib/reducer/storage.test.ts +++ b/tests/lib/reducer/storage.test.ts @@ -352,5 +352,22 @@ describe('defaultNormalize', () => { city: 'NYC', // should be preserved from merge }); }); + + it('should handle storage that is not a record', () => { + mockCdeebee.storage = 'not an object' as unknown as Record; + const response: IResponse = { + userList: { + '1': { id: '1', name: 'John' }, + }, + }; + + const result = defaultNormalize(mockCdeebee, response, {}); + + expect(result).toEqual({ + userList: { + '1': { id: '1', name: 'John' }, + }, + }); + }); }); }); diff --git a/tests/lib/test-helpers.ts b/tests/lib/test-helpers.ts new file mode 100644 index 0000000..d0770d6 --- /dev/null +++ b/tests/lib/test-helpers.ts @@ -0,0 +1,95 @@ +import { vi } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import { factory } from '../../lib/reducer/index'; +import { type CdeebeeSettings } from '../../lib/reducer/types'; + +// Mock fetch globally +global.fetch = vi.fn(); + +// Helper to create mock Response +export const createMockResponse = (options: { + ok?: boolean; + status?: number; + statusText?: string; + contentType?: string; + json?: () => Promise; + text?: () => Promise; + blob?: () => Promise; +}): Response => { + const { + ok = true, + status = 200, + statusText = 'OK', + contentType = 'application/json', + json = async () => ({}), + text = async () => '', + blob = async () => new Blob(), + } = options; + + return { + ok, + status, + statusText, + headers: { + get: (name: string) => { + if (name.toLowerCase() === 'content-type') { + return contentType; + } + return null; + }, + }, + json, + text, + blob, + } as unknown as Response; +}; + +// Helper to create store with proper middleware configuration for tests +export const createTestStore = >( + customSettings?: CdeebeeSettings, + initialStorage?: T +) => { + const slice = factory(customSettings || ({} as CdeebeeSettings), initialStorage); + return configureStore({ + reducer: { + cdeebee: slice.reducer as any, + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: { + ignoredPaths: ['cdeebee.settings.normalize'], + }, + }), + }); +}; + +// Helper to mock fetch with response +export const mockFetch = (response: Response) => { + (global.fetch as ReturnType).mockResolvedValueOnce(response); +}; + +// Helper to mock fetch with multiple responses +export const mockFetchMultiple = (responses: Response[]) => { + responses.forEach(response => { + (global.fetch as ReturnType).mockResolvedValueOnce(response); + }); +}; + +// Helper to mock fetch that always returns the same response +export const mockFetchAlways = (response: Response) => { + (global.fetch as ReturnType).mockResolvedValue(response); +}; + +// Default settings for tests +export const defaultTestSettings = >( + overrides?: Partial> +): CdeebeeSettings => ({ + modules: ['history', 'listener', 'storage', 'cancelation'], + fileKey: 'file', + bodyKey: 'value', + mergeWithData: {}, + mergeWithHeaders: {}, + listStrategy: {}, + ...overrides, +} as CdeebeeSettings); +