diff --git a/webapp/packages/core-sdk/package.json b/webapp/packages/core-sdk/package.json index a9bcb942db..657f5471e2 100644 --- a/webapp/packages/core-sdk/package.json +++ b/webapp/packages/core-sdk/package.json @@ -17,6 +17,7 @@ "gql:gen:dev": "graphql-codegen --watch", "lint": "eslint ./src/ --ext .ts,.tsx", "lint-fix": "eslint ./src/ --ext .ts,.tsx --fix", + "test": "core-cli-test", "validate-dependencies": "core-cli-validate-dependencies" }, "dependencies": { diff --git a/webapp/packages/core-sdk/src/Resource/CachedDataResource.test.ts b/webapp/packages/core-sdk/src/Resource/CachedDataResource.test.ts new file mode 100644 index 0000000000..090cc5c880 --- /dev/null +++ b/webapp/packages/core-sdk/src/Resource/CachedDataResource.test.ts @@ -0,0 +1,95 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { CachedDataResource } from './CachedDataResource'; + +interface IEntityData { + id: string; + value: number; +} + +const DEFAULT_STATE_GETTER: () => IEntityData[] = () => []; +const DATA_MOCK_GETTER: () => IEntityData[] = () => [ + { + id: '1', + value: 1, + }, + { + id: '2', + value: 2, + }, +]; + +async function fetchMock(): Promise { + return new Promise(resolve => { + setTimeout(() => { + resolve(DATA_MOCK_GETTER()); + }, 1); + }); +} + +class TestDataResource extends CachedDataResource { + constructor() { + super(DEFAULT_STATE_GETTER); + } + + protected async loader() { + const data = await fetchMock(); + return data; + } +} + +describe('CachedDataResource', () => { + let dataResource: TestDataResource; + + beforeEach(() => { + dataResource = new TestDataResource(); + }); + + test('should instantiate correctly', () => { + expect(dataResource).toBeInstanceOf(CachedDataResource); + }); + + test('should initialize with the default state', () => { + expect(dataResource.data).toEqual(DEFAULT_STATE_GETTER()); + }); + + test('should load data', async () => { + await dataResource.load(); + expect(dataResource.data).toEqual(DATA_MOCK_GETTER()); + }); + + test('should be able to outdate the data', () => { + dataResource.markOutdated(); + expect(dataResource.isOutdated()).toBe(true); + }); + + test('should run onDataOutdated handlers on data outdate', () => { + const handler = jest.fn(); + + dataResource.onDataOutdated.addHandler(handler); + dataResource.markOutdated(); + + expect(handler).toHaveBeenCalled(); + }); + + test('should run onDataUpdate handlers on data update', () => { + const handler = jest.fn(); + + dataResource.onDataUpdate.addHandler(handler); + dataResource.dataUpdate(); + + expect(handler).toHaveBeenCalled(); + }); + + test('should be able to clear the data', async () => { + await dataResource.load(); + dataResource.clear(); + + expect(dataResource.data).toEqual([]); + }); +}); diff --git a/webapp/packages/core-sdk/src/Resource/CachedMapResource.test.ts b/webapp/packages/core-sdk/src/Resource/CachedMapResource.test.ts new file mode 100644 index 0000000000..7981a80274 --- /dev/null +++ b/webapp/packages/core-sdk/src/Resource/CachedMapResource.test.ts @@ -0,0 +1,254 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { toJS } from 'mobx'; + +import { CachedMapResource } from './CachedMapResource'; +import type { ResourceKey } from './ResourceKey'; +import { resourceKeyList } from './ResourceKeyList'; + +interface IEntityData { + id: string; + value: number; +} + +const ERROR_ITEM_ID = 'error'; + +const DATA_MOCK_GETTER: () => IEntityData[] = () => [ + { + id: '1', + value: 1, + }, + { + id: '2', + value: 2, + }, + { + id: ERROR_ITEM_ID, + value: 3, + }, +]; + +const TEST_ERROR_MESSAGE = 'Test error'; +const DEFAULT_STATE_GETTER = () => new Map(); + +async function fetchMock(key: ResourceKey | undefined): Promise { + const data = DATA_MOCK_GETTER(); + + return new Promise((resolve, reject) => { + setTimeout(() => { + if (key) { + if (key === ERROR_ITEM_ID) { + reject(new Error(TEST_ERROR_MESSAGE)); + } + + const item = data.find(d => d.id === key); + if (item) { + resolve([item]); + } + } else { + resolve(data); + } + }, 1); + }); +} + +class TestMapResource extends CachedMapResource { + constructor() { + super(DEFAULT_STATE_GETTER); + } + + protected async loader(key: ResourceKey) { + const data = await fetchMock(key); + this.replace(resourceKeyList(data.map(d => d.id)), data); + return this.data; + } + + protected validateKey(key: string): boolean { + return typeof key === 'string'; + } +} + +describe('CachedMapResource', () => { + let mapResource: TestMapResource; + + beforeEach(() => { + mapResource = new TestMapResource(); + }); + + test('should instantiate correctly', () => { + expect(mapResource).toBeInstanceOf(CachedMapResource); + }); + + test('should initialize with the initial value', () => { + expect(toJS(mapResource.data)).toEqual(DEFAULT_STATE_GETTER()); + }); + + test('should return all entries', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + expect(mapResource.entries).toEqual([ + ['key1', { id: 'key1', value: 1 }], + ['key2', { id: 'key2', value: 2 }], + ]); + }); + + test('should return all values', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + expect(mapResource.values).toEqual([ + { id: 'key1', value: 1 }, + { id: 'key2', value: 2 }, + ]); + }); + + test('should return all keys', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + expect(mapResource.keys).toEqual(['key1', 'key2']); + }); + + test('should load data for a specific key', async () => { + await mapResource.load('1'); + expect(mapResource.get('1')).toEqual({ id: '1', value: 1 }); + expect(mapResource.get('2')).toBeUndefined(); // This key was not loaded + }); + + test('should NOT load data for a key that produces an error', async () => { + await expect(mapResource.load(ERROR_ITEM_ID)).rejects.toThrow(TEST_ERROR_MESSAGE); + expect(mapResource.get(ERROR_ITEM_ID)).toBeUndefined(); + }); + + test('should mark loaded data as loaded', async () => { + await mapResource.load('1'); + expect(mapResource.isLoaded('1')).toBe(true); + }); + + test('should set and get a value', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + expect(mapResource.get('key1')).toStrictEqual({ id: 'key1', value: 1 }); + }); + + test('should delete a value', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.delete('key1'); + expect(mapResource.get('key1')).toBeUndefined(); + }); + + test('should check if a key exists', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + expect(mapResource.has('key1')).toBe(true); + expect(mapResource.has('key2')).toBe(false); + }); + + test('should replace multiple keys', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + mapResource.set('key4', { id: 'key4', value: 4 }); + + mapResource.replace(resourceKeyList(['key1', 'key3']), [ + { id: 'key1', value: 11 }, + { id: 'key3', value: 33 }, + ]); + + expect(mapResource.get('key1')).toStrictEqual({ id: 'key1', value: 11 }); + expect(mapResource.get('key2')).toBeUndefined(); + expect(mapResource.get('key3')).toStrictEqual({ id: 'key3', value: 33 }); + expect(mapResource.data.size).toBe(2); + }); + + test('should outdated certain keys', () => { + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + + mapResource.markOutdated('key1'); + + expect(mapResource.isOutdated('key1')).toBe(true); + expect(mapResource.isOutdated('key2')).toBe(false); + }); + + test('should run onDataOutdated handlers on data outdate', () => { + const handler = jest.fn(); + + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + + mapResource.onDataOutdated.addHandler(key => { + handler(); + expect(key).toBe('key1'); + }); + + mapResource.markOutdated('key1'); + expect(handler).toHaveBeenCalled(); + }); + + test('should run onDataUpdate handlers on data update', () => { + const handler = jest.fn(); + + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + + mapResource.onDataUpdate.addHandler(key => { + handler(); + expect(key).toBe('key2'); + }); + + mapResource.dataUpdate('key2'); + expect(handler).toHaveBeenCalled(); + }); + + test('should run onItemDelete handlers on data delete', () => { + const handler = jest.fn(); + + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + + mapResource.onItemDelete.addHandler(key => { + handler(); + expect(key).toBe('key1'); + }); + + mapResource.delete('key1'); + expect(handler).toHaveBeenCalled(); + }); + + test('should run onItemUpdate handlers on item update', () => { + const handler = jest.fn(); + + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + + mapResource.onItemUpdate.addHandler(key => { + handler(); + expect(key).toBe('key2'); + }); + + mapResource.set('key2', { id: 'key2', value: 22 }); + expect(handler).toHaveBeenCalled(); + }); + + test('should run onDataError handlers on data error', async () => { + const handler = jest.fn(); + + mapResource.set('key1', { id: 'key1', value: 1 }); + mapResource.set('key2', { id: 'key2', value: 2 }); + + mapResource.onDataError.addHandler(data => { + handler(); + expect(data.param).toBe(ERROR_ITEM_ID); + expect(data.exception.message).toBe(TEST_ERROR_MESSAGE); + }); + + await expect(mapResource.load(ERROR_ITEM_ID)).rejects.toThrow(TEST_ERROR_MESSAGE); + expect(handler).toHaveBeenCalled(); + }); + + test('should be able to get an exception if the one occurred for the key', async () => { + await expect(mapResource.load(ERROR_ITEM_ID)).rejects.toThrow(TEST_ERROR_MESSAGE); + expect(mapResource.getException(ERROR_ITEM_ID)?.message).toBe(TEST_ERROR_MESSAGE); + }); +}); diff --git a/webapp/packages/core-sdk/src/Resource/CachedTreeResource.test.ts b/webapp/packages/core-sdk/src/Resource/CachedTreeResource.test.ts new file mode 100644 index 0000000000..fa54970887 --- /dev/null +++ b/webapp/packages/core-sdk/src/Resource/CachedTreeResource.test.ts @@ -0,0 +1,138 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { CachedTreeChildrenKey, CachedTreeResource, CachedTreeRootChildrenKey } from './CachedTreeResource'; +import type { ResourceKey } from './ResourceKey'; +import { resourceKeyList } from './ResourceKeyList'; + +interface IMockDataEntity { + name: string; +} + +class TestTreeResource extends CachedTreeResource { + constructor() { + super(); + } + + protected async loader(key: ResourceKey) { + return this.data; + } + + protected validateKey(key: string): boolean { + return typeof key === 'string'; + } +} + +describe('CachedMapResource', () => { + let treeResource: TestTreeResource; + + beforeEach(() => { + treeResource = new TestTreeResource(); + }); + + test('should instantiate correctly', () => { + expect(treeResource).toBeInstanceOf(CachedTreeResource); + }); + + test('should set data correctly', () => { + treeResource.set('root', { name: 'root' }); + expect(treeResource.get('root')).toEqual({ name: 'root' }); + }); + + test('.has should check if node exists correctly', () => { + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + + expect(treeResource.has('root/level2')).toBe(true); + expect(treeResource.has('root/level3')).toBe(false); + }); + + test('the node should be outdated after markOutdated is called on it', () => { + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + treeResource.markOutdated('root'); + + expect(treeResource.isOutdated('root')).toBe(true); + expect(treeResource.isOutdated('root/level2')).toBe(false); + }); + + test('the nodes metadata should be outdated after markOutdated is called on it', () => { + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + treeResource.markOutdated('root'); + + expect(treeResource.getMetadata('root').outdated).toBe(true); + expect(treeResource.getMetadata('root/level2').outdated).toBe(false); + }); + + test('should run onDataOutdated handlers on data outdate', () => { + const handler = jest.fn(); + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + + treeResource.onDataOutdated.addHandler(key => { + handler(); + expect(key).toBe('root'); + }); + + treeResource.markOutdated('root'); + + expect(treeResource.isOutdated('root')).toBe(true); + expect(handler).toHaveBeenCalled(); + }); + + test('CachedTreeChildrenKey alias should return key children of the node', () => { + const handler = jest.fn(); + + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + treeResource.set('root/level2/level3', { name: 'level3' }); + + treeResource.onDataOutdated.addHandler(key => { + handler(); + expect(key).toEqual(resourceKeyList(['root/level2'])); + }); + + treeResource.markOutdated(CachedTreeChildrenKey('root')); + + expect(handler).toHaveBeenCalled(); + }); + + test('CachedTreeRootChildrenKey alias should return children for the root node', () => { + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + treeResource.set('root/level2/level3', { name: 'level3' }); + + expect(treeResource.get(CachedTreeRootChildrenKey)).toEqual([{ name: 'root' }]); + }); + + test('should be able to delete the node', () => { + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + + treeResource.delete('root/level2'); + + expect(treeResource.has('root/level2')).toBe(false); + expect(treeResource.has('root')).toBe(true); + }); + + test('should run onItemDelete handlers on data delete', () => { + const handler = jest.fn(); + + treeResource.set('root', { name: 'root' }); + treeResource.set('root/level2', { name: 'level2' }); + + treeResource.onItemDelete.addHandler(key => { + handler(); + expect(key).toBe('root/level2'); + }); + + treeResource.delete('root/level2'); + + expect(handler).toHaveBeenCalled(); + }); +});