diff --git a/src/models/fixtures/pluginFixture.ts b/src/models/fixtures/pluginFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e1d83f8fe79ef99b8f6326fb1f392387752e884 --- /dev/null +++ b/src/models/fixtures/pluginFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pluginSchema } from '../pluginSchema'; + +export const pluginFixture = createFixture(pluginSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/pluginSchema.ts b/src/models/pluginSchema.ts index 0204b6f05d9e797c3cbad124eb063222bcc3e6f3..2cfb0725fc31a652b5db6d5e38996002eb262af9 100644 --- a/src/models/pluginSchema.ts +++ b/src/models/pluginSchema.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-magic-numbers */ import { z } from 'zod'; export const pluginSchema = z.object({ @@ -6,5 +7,5 @@ export const pluginSchema = z.object({ version: z.string(), isPublic: z.boolean(), isDefault: z.boolean(), - urls: z.array(z.string()), + urls: z.array(z.string().min(1)), }); diff --git a/src/redux/plugins/overlays.mock.ts b/src/redux/plugins/plugins.mock.ts similarity index 100% rename from src/redux/plugins/overlays.mock.ts rename to src/redux/plugins/plugins.mock.ts diff --git a/src/redux/plugins/plugins.reducers.test.ts b/src/redux/plugins/plugins.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..faf47803f3f3537557e86386a69b441ed2e7a3d2 --- /dev/null +++ b/src/redux/plugins/plugins.reducers.test.ts @@ -0,0 +1,102 @@ +/* eslint-disable no-magic-numbers */ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { apiPath } from '../apiPath'; +import { PluginsState } from './plugins.types'; +import pluginsReducer, { removePlugin } from './plugins.slice'; +import { registerPlugin } from './plugins.thunk'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: PluginsState = { + data: {}, + pluginsId: [], +}; + +describe('plugins reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<PluginsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('plugins', pluginsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(pluginsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should remove overlay from store properly', () => { + const { type, payload } = store.dispatch( + removePlugin({ + pluginId: 'hash1', + }), + ); + + expect(type).toBe('plugins/removePlugin'); + expect(payload).toEqual({ pluginId: 'hash1' }); + }); + it('should update store after successful registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture); + + const { type } = await store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + expect(type).toBe('plugins/registerPlugin/fulfilled'); + const { data, pluginsId } = store.getState().plugins; + + expect(data[pluginFixture.hash]).toEqual(pluginFixture); + expect(pluginsId).toContain(pluginFixture.hash); + }); + + it('should update store after failed registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, undefined); + + const { type, payload } = await store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + expect(type).toBe('plugins/registerPlugin/rejected'); + expect(payload).toEqual(undefined); + const { data, pluginsId } = store.getState().plugins; + + expect(data).toEqual({}); + + expect(pluginsId).not.toContain(pluginFixture.hash); + }); + + it('should update store on loading registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, undefined); + + store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + const { data, pluginsId } = store.getState().plugins; + + expect(data).toEqual({}); + expect(pluginsId).toContain(pluginFixture.hash); + }); +}); diff --git a/src/redux/plugins/plugins.reducers.ts b/src/redux/plugins/plugins.reducers.ts index 4ce46c2417cea0b4aed58c89b12648a865c70056..af01fcfcd7d2ba2ea63be763e828ea1cea6cc4ba 100644 --- a/src/redux/plugins/plugins.reducers.ts +++ b/src/redux/plugins/plugins.reducers.ts @@ -20,11 +20,7 @@ export const registerPluginReducer = (builder: ActionReducerMapBuilder<PluginsSt state.data[hash] = action.payload; } }); - builder.addCase(registerPlugin.rejected, (state, action) => { - if (action.payload) { - const { hash } = action.meta.arg; - - state.pluginsId = state.pluginsId.filter(pluginId => pluginId !== hash); - } + builder.addCase(registerPlugin.rejected, state => { + state.pluginsId = []; }); }; diff --git a/src/redux/plugins/plugins.thunk.test.ts b/src/redux/plugins/plugins.thunk.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2ae3d53f8d9c0df763d0d3241b38e654692a3ba --- /dev/null +++ b/src/redux/plugins/plugins.thunk.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-magic-numbers */ +import axios, { HttpStatusCode } from 'axios'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import MockAdapter from 'axios-mock-adapter'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { apiPath } from '../apiPath'; +import { PluginsState } from './plugins.types'; +import pluginsReducer from './plugins.slice'; +import { getInitPlugins } from './plugins.thunk'; + +const mockedAxiosApiClient = mockNetworkResponse(); +const mockedAxiosClient = new MockAdapter(axios); + +describe('plugins - thunks', () => { + describe('getInitPlugins', () => { + let store = {} as ToolkitStoreWithSingleSlice<PluginsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('plugins', pluginsReducer); + }); + const setHashedPluginMock = jest.fn(); + + beforeEach(() => { + setHashedPluginMock.mockClear(); + }); + + it('should fetch and load initial plugins', async () => { + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture); + mockedAxiosApiClient + .onGet(apiPath.getPlugin(pluginFixture.hash)) + .reply(HttpStatusCode.Ok, pluginFixture); + mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.Ok, ''); + + await store.dispatch( + getInitPlugins({ + pluginsId: [pluginFixture.hash], + setHashedPlugin: setHashedPluginMock, + }), + ); + + expect(setHashedPluginMock).toHaveBeenCalledTimes(1); + }); + it('should not load plugin if fetched plugin is not valid', async () => { + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, {}); + mockedAxiosApiClient + .onGet(apiPath.getPlugin(pluginFixture.hash)) + .reply(HttpStatusCode.NotFound, {}); + mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.NotFound, ''); + + await store.dispatch( + getInitPlugins({ + pluginsId: [pluginFixture.hash], + setHashedPlugin: setHashedPluginMock, + }), + ); + + expect(setHashedPluginMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index e7b83da91015af644a20e7d39c949ec5580a5049..bfb6570be66aa04a3c47be5f9c9a26a46451b25f 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -20,7 +20,7 @@ import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; import { COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK } from '../compartmentPathways/compartmentPathways.mock'; import { EXPORT_INITIAL_STATE_MOCK } from '../export/export.mock'; -import { PLUGINS_INITIAL_STATE_MOCK } from '../plugins/overlays.mock'; +import { PLUGINS_INITIAL_STATE_MOCK } from '../plugins/plugins.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, diff --git a/src/services/pluginsManager/pluginsManager.test.ts b/src/services/pluginsManager/pluginsManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9b55d2712e3cb23ec806e3440d373fd06bcd3d3 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable no-magic-numbers */ +import { store } from '@/redux/store'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { configurationMapper } from './pluginsManager.utils'; +import { PluginsManager } from './pluginsManager'; + +jest.mock('../../redux/store'); + +describe('PluginsManager', () => { + const originalWindow = { ...global.window }; + + beforeEach(() => { + global.window = { ...originalWindow }; + }); + + afterEach(() => { + global.window = originalWindow; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('setHashedPlugin correctly computes hash and updates hashedPlugins', () => { + const pluginUrl = 'https://example.com/plugin.js'; + const pluginScript = 'console.log("Hello, Plugin!");'; + + PluginsManager.setHashedPlugin({ pluginUrl, pluginScript }); + + expect(PluginsManager.hashedPlugins[pluginUrl]).toBe('edc7eeafccc9e1ab66f713298425947b'); + }); + + it('init subscribes to store changes and updates minerva configuration', () => { + (store.getState as jest.Mock).mockReturnValueOnce({ + configuration: { main: { data: configurationFixture } }, + }); + + PluginsManager.init(); + + expect(store.subscribe).toHaveBeenCalled(); + + // Simulate store change + (store.subscribe as jest.Mock).mock.calls[0][0](); + + expect(store.getState).toHaveBeenCalled(); + expect(window.minerva.configuration).toEqual(configurationMapper(configurationFixture)); + }); + it('init does not update minerva configuration when configuration is undefined', () => { + (store.getState as jest.Mock).mockReturnValueOnce({ + configuration: { main: { data: undefined } }, + }); + + PluginsManager.init(); + + expect(store.subscribe).toHaveBeenCalled(); + + // Simulate store change + (store.subscribe as jest.Mock).mock.calls[0][0](); + + expect(store.getState).toHaveBeenCalled(); + expect(window.minerva.configuration).toBeUndefined(); + }); + + it('registerPlugin dispatches action and returns element', () => { + const pluginName = 'TestPlugin'; + const pluginVersion = '1.0.0'; + const pluginUrl = 'https://example.com/test-plugin.js'; + + const result = PluginsManager.registerPlugin({ pluginName, pluginVersion, pluginUrl }); + + expect(store.dispatch).toHaveBeenCalled(); + + expect(result.element).toBeDefined(); + }); +});