diff --git a/package.json b/package.json index b04697c..364b9be 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "observable-hooks": "^4.2.3", "rxdb": "15.24.0", "rxjs": "^7.8.1", - "typescript": "^5.4.5" + "uuid": "10.0.0" }, "devDependencies": { "@babel/core": "^7.24.6", @@ -37,6 +37,8 @@ "babel-jest": "^29.7.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "ts-jest": "^29.1.5" + "rxjs-marbles": "^7.0.1", + "ts-jest": "^29.1.5", + "typescript": "^5.4.5" } } diff --git a/src/query-state.ts b/src/query-state.ts index a3468b0..ab5339c 100644 --- a/src/query-state.ts +++ b/src/query-state.ts @@ -15,7 +15,7 @@ import { combineLatest, catchError, } from 'rxjs'; -import { map, switchMap, distinctUntilChanged, debounceTime, tap } from 'rxjs/operators'; +import { map, switchMap, distinctUntilChanged, debounceTime, tap, startWith } from 'rxjs/operators'; import { SubscribableBase } from './subscribable-base'; @@ -144,6 +144,7 @@ export class Query extends SubscribableBase { this.find$ .pipe( distinctUntilChanged((prev, next) => { + console.log('DistinctUntilChanged', prev, next); // Check if search is active and searchTerm has changed if (prev.searchActive !== next.searchActive || prev.searchTerm !== next.searchTerm) { return false; diff --git a/tests/__mocks__/http.ts b/tests/__mocks__/http.ts index 5b9fd5e..5dda2a6 100644 --- a/tests/__mocks__/http.ts +++ b/tests/__mocks__/http.ts @@ -1,9 +1,60 @@ -const httpClientMock = { - get: jest.fn().mockResolvedValue({ data: {} }), - post: jest.fn().mockResolvedValue({ data: {} }), - put: jest.fn().mockResolvedValue({ data: {} }), - delete: jest.fn().mockResolvedValue({ data: {} }), - // ... other methods that your httpClient might have +import { AxiosRequestConfig } from 'axios'; +import { Mock } from 'jest-mock'; + +interface MockResponse { + [key: string]: any; +} + +interface HttpClientMock { + get: Mock; + post: Mock; + put: Mock; + delete: Mock; + __setMockResponse: (method: HttpMethod, url: string, params: Record, response: any) => void; + __resetMockResponses: () => void; +} + +type HttpMethod = 'get' | 'post' | 'put' | 'delete'; + +const mockResponses: Record = {}; + +const httpClientMock: HttpClientMock = { + get: jest.fn((url: string, config?: AxiosRequestConfig) => { + return resolveResponse('get', url, config); + }), + post: jest.fn((url: string, data: any, config?: AxiosRequestConfig) => { + return resolveResponse('post', url, config, data); + }), + put: jest.fn((url: string, data: any, config?: AxiosRequestConfig) => { + return resolveResponse('put', url, config, data); + }), + delete: jest.fn((url: string, config?: AxiosRequestConfig) => { + return resolveResponse('delete', url, config); + }), + __setMockResponse: (method: HttpMethod, url: string, params: Record, response: any) => { + const key = `${method}:${url}:${JSON.stringify(params) || ''}`; + mockResponses[key] = response; + }, + __resetMockResponses: () => { + Object.keys(mockResponses).forEach(key => delete mockResponses[key]); + }, +}; + +const standardErrorResponses = { + unauthorized: { status: 401, data: { message: 'Not authorized' } }, + serverError: { status: 500, data: { message: 'Internal server error' } }, + badRequest: { status: 400, data: { message: 'Bad request' } }, }; -export default httpClientMock; +function resolveResponse(method: HttpMethod, url: string, config?: AxiosRequestConfig, data?: any) { + const key = `${method}:${url}:${JSON.stringify(config?.params) || ''}`; + if (mockResponses[key]) { + return Promise.resolve({ data: mockResponses[key] }); + } else if (mockResponses[method] && mockResponses[method][url]) { + return Promise.resolve({ data: mockResponses[method][url] }); + } else { + return Promise.resolve({ data: [] }); // Default response is an empty array + } +} + +export { httpClientMock, standardErrorResponses }; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 7089cac..17e10fd 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -8,6 +8,7 @@ import { } from 'rxdb'; // import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode'; import { getRxStorageMemory } from 'rxdb/plugins/storage-memory'; +import { RxDBGenerateIdPlugin } from './generate-id'; import { logsLiteral } from './schemas/logs'; import { productsLiteral } from './schemas/products'; @@ -17,6 +18,7 @@ import { variationsLiteral } from './schemas/variations'; import type { RxCollectionCreator, RxCollection, RxDocument } from 'rxdb'; // addRxPlugin(RxDBDevModePlugin); +addRxPlugin(RxDBGenerateIdPlugin); /** * Products diff --git a/tests/helpers/generate-id.ts b/tests/helpers/generate-id.ts new file mode 100644 index 0000000..5fa3dc3 --- /dev/null +++ b/tests/helpers/generate-id.ts @@ -0,0 +1,57 @@ +import { RxCollection, RxPlugin } from 'rxdb'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Generate a UUID if the primary key is not set + */ +export function generateID(this: RxCollection, data: Record) { + const primaryPath = this.schema.primaryPath; + const hasMetaData = this.schema.jsonSchema.properties.meta_data; + let metaUUID; + + if (hasMetaData) { + data.meta_data = data.meta_data || []; + const meta = data.meta_data.find((meta: any) => meta.key === '_woocommerce_pos_uuid'); + metaUUID = meta && meta.value; + } + + if (!data[primaryPath] && metaUUID) { + data[primaryPath] = metaUUID; + } else if (!data[primaryPath]) { + const uuid = uuidv4(); + // + if (primaryPath === 'uuid') { + data.uuid = uuid; + } else if (primaryPath === 'logId') { + data.logId = uuid; + } else if (primaryPath === 'localID') { + data.localID = uuid.slice(0, 8); // only short id required here + } + } + + if (hasMetaData && !metaUUID) { + data.meta_data.push({ + key: '_woocommerce_pos_uuid', + value: data[primaryPath], + }); + } +} + +export const RxDBGenerateIdPlugin: RxPlugin = { + name: 'generate-id', + rxdb: true, + prototypes: { + RxCollection: (proto: any) => { + proto.generateID = generateID; + }, + }, + overwritable: {}, + hooks: { + createRxCollection: { + after({ collection }) { + collection.preInsert(generateID, false); + collection.preSave(generateID, false); + }, + }, + }, +}; diff --git a/tests/manager.test.ts b/tests/manager.test.ts index 3392628..7fa72dc 100644 --- a/tests/manager.test.ts +++ b/tests/manager.test.ts @@ -18,9 +18,10 @@ describe('Manager', () => { }); afterEach(() => { - jest.clearAllMocks(); storeDatabase.remove(); syncDatabase.remove(); + manager.cancel(); + jest.clearAllMocks(); }); describe('Query States', () => { diff --git a/tests/provider.test.tsx b/tests/provider.test.tsx index 9ca6220..d6aadb8 100644 --- a/tests/provider.test.tsx +++ b/tests/provider.test.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { marbles } from 'rxjs-marbles/jest'; -import httpClientMock from './__mocks__/http'; +import { httpClientMock } from './__mocks__/http'; import { createStoreDatabase, createSyncDatabase } from './helpers/db'; import { QueryProvider, useQueryManager } from '../src/provider'; import { useQuery } from '../src/use-query'; @@ -21,11 +22,11 @@ describe('QueryProvider', () => { syncDatabase = await createSyncDatabase(); }); - afterEach(() => { - jest.clearAllMocks(); - storeDatabase.remove(); - syncDatabase.remove(); + afterEach(async () => { + await storeDatabase.destroy(); + await syncDatabase.destroy(); cleanup(); + jest.clearAllMocks(); }); it('should provide a Manager instance', () => { @@ -47,7 +48,7 @@ describe('QueryProvider', () => { ); }); - it('should create and retrieve a query instance', () => { + it('should create and retrieve a query instance', async () => { // TestComponent1 uses useQuery to create a query const TestComponent1 = () => { const query = useQuery({ queryKeys: ['myQuery'], collectionName: 'products' }); @@ -74,34 +75,34 @@ describe('QueryProvider', () => { ); + + /** + * It's important to wait for the render to complete before making assertions. + * In this case the 'maybeCreateSearchDB' is created asynchronously and cleanup is called + * before the searchDB is created. + * + * @TODO - find a better way to handle this + */ + await waitFor(() => { + expect(true).toBe(true); // Placeholder to wait for render + }); }); it('should have initial values for query.params$ and query.result$', (done) => { const TestComponent = () => { const query = useQuery({ queryKeys: ['myQuery'], collectionName: 'products' }); - const paramsPromise = new Promise((resolve) => { - const paramsSubscription = query?.params$.subscribe((params) => { - resolve(params); - paramsSubscription?.unsubscribe(); - }); - }); - - const dataPromise = new Promise((resolve) => { - const querySubscription = query?.result$.subscribe((data) => { - resolve(data); - querySubscription?.unsubscribe(); - }); + query.params$.subscribe((params) => { + expect(params).toEqual({}); }); - Promise.all([paramsPromise, dataPromise]).then(([params, data]) => { - try { - expect(params).toEqual({}); // Expect params to be an empty object - expect(data).toEqual([]); // Expect data to be an empty array - done(); - } catch (error) { - done(error); - } + query.result$.subscribe((result) => { + expect(result).toEqual(expect.objectContaining({ + searchActive: false, + hits: [], + count: 0 + })); + done(); }); return
;