diff --git a/packages/editor-ui/src/views/ForgotMyPasswordView.test.ts b/packages/editor-ui/src/views/ForgotMyPasswordView.test.ts new file mode 100644 index 00000000000..6e5819c4406 --- /dev/null +++ b/packages/editor-ui/src/views/ForgotMyPasswordView.test.ts @@ -0,0 +1,137 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import ForgotMyPasswordView from '@/views/ForgotMyPasswordView.vue'; +import { useToast } from '@/composables/useToast'; +import { useUsersStore } from '@/stores/users.store'; +import { useSettingsStore } from '@/stores/settings.store'; + +vi.mock('vue-router', () => { + const push = vi.fn(); + const replace = vi.fn(); + const query = {}; + return { + useRouter: () => ({ + push, + replace, + }), + useRoute: () => ({ + query, + }), + RouterLink: { + template: '', + }, + }; +}); + +vi.mock('@/composables/useToast', () => { + const showError = vi.fn(); + const showMessage = vi.fn(); + return { + useToast: () => ({ + showError, + showMessage, + }), + }; +}); + +const renderComponent = createComponentRenderer(ForgotMyPasswordView, { + global: { + stubs: { + 'router-link': { + template: '', + }, + }, + }, +}); + +let toast: ReturnType; +let usersStore: ReturnType>; +let settingsStore: ReturnType>; + +describe('ForgotMyPasswordView', () => { + beforeEach(() => { + vi.clearAllMocks(); + + createTestingPinia(); + + toast = useToast(); + usersStore = mockedStore(useUsersStore); + settingsStore = mockedStore(useSettingsStore); + }); + + it('should not throw error when opened', () => { + expect(() => renderComponent()).not.toThrow(); + }); + + it('should show email sending setup warning', async () => { + const { getByRole, queryByRole } = renderComponent(); + + const link = getByRole('link'); + const emailInput = queryByRole('textbox'); + + expect(emailInput).not.toBeInTheDocument(); + expect(link).toBeVisible(); + expect(link).toHaveTextContent('Back to sign in'); + }); + + it('should show form and submit', async () => { + settingsStore.isSmtpSetup = true; + usersStore.sendForgotPasswordEmail.mockResolvedValueOnce(); + + const { getByRole } = renderComponent(); + + const link = getByRole('link'); + const emailInput = getByRole('textbox'); + const submitButton = getByRole('button'); + + expect(emailInput).toBeVisible(); + expect(link).toBeVisible(); + expect(link).toHaveTextContent('Back to sign in'); + + // TODO: Remove manual tabbing when the following issue is fixed (it should fail the test anyway) + // https://github.com/testing-library/vue-testing-library/issues/317 + await userEvent.tab(); + expect(document.activeElement).toBe(emailInput); + + await userEvent.type(emailInput, 'test@n8n.io'); + await userEvent.click(submitButton); + + expect(usersStore.sendForgotPasswordEmail).toHaveBeenCalledWith({ + email: 'test@n8n.io', + }); + + expect(toast.showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.any(String), + message: expect.any(String), + }), + ); + }); + + it('should show form and error toast when submit has error', async () => { + settingsStore.isSmtpSetup = true; + usersStore.sendForgotPasswordEmail.mockRejectedValueOnce({ + httpStatusCode: 400, + }); + + const { getByRole } = renderComponent(); + + const emailInput = getByRole('textbox'); + const submitButton = getByRole('button'); + + await userEvent.type(emailInput, 'test@n8n.io'); + await userEvent.click(submitButton); + + expect(usersStore.sendForgotPasswordEmail).toHaveBeenCalledWith({ + email: 'test@n8n.io', + }); + + expect(toast.showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ); + }); +}); diff --git a/packages/editor-ui/src/views/ForgotMyPasswordView.vue b/packages/editor-ui/src/views/ForgotMyPasswordView.vue index 87aaa9dc47f..6d7d5c48811 100644 --- a/packages/editor-ui/src/views/ForgotMyPasswordView.vue +++ b/packages/editor-ui/src/views/ForgotMyPasswordView.vue @@ -26,6 +26,7 @@ const formConfig = computed(() => { validationRules: [{ name: 'VALID_EMAIL' }], autocomplete: 'email', capitalize: true, + focusInitially: true, }, }, ]; diff --git a/packages/editor-ui/src/views/SigninView.test.ts b/packages/editor-ui/src/views/SigninView.test.ts new file mode 100644 index 00000000000..d1bbb9b020d --- /dev/null +++ b/packages/editor-ui/src/views/SigninView.test.ts @@ -0,0 +1,100 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { useRouter } from 'vue-router'; +import SigninView from '@/views/SigninView.vue'; +import { useUsersStore } from '@/stores/users.store'; +import { useSettingsStore } from '@/stores/settings.store'; +import { useTelemetry } from '@/composables/useTelemetry'; + +vi.mock('vue-router', () => { + const push = vi.fn(); + return { + useRouter: () => ({ + push, + }), + useRoute: () => ({ + query: { + redirect: '/home/workflows', + }, + }), + RouterLink: { + template: '', + }, + }; +}); + +vi.mock('@/composables/useTelemetry', () => { + const track = vi.fn(); + return { + useTelemetry: () => ({ + track, + }), + }; +}); + +const renderComponent = createComponentRenderer(SigninView); + +let usersStore: ReturnType>; +let settingsStore: ReturnType>; + +let router: ReturnType; +let telemetry: ReturnType; + +describe('SigninView', () => { + beforeEach(() => { + createTestingPinia(); + usersStore = mockedStore(useUsersStore); + settingsStore = mockedStore(useSettingsStore); + + router = useRouter(); + telemetry = useTelemetry(); + }); + + it('should not throw error when opened', () => { + expect(() => renderComponent()).not.toThrow(); + }); + + it('should show and submit email/password form (happy path)', async () => { + settingsStore.isCloudDeployment = false; + usersStore.loginWithCreds.mockResolvedValueOnce(); + + const { getByRole, queryByTestId, container } = renderComponent(); + const emailInput = container.querySelector('input[type="email"]'); + const passwordInput = container.querySelector('input[type="password"]'); + const submitButton = getByRole('button', { name: 'Sign in' }); + + if (!emailInput || !passwordInput) { + throw new Error('Inputs not found'); + } + + expect(queryByTestId('mfa-login-form')).not.toBeInTheDocument(); + + expect(emailInput).toBeVisible(); + expect(passwordInput).toBeVisible(); + + // TODO: Remove manual tabbing when the following issue is fixed (it should fail the test anyway) + // https://github.com/testing-library/vue-testing-library/issues/317 + await userEvent.tab(); + expect(document.activeElement).toBe(emailInput); + + await userEvent.type(emailInput, 'test@n8n.io'); + await userEvent.type(passwordInput, 'password'); + + await userEvent.click(submitButton); + + expect(usersStore.loginWithCreds).toHaveBeenCalledWith({ + email: 'test@n8n.io', + password: 'password', + mfaToken: undefined, + mfaRecoveryCode: undefined, + }); + + expect(telemetry.track).toHaveBeenCalledWith('User attempted to login', { + result: 'success', + }); + + expect(router.push).toHaveBeenCalledWith('/home/workflows'); + }); +}); diff --git a/packages/editor-ui/src/views/SigninView.vue b/packages/editor-ui/src/views/SigninView.vue index fa258c5d7ad..f0c203d4694 100644 --- a/packages/editor-ui/src/views/SigninView.vue +++ b/packages/editor-ui/src/views/SigninView.vue @@ -60,6 +60,7 @@ const formConfig: IFormBoxConfig = reactive({ validateOnBlur: false, autocomplete: 'email', capitalize: true, + focusInitially: true, }, }, { diff --git a/packages/editor-ui/src/views/SignupView.test.ts b/packages/editor-ui/src/views/SignupView.test.ts new file mode 100644 index 00000000000..e9eb69478f4 --- /dev/null +++ b/packages/editor-ui/src/views/SignupView.test.ts @@ -0,0 +1,149 @@ +import { useRoute, useRouter } from 'vue-router'; +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { useToast } from '@/composables/useToast'; +import SignupView from '@/views/SignupView.vue'; +import { VIEWS } from '@/constants'; +import { useUsersStore } from '@/stores/users.store'; +import { mockedStore } from '@/__tests__/utils'; + +vi.mock('vue-router', () => { + const push = vi.fn(); + const replace = vi.fn(); + const query = {}; + return { + useRouter: () => ({ + push, + replace, + }), + useRoute: () => ({ + query, + }), + RouterLink: { + template: '', + }, + }; +}); + +vi.mock('@/composables/useToast', () => { + const showError = vi.fn(); + return { + useToast: () => ({ + showError, + }), + }; +}); + +const renderComponent = createComponentRenderer(SignupView); + +let route: ReturnType; +let router: ReturnType; +let toast: ReturnType; +let usersStore: ReturnType>; + +describe('SignupView', () => { + beforeEach(() => { + vi.clearAllMocks(); + + createTestingPinia(); + + route = useRoute(); + router = useRouter(); + toast = useToast(); + + usersStore = mockedStore(useUsersStore); + }); + + it('should not throw error when opened', async () => { + expect(() => renderComponent()).not.toThrow(); + }); + + it('should redirect to Signin when no inviterId and inviteeId', async () => { + renderComponent(); + + expect(toast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String)); + expect(router.replace).toHaveBeenCalledWith({ name: VIEWS.SIGNIN }); + }); + + it('should validate signup token if there is any', async () => { + route.query.inviterId = '123'; + route.query.inviteeId = '456'; + + renderComponent(); + + expect(usersStore.validateSignupToken).toHaveBeenCalledWith({ + inviterId: '123', + inviteeId: '456', + }); + }); + + it('should not accept invitation when missing tokens', async () => { + const { getByRole } = renderComponent(); + + const acceptButton = getByRole('button', { name: 'Finish account setup' }); + + await userEvent.click(acceptButton); + + expect(toast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String)); + expect(usersStore.acceptInvitation).not.toHaveBeenCalled(); + }); + + it('should not accept invitation when form is unfilled', async () => { + route.query.inviterId = '123'; + route.query.inviteeId = '456'; + + const { getByRole } = renderComponent(); + + const acceptButton = getByRole('button', { name: 'Finish account setup' }); + + await userEvent.click(acceptButton); + + expect(toast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String)); + expect(usersStore.acceptInvitation).not.toHaveBeenCalled(); + }); + + it('should accept invitation with tokens', async () => { + route.query.inviterId = '123'; + route.query.inviteeId = '456'; + + usersStore.validateSignupToken.mockResolvedValueOnce({ + inviter: { + firstName: 'John', + lastName: 'Doe', + }, + }); + + const { getByRole, container } = renderComponent(); + + const acceptButton = getByRole('button', { name: 'Finish account setup' }); + + const firstNameInput = container.querySelector('input[name="firstName"]'); + const lastNameInput = container.querySelector('input[name="lastName"]'); + const passwordInput = container.querySelector('input[type="password"]'); + + if (!firstNameInput || !lastNameInput || !passwordInput) { + throw new Error('Inputs not found'); + } + + // TODO: Remove manual tabbing when the following issue is fixed (it should fail the test anyway) + // https://github.com/testing-library/vue-testing-library/issues/317 + await userEvent.tab(); + expect(document.activeElement).toBe(firstNameInput); + + await userEvent.type(firstNameInput, 'Jane'); + await userEvent.type(lastNameInput, 'Doe'); + await userEvent.type(passwordInput, '324R435gfg5fgj!'); + + await userEvent.click(acceptButton); + + expect(toast.showError).not.toHaveBeenCalled(); + expect(usersStore.acceptInvitation).toHaveBeenCalledWith({ + inviterId: '123', + inviteeId: '456', + firstName: 'Jane', + lastName: 'Doe', + password: '324R435gfg5fgj!', + }); + }); +}); diff --git a/packages/editor-ui/src/views/SignupView.vue b/packages/editor-ui/src/views/SignupView.vue index 3395616207f..a68ba0a433b 100644 --- a/packages/editor-ui/src/views/SignupView.vue +++ b/packages/editor-ui/src/views/SignupView.vue @@ -30,6 +30,7 @@ const FORM_CONFIG: IFormBoxConfig = { required: true, autocomplete: 'given-name', capitalize: true, + focusInitially: true, }, }, {