From 872fa4c917cd01fb4b80d4e4952fc2f61e550010 Mon Sep 17 00:00:00 2001 From: ayesha waris <73840786+ayesha-waris@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:21:09 +0500 Subject: [PATCH] test: added test cases to improve test coverage (#1254) Co-authored-by: Ayesha Waris --- src/profile/data/sagas.test.js | 62 +++++++ src/profile/data/services.test.js | 174 ++++++++++++++++++ .../forms/elements/FormControls.test.jsx | 59 ++++++ .../forms/elements/SwitchContent.test.jsx | 80 ++++++++ 4 files changed, 375 insertions(+) create mode 100644 src/profile/data/services.test.js create mode 100644 src/profile/forms/elements/FormControls.test.jsx create mode 100644 src/profile/forms/elements/SwitchContent.test.jsx diff --git a/src/profile/data/sagas.test.js b/src/profile/data/sagas.test.js index 2da09b3..5635f82 100644 --- a/src/profile/data/sagas.test.js +++ b/src/profile/data/sagas.test.js @@ -163,5 +163,67 @@ describe('RootSaga', () => { expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' }))); expect(gen.next().value).toBeUndefined(); }); + + it('should reset profile if error has no processedData', () => { + const action = profileActions.saveProfile('formid', 'user1'); + const gen = handleSaveProfile(action); + + expect(gen.next().value).toEqual(select(handleSaveProfileSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin())); + + const err = new Error('oops'); + const result = gen.throw(err); + expect(result.value).toEqual(put(profileActions.saveProfileReset())); + }); + }); + + describe('handleSaveProfilePhoto', () => { + it('should save profile photo successfully', () => { + const action = profileActions.saveProfilePhoto('user1', { some: 'formdata' }); + const gen = handleSaveProfilePhoto(action); + const fakePhoto = { url: 'photo.jpg' }; + + expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin())); + expect(gen.next().value).toEqual(call(ProfileApiService.postProfilePhoto, 'user1', { some: 'formdata' })); + expect(gen.next(fakePhoto).value).toEqual(put(profileActions.saveProfilePhotoSuccess(fakePhoto))); + expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoReset())); + expect(gen.next().value).toBeUndefined(); + }); + + it('should reset photo state on error', () => { + const action = profileActions.saveProfilePhoto('user1', {}); + const gen = handleSaveProfilePhoto(action); + + expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin())); + + const err = new Error('fail'); + + expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset())); + expect(gen.next().done).toBe(true); + }); + }); + + describe('handleDeleteProfilePhoto', () => { + it('should delete profile photo successfully', () => { + const action = profileActions.deleteProfilePhoto('user1'); + const gen = handleDeleteProfilePhoto(action); + const fakeResult = { ok: true }; + + expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin())); + expect(gen.next().value).toEqual(call(ProfileApiService.deleteProfilePhoto, 'user1')); + expect(gen.next(fakeResult).value).toEqual(put(profileActions.deleteProfilePhotoSuccess(fakeResult))); + expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoReset())); + expect(gen.next().value).toBeUndefined(); + }); + it('should reset photo state on error', () => { + const action = profileActions.saveProfilePhoto('user1', {}); + const gen = handleSaveProfilePhoto(action); + + expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin())); + const err = new Error('fail'); + expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset())); + + expect(gen.next().done).toBe(true); + }); }); }); diff --git a/src/profile/data/services.test.js b/src/profile/data/services.test.js new file mode 100644 index 0000000..e13c3be --- /dev/null +++ b/src/profile/data/services.test.js @@ -0,0 +1,174 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError } from '@edx/frontend-platform/logging'; +import { + getAccount, + patchProfile, + postProfilePhoto, + deleteProfilePhoto, + getPreferences, + patchPreferences, + getCourseCertificates, + getCountryList, +} from './services'; + +import { FIELD_LABELS } from './constants'; + +import { camelCaseObject, snakeCaseObject, convertKeyNames } from '../utils'; + +// --- Mocks --- +jest.mock('@edx/frontend-platform', () => ({ + ensureConfig: jest.fn(), + getConfig: jest.fn(() => ({ LMS_BASE_URL: 'http://fake-lms' })), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +jest.mock('../utils', () => ({ + camelCaseObject: jest.fn((obj) => obj), + snakeCaseObject: jest.fn((obj) => obj), + convertKeyNames: jest.fn((obj) => obj), +})); + +const mockHttpClient = { + get: jest.fn(), + patch: jest.fn(), + post: jest.fn(), + delete: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + getAuthenticatedHttpClient.mockReturnValue(mockHttpClient); +}); + +// --- Tests --- +describe('services', () => { + describe('getAccount', () => { + it('should return processed account data', async () => { + const mockData = { name: 'John Doe', socialLinks: [] }; + mockHttpClient.get.mockResolvedValue({ data: mockData }); + + const result = await getAccount('john'); + expect(result).toMatchObject(mockData); + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'http://fake-lms/api/user/v1/accounts/john', + ); + }); + }); + + describe('patchProfile', () => { + it('should patch and return processed data', async () => { + const mockData = { bio: 'New Bio' }; + mockHttpClient.patch.mockResolvedValue({ data: mockData }); + + const result = await patchProfile('john', { bio: 'New Bio' }); + expect(result).toMatchObject(mockData); + expect(snakeCaseObject).toHaveBeenCalledWith({ bio: 'New Bio' }); + }); + + it('should throw processed error on failure', async () => { + const error = { response: { data: { some: 'error' } } }; + mockHttpClient.patch.mockRejectedValue(error); + + await expect(patchProfile('john', {})).rejects.toMatchObject(error); + }); + }); + + describe('postProfilePhoto', () => { + it('should post photo and return updated profile image', async () => { + mockHttpClient.post.mockResolvedValue({}); + mockHttpClient.get.mockResolvedValue({ + data: { profileImage: { url: 'img.png' } }, + }); + + const result = await postProfilePhoto('john', new FormData()); + expect(result).toEqual({ url: 'img.png' }); + }); + + it('should throw error if API fails', async () => { + const error = { response: { data: { error: 'fail' } } }; + mockHttpClient.post.mockRejectedValue(error); + await expect(postProfilePhoto('john', new FormData())).rejects.toMatchObject(error); + }); + }); + + describe('deleteProfilePhoto', () => { + it('should delete photo and return updated profile image', async () => { + mockHttpClient.delete.mockResolvedValue({}); + mockHttpClient.get.mockResolvedValue({ + data: { profileImage: { url: 'deleted.png' } }, + }); + + const result = await deleteProfilePhoto('john'); + expect(result).toEqual({ url: 'deleted.png' }); + }); + }); + + describe('getPreferences', () => { + it('should return camelCased preferences', async () => { + mockHttpClient.get.mockResolvedValue({ data: { pref: 1 } }); + + const result = await getPreferences('john'); + expect(result).toMatchObject({ pref: 1 }); + expect(camelCaseObject).toHaveBeenCalledWith({ pref: 1 }); + }); + }); + + describe('patchPreferences', () => { + it('should patch preferences and return params', async () => { + mockHttpClient.patch.mockResolvedValue({}); + const params = { visibility_bio: true }; + + const result = await patchPreferences('john', params); + expect(result).toBe(params); + expect(snakeCaseObject).toHaveBeenCalledWith(params); + expect(convertKeyNames).toHaveBeenCalled(); + }); + }); + + describe('getCourseCertificates', () => { + it('should return transformed certificates', async () => { + mockHttpClient.get.mockResolvedValue({ + data: [{ download_url: '/path', certificate_type: 'type' }], + }); + + const result = await getCourseCertificates('john'); + expect(result[0]).toHaveProperty('downloadUrl', 'http://fake-lms/path'); + }); + + it('should log error and return empty array on failure', async () => { + mockHttpClient.get.mockRejectedValue(new Error('fail')); + const result = await getCourseCertificates('john'); + expect(result).toEqual([]); + expect(logError).toHaveBeenCalled(); + }); + }); + + describe('getCountryList', () => { + it('should extract country list', async () => { + mockHttpClient.get.mockResolvedValue({ + data: { + fields: [ + { name: FIELD_LABELS.COUNTRY, options: [{ value: 'US' }, { value: 'CA' }] }, + ], + }, + }); + + const result = await getCountryList(); + expect(result).toEqual(['US', 'CA']); + }); + + it('should log error and return empty array on failure', async () => { + mockHttpClient.get.mockRejectedValue(new Error('fail')); + const result = await getCountryList(); + expect(result).toEqual([]); + expect(logError).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/profile/forms/elements/FormControls.test.jsx b/src/profile/forms/elements/FormControls.test.jsx new file mode 100644 index 0000000..04854b1 --- /dev/null +++ b/src/profile/forms/elements/FormControls.test.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import FormControls from './FormControls'; +import messages from './FormControls.messages'; + +const defaultProps = { + cancelHandler: jest.fn(), + changeHandler: jest.fn(), + visibilityId: 'visibility-id', + visibility: 'private', + saveState: null, +}; + +jest.mock('@edx/frontend-platform/i18n', () => { + const actual = jest.requireActual('@edx/frontend-platform/i18n'); + return { + ...actual, + injectIntl: (Component) => (props) => ( + msg.id, // returns id so we can assert on it + }} + /> + ), + intlShape: {}, // optional, prevents prop-type warnings + }; +}); + +describe('FormControls', () => { + it('renders Save button label when saveState is null', () => { + render(); + expect( + screen.getByRole('button', { name: messages['profile.formcontrols.button.save'].id }), + ).toBeInTheDocument(); + }); + + it('renders Saved label when saveState is complete', () => { + render(); + expect( + screen.getByRole('button', { name: messages['profile.formcontrols.button.saved'].id }), + ).toBeInTheDocument(); + }); + + it('renders Saving label when saveState is pending', () => { + render(); + expect( + screen.getByRole('button', { name: messages['profile.formcontrols.button.saving'].id }), + ).toBeInTheDocument(); + }); + + it('calls cancelHandler when Cancel button is clicked', () => { + render(); + fireEvent.click( + screen.getByRole('button', { name: messages['profile.formcontrols.button.cancel'].id }), + ); + expect(defaultProps.cancelHandler).toHaveBeenCalled(); + }); +}); diff --git a/src/profile/forms/elements/SwitchContent.test.jsx b/src/profile/forms/elements/SwitchContent.test.jsx new file mode 100644 index 0000000..15fe7fe --- /dev/null +++ b/src/profile/forms/elements/SwitchContent.test.jsx @@ -0,0 +1,80 @@ +/* eslint-disable react/prop-types */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import SwitchContent from './SwitchContent'; + +jest.mock('@openedx/paragon', () => ({ + TransitionReplace: ({ children, onChildExit, className }) => ( +
+ {children} +
+ ), +})); + +describe('SwitchContent', () => { + const makeElement = (text) =>
{text}
; + + it('renders matching case element directly', () => { + render( + , + ); + expect(screen.getByText('Case One')).toBeInTheDocument(); + }); + + it('renders case via string alias', () => { + render( + , + ); + expect(screen.getByText('Target Case')).toBeInTheDocument(); + }); + + it('renders default alias when expression not found', () => { + render( + , + ); + expect(screen.getByText('Target via Default')).toBeInTheDocument(); + }); + + it('renders null when no matching case and no default', () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-testid="transition"]').textContent).toBe(''); + }); + + it('calls onChildExit when child exits', () => { + const onChildExit = jest.fn(); + render( + , + ); + const transition = screen.getByTestId('transition'); + transition.dataset.onchildexit = onChildExit; + + // Simulate child exit + onChildExit(transition); + expect(onChildExit).toHaveBeenCalledWith(transition); + }); +});