diff --git a/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue b/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue index 8be9c6a54d..8ebea3ace0 100644 --- a/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue +++ b/contentcuration/contentcuration/frontend/accounts/components/StudioMessageLayout.vue @@ -100,6 +100,7 @@ } .message-slot-container { + width: 100%; max-width: 400px; margin: 24px auto 0; text-align: center; diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js index c87be9ab31..89c403d098 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js @@ -1,7 +1,20 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/vue'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; import VueRouter from 'vue-router'; import { createLocalVue } from '@vue/test-utils'; import ResetPassword from '../resetPassword/ResetPassword'; +import commonStrings from 'shared/translator'; +import { createTranslator } from 'shared/i18n'; + +const { fieldRequired$ } = commonStrings; +const { + passwordLabel$, + passwordConfirmLabel$, + passwordValidationMessage$, + passwordMatchMessage$, + submitButton$, + resetPasswordFailed$, +} = createTranslator(ResetPassword.name, ResetPassword.$trs); const localVue = createLocalVue(); localVue.use(VueRouter); @@ -10,6 +23,7 @@ const setPasswordMock = jest.fn(() => Promise.resolve()); const renderComponent = (queryParams = {}) => { const router = new VueRouter({ + mode: 'abstract', routes: [ { path: '/', name: 'Main' }, { path: '/reset-password', name: 'ResetPassword' }, @@ -80,4 +94,130 @@ describe('ResetPassword', () => { expect(router.currentRoute.name).toBe('ResetPasswordSuccess'); }); }); + + describe('validation feedback', () => { + let user; + + beforeEach(() => { + user = userEvent.setup(); + }); + + const newPasswordField = () => screen.getByLabelText(passwordLabel$()); + const confirmPasswordField = () => screen.getByLabelText(passwordConfirmLabel$()); + // KTextbox doesn't link its error text to the input via ARIA, so scope + // queries to the textbox container that holds both. + const fieldContainer = field => field.closest('.ui-textbox'); + + it('shows the required error when an empty field loses focus', async () => { + renderComponent(); + + await user.click(newPasswordField()); + await user.tab(); + + expect(within(fieldContainer(newPasswordField())).getByText(fieldRequired$())).toBeVisible(); + expect( + within(fieldContainer(confirmPasswordField())).queryByText(fieldRequired$()), + ).not.toBeInTheDocument(); + }); + + it('hides errors while typing and shows them once the field loses focus', async () => { + renderComponent(); + + await user.type(newPasswordField(), 'short'); + expect(screen.queryByText(passwordValidationMessage$())).not.toBeInTheDocument(); + + await user.tab(); + expect(screen.getByText(passwordValidationMessage$())).toBeInTheDocument(); + }); + + it('re-checks the confirmation when the new password changes', async () => { + renderComponent(); + + await user.type(newPasswordField(), 'abcdefgh'); + await user.type(confirmPasswordField(), 'abcdefgh'); + await user.tab(); + expect(screen.queryByText(passwordMatchMessage$())).not.toBeInTheDocument(); + + await user.type(newPasswordField(), 'ZZ'); + await user.tab(); + expect(screen.getByText(passwordMatchMessage$())).toBeInTheDocument(); + }); + + it('clears the mismatch error when the new password is changed to match', async () => { + renderComponent(); + + await user.type(newPasswordField(), 'abcdefgh'); + await user.type(confirmPasswordField(), 'abcdefghZZ'); + await user.tab(); + expect(screen.getByText(passwordMatchMessage$())).toBeInTheDocument(); + + await user.type(newPasswordField(), 'ZZ'); + expect(screen.queryByText(passwordMatchMessage$())).not.toBeInTheDocument(); + }); + + it('shows the required error on both fields when submitting an empty form', async () => { + renderComponent(); + + await user.click(screen.getByRole('button', { name: submitButton$() })); + + expect(screen.getAllByText(fieldRequired$())).toHaveLength(2); + expect(setPasswordMock).not.toHaveBeenCalled(); + }); + + it('moves focus to the new password field when it is the first invalid field', async () => { + renderComponent(); + + await user.click(screen.getByRole('button', { name: submitButton$() })); + + await waitFor(() => { + expect(newPasswordField()).toHaveFocus(); + }); + }); + + it('moves focus to the confirmation field when only it is invalid', async () => { + renderComponent(); + + await user.type(newPasswordField(), 'abcdefgh'); + await user.type(confirmPasswordField(), 'mismatch'); + await user.click(screen.getByRole('button', { name: submitButton$() })); + + await waitFor(() => { + expect(confirmPasswordField()).toHaveFocus(); + }); + }); + }); + + it('shows and announces an error banner when resetting the password fails', async () => { + const user = userEvent.setup(); + setPasswordMock.mockRejectedValueOnce(new Error('Invalid token')); + renderComponent(); + + await user.type(screen.getByLabelText(passwordLabel$()), 'validPassword123'); + await user.type(screen.getByLabelText(passwordConfirmLabel$()), 'validPassword123'); + await user.click(screen.getByRole('button', { name: submitButton$() })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(resetPasswordFailed$()); + }); + }); + + it('preserves leading and trailing whitespace in the submitted passwords', async () => { + const user = userEvent.setup(); + const PASSWORD_WITH_SPACES = ' spaced password '; + renderComponent(); + + await user.type(screen.getByLabelText(passwordLabel$()), PASSWORD_WITH_SPACES); + await user.type(screen.getByLabelText(passwordConfirmLabel$()), PASSWORD_WITH_SPACES); + await user.click(screen.getByRole('button', { name: submitButton$() })); + + await waitFor(() => { + expect(setPasswordMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + new_password1: PASSWORD_WITH_SPACES, + new_password2: PASSWORD_WITH_SPACES, + }), + ); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue index f7149673d5..d5e490af2e 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue @@ -1,30 +1,36 @@ @@ -41,52 +47,107 @@