From 4c52fc609ed483af8b2c69992e140fde215cc3cc Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 17:40:50 +0000 Subject: [PATCH 1/2] test(accounts): refactor AccountsMain spec to Vue Testing Library; add next-redirect & payload checks --- .../pages/__tests__/accountsMain.spec.js | 225 +++++++++++------- 1 file changed, 136 insertions(+), 89 deletions(-) diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js index 3f579cb84e..745340e94a 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js @@ -1,115 +1,162 @@ -import { mount } from '@vue/test-utils'; -import router from '../../router'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import VueRouter from 'vue-router'; +import { render, screen, waitFor } from '@testing-library/vue'; +import { configure } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; import AccountsMain from '../AccountsMain.vue'; -async function makeWrapper() { - const wrapper = mount(AccountsMain, { - router, - stubs: ['GlobalSnackbar', 'PolicyModals'], - mocks: { - $store: { - state: { - connection: { - online: true, - }, - }, - }, +Vue.use(Vuex); +Vue.use(VueRouter); + +configure({ testIdAttribute: 'data-test' }); + +const loginMock = jest.fn(); + +const makeRouter = (query = {}) => { + const router = new VueRouter({ + mode: 'abstract', + routes: [ + { path: '/signin', name: 'SignIn', component: AccountsMain }, + { path: '/forgot', name: 'ForgotPassword', component: { render: h => h('div') } }, + { path: '/create', name: 'Create', component: { render: h => h('div') } }, + { path: '/account-not-activated', name: 'AccountNotActivated', component: { render: h => h('div') } }, + ], + }); + router.replace({ path: '/signin', query }); + return router; +}; + +const makeStore = (overrides = {}) => + new Vuex.Store({ + state: { connection: { online: true }, ...(overrides.state || {}) }, + actions: { + login: loginMock, + ...(overrides.actions || {}), }, + getters: { ...(overrides.getters || {}) }, }); - await wrapper.setData({ - username: 'test@test.com', - password: 'pass', + +const renderComponent = ({ query, store } = {}) => { + const router = makeRouter(query); + const vuex = makeStore(store); + + delete window.location; + const nextSearch = query?.next ? `?next=${query.next}` : ''; + Object.defineProperty(window, 'location', { + value: { + href: `http://test.local/signin${nextSearch}`, + search: nextSearch, + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + }, + writable: true, }); - const login = jest.spyOn(wrapper.vm, 'login'); - login.mockImplementation(() => Promise.resolve()); - return [wrapper, login]; -} - -function makeFailedPromise(statusCode) { - return () => { - return new Promise((resolve, reject) => { - reject({ - response: { - status: statusCode || 500, - }, - }); - }); - }; -} + const utils = render(AccountsMain, { + routes: router, + store: vuex, + stubs: ['GlobalSnackbar', 'PolicyModals'], + }); + return { router, ...utils }; +}; -describe('main', () => { - let wrapper, login, loginToProceed; +const origLocation = window.location; +beforeEach(() => { + jest.clearAllMocks(); + loginMock.mockReset(); +}); +afterAll(() => { + window.location = origLocation; +}); - beforeEach(async () => { - [wrapper, login] = await makeWrapper(); - await wrapper.vm.$nextTick(); - loginToProceed = wrapper.findAllComponents('[data-test="loginToProceed"]').at(0); +describe('AccountsMain (VTL)', () => { + test('renders sign-in form', () => { + renderComponent({}); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); }); - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } + test('submitting empty form blocks login and shows validation', async () => { + loginMock.mockResolvedValue(); + renderComponent({}); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + expect(loginMock).not.toHaveBeenCalled(); + const msgs = await screen.findAllByText(/this field is required/i); + expect(msgs.length).toBeGreaterThanOrEqual(1); }); - it('should trigger submit method when form is submitted', async () => { - expect(loginToProceed.isVisible()).toBe(false); - const submit = jest.spyOn(wrapper.vm, 'submit'); - submit.mockImplementation(() => {}); - await wrapper.findComponent({ ref: 'form' }).trigger('submit'); - expect(submit).toHaveBeenCalled(); + test('valid credentials call login', async () => { + loginMock.mockResolvedValue(); + renderComponent({}); + await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + await waitFor(() => expect(loginMock).toHaveBeenCalled()); }); - it('should call login with username and password provided', () => { - expect(loginToProceed.isVisible()).toBe(false); - wrapper.vm.submit(); - expect(login).toHaveBeenCalled(); - }); + test('with ?next= shows banner and redirects after successful login', async () => { + loginMock.mockResolvedValue(); + const nextUrl = '/test-next/'; + const { router } = renderComponent({ query: { next: nextUrl } }); - it('should fail if username is not provided', async () => { - expect(loginToProceed.isVisible()).toBe(false); - await wrapper.setData({ username: ' ' }); - wrapper.vm.submit(); - expect(login).not.toHaveBeenCalled(); - }); + expect(screen.getByTestId('loginToProceed')).toBeInTheDocument(); - it('should fail if password is not provided', async () => { - expect(loginToProceed.isVisible()).toBe(false); - await wrapper.setData({ password: '' }); - wrapper.vm.submit(); - expect(login).not.toHaveBeenCalled(); + await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => { + expect(window.location.assign).toHaveBeenCalledWith(nextUrl); + }); + expect(router.currentRoute.name).toBe('SignIn'); }); - it('should set loginFailed if login fails', async () => { - expect(loginToProceed.isVisible()).toBe(false); - jest.spyOn(wrapper.vm, 'login').mockImplementation(makeFailedPromise()); - await wrapper.vm.submit(); - expect(wrapper.vm.loginFailed).toBe(true); + test('generic failure does not navigate', async () => { + loginMock.mockRejectedValue({ response: { status: 500 } }); + const { router } = renderComponent({}); + await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(loginMock).toHaveBeenCalled()); + expect(router.currentRoute.name).toBe('SignIn'); }); - it('should say account has not been activated if login returns 405', async () => { - expect(loginToProceed.isVisible()).toBe(false); - jest.spyOn(wrapper.vm, 'login').mockImplementation(makeFailedPromise()); - await wrapper.vm.submit(); - expect(wrapper.vm.loginFailed).toBe(true); + test('405 failure navigates to AccountNotActivated', async () => { + const store = { + actions: { login: jest.fn().mockRejectedValue({ response: { status: 405 } }) }, + }; + const { router } = renderComponent({ store }); + await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(router.currentRoute.name).toBe('AccountNotActivated')); }); - it('should navigate to next url if next query param is set', async () => { - const testUrl = '/testnext/'; - const location = new URL(`http://studio.time/?next=${testUrl}`); + test('calls login with exact payload', async () => { + loginMock.mockResolvedValue(); + const nextUrl = '/test-next/'; + renderComponent({ query: { next: nextUrl } }); + + await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(loginMock).toHaveBeenCalled()); - delete window.location; - window.location = location; - window.location.assign = jest.fn(); + const [, payload] = loginMock.mock.calls[0]; - wrapper.destroy(); - [wrapper, login] = await makeWrapper(); - await wrapper.vm.$nextTick(); - loginToProceed = wrapper.findAll('[data-test="loginToProceed"]').at(0); - expect(loginToProceed.isVisible()).toBe(true); + expect(payload).toMatchObject({ password: 'password123' }); - await wrapper.vm.submit(); - expect(window.location.assign.mock.calls[0][0]).toBe(testUrl); + const idMatches = + (payload.email && payload.email === 'test@test.com') || + (payload.username && payload.username === 'test@test.com'); + expect(idMatches).toBe(true); + + expect(payload.next).toBe(undefined); }); -}); +}); \ No newline at end of file From 81e2c8f4e4c608bbdbc90edbdbb7b62870c97f0a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 22 Nov 2025 14:08:38 +0000 Subject: [PATCH 2/2] fix: apply code review feedback and auto-formatting --- .../pages/__tests__/accountsMain.spec.js | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js index 745340e94a..d033645c54 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/accountsMain.spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import Vuex from 'vuex'; +import Vuex, { Store } from 'vuex'; import VueRouter from 'vue-router'; import { render, screen, waitFor } from '@testing-library/vue'; import { configure } from '@testing-library/dom'; @@ -11,6 +11,7 @@ Vue.use(VueRouter); configure({ testIdAttribute: 'data-test' }); +// ---- Mocks and helpers --------------------------------------------------- const loginMock = jest.fn(); const makeRouter = (query = {}) => { @@ -20,7 +21,11 @@ const makeRouter = (query = {}) => { { path: '/signin', name: 'SignIn', component: AccountsMain }, { path: '/forgot', name: 'ForgotPassword', component: { render: h => h('div') } }, { path: '/create', name: 'Create', component: { render: h => h('div') } }, - { path: '/account-not-activated', name: 'AccountNotActivated', component: { render: h => h('div') } }, + { + path: '/account-not-activated', + name: 'AccountNotActivated', + component: { render: h => h('div') }, + }, ], }); router.replace({ path: '/signin', query }); @@ -28,7 +33,7 @@ const makeRouter = (query = {}) => { }; const makeStore = (overrides = {}) => - new Vuex.Store({ + new Store({ state: { connection: { online: true }, ...(overrides.state || {}) }, actions: { login: loginMock, @@ -62,6 +67,7 @@ const renderComponent = ({ query, store } = {}) => { return { router, ...utils }; }; +// ---- Window location stub (for next= redirect) --------------------------- const origLocation = window.location; beforeEach(() => { jest.clearAllMocks(); @@ -71,15 +77,16 @@ afterAll(() => { window.location = origLocation; }); -describe('AccountsMain (VTL)', () => { - test('renders sign-in form', () => { +// ---- Tests --------------------------------------------------------------- +describe('AccountsMain', () => { + it('renders sign-in form', () => { renderComponent({}); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); }); - test('submitting empty form blocks login and shows validation', async () => { + it('submitting empty form blocks login and shows validation', async () => { loginMock.mockResolvedValue(); renderComponent({}); await userEvent.click(screen.getByRole('button', { name: /sign in/i })); @@ -88,7 +95,7 @@ describe('AccountsMain (VTL)', () => { expect(msgs.length).toBeGreaterThanOrEqual(1); }); - test('valid credentials call login', async () => { + it('valid credentials call login', async () => { loginMock.mockResolvedValue(); renderComponent({}); await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com'); @@ -97,7 +104,7 @@ describe('AccountsMain (VTL)', () => { await waitFor(() => expect(loginMock).toHaveBeenCalled()); }); - test('with ?next= shows banner and redirects after successful login', async () => { + it('with ?next= shows banner and redirects after successful login', async () => { loginMock.mockResolvedValue(); const nextUrl = '/test-next/'; const { router } = renderComponent({ query: { next: nextUrl } }); @@ -114,7 +121,7 @@ describe('AccountsMain (VTL)', () => { expect(router.currentRoute.name).toBe('SignIn'); }); - test('generic failure does not navigate', async () => { + it('generic failure does not navigate', async () => { loginMock.mockRejectedValue({ response: { status: 500 } }); const { router } = renderComponent({}); await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com'); @@ -125,7 +132,7 @@ describe('AccountsMain (VTL)', () => { expect(router.currentRoute.name).toBe('SignIn'); }); - test('405 failure navigates to AccountNotActivated', async () => { + it('405 failure navigates to AccountNotActivated', async () => { const store = { actions: { login: jest.fn().mockRejectedValue({ response: { status: 405 } }) }, }; @@ -137,7 +144,7 @@ describe('AccountsMain (VTL)', () => { await waitFor(() => expect(router.currentRoute.name).toBe('AccountNotActivated')); }); - test('calls login with exact payload', async () => { + it('calls login with exact payload', async () => { loginMock.mockResolvedValue(); const nextUrl = '/test-next/'; renderComponent({ query: { next: nextUrl } }); @@ -159,4 +166,4 @@ describe('AccountsMain (VTL)', () => { expect(payload.next).toBe(undefined); }); -}); \ No newline at end of file +});