From b8adaee866cf2a02688f03237e5e30ccf585f43a Mon Sep 17 00:00:00 2001 From: ZaeemSheikh <124173573+SheikhZaeem@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:04:21 +0300 Subject: [PATCH] convert collection modal tests to Vue Testing Library --- .../__tests__/channelSetModal.spec.js | 436 +++++++++--------- 1 file changed, 215 insertions(+), 221 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js index a1dd15c4fa..db2939cb5d 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetModal.spec.js @@ -1,20 +1,17 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { render, screen, waitFor } from '@testing-library/vue'; +import { configure } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import Vuex, { Store } from 'vuex'; import VueRouter from 'vue-router'; -import cloneDeep from 'lodash/cloneDeep'; -import flushPromises from 'flush-promises'; import { RouteNames } from '../../../constants'; -import router from '../../../router'; -import channelSet from '../../../vuex/channelSet'; import ChannelSetModal from '../ChannelSetModal'; -import channel from 'shared/vuex/channel'; -import storeFactory from 'shared/vuex/baseStore'; jest.mock('kolibri-design-system/lib/composables/useKShow', () => ({ __esModule: true, default: () => ({ - show: () => false, // skip loading state + show: () => false, }), })); @@ -22,283 +19,280 @@ const localVue = createLocalVue(); localVue.use(Vuex); localVue.use(VueRouter); -const STORE_CONFIG = { - modules: { channel, channelSet }, -}; - const CHANNEL_1 = { id: 'id-channel-1', name: 'Channel 1', description: 'First channel description', + editable: true, + published: true, + version: 1, }; + const CHANNEL_2 = { id: 'id-channel-2', name: 'Channel 2', description: 'Second channel description', + editable: true, + published: true, + version: 2, }; const CHANNEL_SET = { id: 'id-channel-set', + name: 'My collection', channels: { [CHANNEL_1.id]: true, [CHANNEL_2.id]: true, }, }; -const makeWrapper = ({ store, channelSetId }) => { - if (router.currentRoute.name !== RouteNames.CHANNEL_SET_DETAILS) { - router.push({ - name: RouteNames.CHANNEL_SET_DETAILS, - params: { - channelSetId, - }, - }); - } - - const loadChannelSet = jest.spyOn(ChannelSetModal.methods, 'loadChannelSet'); - loadChannelSet.mockImplementation(() => { - store.commit('channelSet/ADD_CHANNELSET', CHANNEL_SET); - return Promise.resolve(CHANNEL_SET); - }); - const loadChannelList = jest.spyOn(ChannelSetModal.methods, 'loadChannelList'); - loadChannelList.mockImplementation(() => Promise.resolve(CHANNEL_SET.channels)); +const mockActions = { + loadChannelSet: jest.fn(({ commit }, id) => { + if (id === CHANNEL_SET.id) { + commit('channelSet/ADD_CHANNELSET', CHANNEL_SET, { root: true }); + return Promise.resolve(CHANNEL_SET); + } + return Promise.resolve(null); + }), + loadChannelList: jest.fn(() => Promise.resolve()), + updateChannelSet: jest.fn(() => Promise.resolve()), + commitChannelSet: jest.fn(() => Promise.resolve({ id: 'new-collection-id' })), + deleteChannelSet: jest.fn(() => Promise.resolve()), + addChannels: jest.fn(() => Promise.resolve()), + removeChannels: jest.fn(() => Promise.resolve()), + showSnackbarSimple: jest.fn(() => Promise.resolve()), +}; - const wrapper = mount(ChannelSetModal, { - propsData: { - channelSetId, +const makeStore = ({ + channelSet = CHANNEL_SET, + channels = [CHANNEL_1, CHANNEL_2], + preloadChannelSet = true, +} = {}) => { + const channelsMap = channels.reduce((map, channel) => { + map[channel.id] = channel; + return map; + }, {}); + + const channelSetsMap = preloadChannelSet && channelSet ? { [channelSet.id]: channelSet } : {}; + + return new Store({ + state: { + connection: { + online: true, + }, + }, + modules: { + channel: { + namespaced: true, + state: { + channelsMap, + }, + getters: { + channels: state => Object.values(state.channelsMap), + getChannel: state => id => state.channelsMap[id], + }, + actions: { + loadChannelList: mockActions.loadChannelList, + }, + }, + channelSet: { + namespaced: true, + state: { + channelSetsMap, + }, + getters: { + getChannelSet: state => id => { + const set = state.channelSetsMap[id]; + if (!set) { + return; + } + + return { + ...set, + channels: Object.keys(set.channels || {}), + }; + }, + }, + mutations: { + ADD_CHANNELSET(state, data) { + state.channelSetsMap = { + ...state.channelSetsMap, + [data.id]: data, + }; + }, + }, + actions: { + loadChannelSet: mockActions.loadChannelSet, + updateChannelSet: mockActions.updateChannelSet, + commitChannelSet: mockActions.commitChannelSet, + deleteChannelSet: mockActions.deleteChannelSet, + addChannels: mockActions.addChannels, + removeChannels: mockActions.removeChannels, + }, + }, + }, + actions: { + showSnackbarSimple: mockActions.showSnackbarSimple, }, - router, - localVue, - store, }); - return [wrapper, { loadChannelSet, loadChannelList }]; -}; - -const getCollectionNameInput = wrapper => { - return wrapper.findComponent('[data-test="input-name"]'); -}; - -const getUnsavedDialog = wrapper => { - return wrapper.findComponent('[data-test="dialog-unsaved"]'); }; -const getCloseButton = wrapper => { - return wrapper.findComponent('[data-test="close"]'); +const makeRouter = () => { + return new VueRouter({ + routes: [ + { + name: RouteNames.CHANNEL_SETS, + path: '/collections', + component: { template: '
Collections
' }, + }, + { + name: RouteNames.NEW_CHANNEL_SET, + path: '/collections/new', + component: ChannelSetModal, + props: true, + }, + { + name: RouteNames.CHANNEL_SET_DETAILS, + path: '/collections/:channelSetId', + component: ChannelSetModal, + props: true, + }, + ], + }); }; -const getSaveButton = wrapper => { - return wrapper.findComponent('[data-test="button-save"]'); -}; +const renderComponent = async ({ + routeName = RouteNames.CHANNEL_SET_DETAILS, + channelSetId = CHANNEL_SET.id, + channelSet = CHANNEL_SET, + channels = [CHANNEL_1, CHANNEL_2], + preloadChannelSet = true, +} = {}) => { + const store = makeStore({ channelSet, channels, preloadChannelSet }); + const router = makeRouter(); + + if (routeName === RouteNames.NEW_CHANNEL_SET) { + await router.push({ name: RouteNames.NEW_CHANNEL_SET }); + } else { + await router.push({ + name: RouteNames.CHANNEL_SET_DETAILS, + params: { channelSetId }, + }); + } -const getSelectChannelsButton = wrapper => { - return wrapper.findComponent('[data-test="button-select"]'); -}; + const result = render(ChannelSetModal, { + localVue, + router, + store, + props: routeName === RouteNames.CHANNEL_SET_DETAILS ? { channelSetId } : {}, + }); -const getFinishButton = wrapper => { - return wrapper.findComponent('[data-test="button-finish"]'); + return { ...result, router }; }; describe('ChannelSetModal', () => { - beforeEach(() => { - jest.resetAllMocks(); + beforeAll(() => { + configure({ testIdAttribute: 'data-test' }); }); - it('should show collection channels view at first', () => { - const storeConfig = cloneDeep(STORE_CONFIG); - const store = storeFactory(storeConfig); - const [wrapper] = makeWrapper({ store, channelSetId: CHANNEL_SET.id }); - - expect(wrapper.find('[data-test="collection-channels-view"]').isVisible()).toBe(true); + afterAll(() => { + configure({ testIdAttribute: 'data-testid' }); }); - describe('if there are no data for a channel set yet', () => { - let mocks; - - beforeEach(() => { - const storeConfig = cloneDeep(STORE_CONFIG); - const store = storeFactory(storeConfig); - - mocks = makeWrapper({ store, channelSetId: CHANNEL_SET.id })[1]; - }); - - it('should load the channel set', () => { - expect(mocks.loadChannelSet).toHaveBeenCalledTimes(1); - expect(mocks.loadChannelSet.mock.calls[0][0]).toBe(CHANNEL_SET.id); - }); - - it('should load channels of the channel set', () => { - expect(mocks.loadChannelList).toHaveBeenCalledTimes(1); - expect(mocks.loadChannelList.mock.calls[0][0]).toEqual({ - id__in: [CHANNEL_1.id, CHANNEL_2.id], - }); - }); + beforeEach(() => { + jest.clearAllMocks(); }); - describe('if a channel set has been already loaded', () => { - let store, mocks; - - beforeEach(() => { - const storeConfig = cloneDeep(STORE_CONFIG); - store = storeFactory(storeConfig); - store.commit('channelSet/ADD_CHANNELSET', CHANNEL_SET); + it('shows existing collection details and channel list', async () => { + await renderComponent(); - mocks = makeWrapper({ store, channelSetId: CHANNEL_SET.id })[1]; - }); - - it("shouldn't load the channel set", () => { - expect(mocks.loadChannelSet).not.toHaveBeenCalled(); - }); + const nameInput = await screen.findByLabelText('Collection name'); - it('should load channels from the channel set', () => { - expect(mocks.loadChannelList).toHaveBeenCalledTimes(1); - expect(mocks.loadChannelList.mock.calls[0][0]).toEqual({ - id__in: [CHANNEL_1.id, CHANNEL_2.id], - }); - }); + expect(nameInput).toHaveValue('My collection'); + expect(screen.getByText('2 channels')).toBeInTheDocument(); + expect(screen.getByText(CHANNEL_1.name)).toBeInTheDocument(); + expect(screen.getByText(CHANNEL_2.name)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Select channels' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save and close' })).toBeInTheDocument(); }); - describe('collection channels view', () => { - let wrapper, updateChannelSet; - - beforeEach(() => { - const storeConfig = cloneDeep(STORE_CONFIG); - updateChannelSet = jest.fn(); - storeConfig.modules.channel.actions.loadChannelList = jest.fn().mockResolvedValue(); - storeConfig.modules.channelSet.actions.updateChannelSet = updateChannelSet; - const store = storeFactory(storeConfig); - store.commit('channelSet/ADD_CHANNELSET', CHANNEL_SET); - store.commit('channel/ADD_CHANNELS', [CHANNEL_1, CHANNEL_2]); - - [wrapper] = makeWrapper({ store, channelSetId: CHANNEL_SET.id }); - }); + it('loads existing collection data when opened directly on details route', async () => { + await renderComponent({ preloadChannelSet: false }); - it('should render a collection name input', () => { - expect(getCollectionNameInput(wrapper).isVisible()).toBe(true); - }); - - it('should render select channels button', () => { - expect(getSelectChannelsButton(wrapper).isVisible()).toBe(true); - }); - - it('should render save button', () => { - expect(getSaveButton(wrapper).isVisible()).toBe(true); - }); + expect(await screen.findByDisplayValue('My collection')).toBeInTheDocument(); + expect(mockActions.loadChannelSet).toHaveBeenCalledWith(expect.any(Object), CHANNEL_SET.id); + }); - it('should render close button', () => { - expect(getCloseButton(wrapper).isVisible()).toBe(true); - }); + it('lets the user move to channel selection and finish back to the collection view', async () => { + const user = userEvent.setup(); + await renderComponent(); - it('should render a correct channels count', () => { - expect(wrapper.findComponent('.subheading').html()).toContain('2 channels'); - }); + await user.click(screen.getByRole('button', { name: 'Select channels' })); - it("should render channels' names, descriptions and remove buttons", () => { - const channelItems = wrapper.findAllComponents({ name: 'ChannelItem' }); + expect(await screen.findByRole('heading', { name: 'Select channels' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Finish' })).toBeInTheDocument(); - expect(channelItems.length).toBe(2); + await user.click(screen.getByRole('button', { name: 'Finish' })); - expect(channelItems.at(0).html()).toContain('Channel 1'); - expect(channelItems.at(0).html()).toContain('First channel description'); - expect(channelItems.at(0).find('button').text()).toBe('Remove'); + expect(await screen.findByRole('heading', { name: 'Collection channels' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save and close' })).toBeInTheDocument(); + }); - expect(channelItems.at(1).html()).toContain('Channel 2'); - expect(channelItems.at(1).html()).toContain('Second channel description'); - expect(channelItems.at(1).find('button').text()).toBe('Remove'); + it('shows a validation message when user tries to create without a name', async () => { + const user = userEvent.setup(); + const { router } = await renderComponent({ + routeName: RouteNames.NEW_CHANNEL_SET, + preloadChannelSet: false, + channelSet: null, + channels: [], }); - it('clicking select channels button should navigate to channels selection view', async () => { - await getSelectChannelsButton(wrapper).trigger('click'); - await wrapper.vm.$nextTick(); + expect(await screen.findByRole('button', { name: 'Create' })).toBeInTheDocument(); - expect(wrapper.findComponent('[data-test="collection-channels-view"]').isVisible()).toBe( - false, - ); - expect(wrapper.findComponent('[data-test="channels-selection-view"]').isVisible()).toBe(true); - }); - - describe('clicking close button', () => { - it('should redirect to channel sets page', async () => { - await getCloseButton(wrapper).trigger('click'); + await user.click(screen.getByRole('button', { name: 'Create' })); - expect(wrapper.vm.$route.name).toBe(RouteNames.CHANNEL_SETS); - }); + expect(screen.getByText('Field is required')).toBeInTheDocument(); + expect(router.currentRoute.name).toBe(RouteNames.NEW_CHANNEL_SET); + }); - it('should prompt user if there are unsaved changes', async () => { - expect(getUnsavedDialog(wrapper).exists()).toBeFalsy(); + it('saves valid changes and returns the user to collections', async () => { + const user = userEvent.setup(); + const { router } = await renderComponent(); - await wrapper.setData({ name: 'My collection' }); - await getCloseButton(wrapper).trigger('click'); + const nameInput = await screen.findByLabelText('Collection name'); - expect(getUnsavedDialog(wrapper).exists()).toBeTruthy(); - }); - }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated collection name'); + await user.click(screen.getByRole('button', { name: 'Save and close' })); - describe('clicking save button', () => { - it("shouldn't update a channel set when a collection name is missing", async () => { - await wrapper.setData({ name: '' }); - await getSaveButton(wrapper).trigger('click'); - await flushPromises(); - - expect(updateChannelSet).not.toHaveBeenCalled(); - }); - - it("shouldn't update a channel set when a collection name is made of empty characters", async () => { - await wrapper.setData({ name: ' ' }); - await getSaveButton(wrapper).trigger('click'); - await flushPromises(); - - expect(updateChannelSet).not.toHaveBeenCalled(); - }); - - it('should update a channel set when a collection name is valid', async () => { - await wrapper.setData({ name: 'My collection' }); - await getSaveButton(wrapper).trigger('click'); - await flushPromises(); - - expect(updateChannelSet).toHaveBeenCalledTimes(1); - expect(updateChannelSet.mock.calls[0][1]).toEqual({ - id: CHANNEL_SET.id, - name: 'My collection', - }); - }); + await waitFor(() => { + expect(router.currentRoute.name).toBe(RouteNames.CHANNEL_SETS); }); }); - describe('channels selection view', () => { - let wrapper; + it('prompts before closing when there are unsaved changes', async () => { + const user = userEvent.setup(); + await renderComponent(); - beforeEach(async () => { - const storeConfig = cloneDeep(STORE_CONFIG); - storeConfig.modules.channel.actions.loadChannelList = jest.fn().mockResolvedValue(); - const store = storeFactory(storeConfig); - store.commit('channelSet/ADD_CHANNELSET', CHANNEL_SET); - store.commit('channel/ADD_CHANNELS', [CHANNEL_1, CHANNEL_2]); + const nameInput = await screen.findByLabelText('Collection name'); + await user.type(nameInput, ' Updated'); - [wrapper] = makeWrapper({ store, channelSetId: CHANNEL_SET.id }); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); + await user.click(screen.getByTestId('close')); - await getSelectChannelsButton(wrapper).trigger('click'); - await wrapper.vm.$nextTick(); - - expect(wrapper.findComponent('[data-test="channels-selection-view"]').isVisible()).toBe(true); - }); + expect(await screen.findByRole('heading', { name: 'Unsaved changes' })).toBeInTheDocument(); + expect( + screen.getByText('You will lose any unsaved changes. Are you sure you want to exit?'), + ).toBeInTheDocument(); + }); - it('should render finish button', async () => { - expect(wrapper.vm.step).toBe(2); - expect(getFinishButton(wrapper).isVisible()).toBe(true); - }); + it('closes directly when there are no unsaved changes', async () => { + const user = userEvent.setup(); + const { router } = await renderComponent(); - it('clicking finish button should navigate back to collection channels view', async () => { - await getFinishButton(wrapper).trigger('click'); - await wrapper.vm.$nextTick(); + await user.click(screen.getByTestId('close')); - expect(wrapper.findComponent('[data-test="channels-selection-view"]').isVisible()).toBe( - false, - ); - expect(wrapper.findComponent('[data-test="collection-channels-view"]').isVisible()).toBe( - true, - ); + await waitFor(() => { + expect(router.currentRoute.name).toBe(RouteNames.CHANNEL_SETS); }); }); });