diff --git a/src/features/login/EnforcePolicies.test.tsx b/src/features/login/EnforcePolicies.test.tsx new file mode 100644 index 00000000..e4d2c059 --- /dev/null +++ b/src/features/login/EnforcePolicies.test.tsx @@ -0,0 +1,111 @@ +import { ThemeProvider } from '@emotion/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { createTestStore } from '../../app/store'; +import { theme } from '../../theme'; +import { noOp } from '../common'; +import { EnforcePolicies } from './EnforcePolicies'; + +jest.mock('./Policies', () => ({ + ...jest.requireActual('./Policies'), + kbasePolicies: { + 'kbase-user': { + raw: '---\ntitle: KBase Terms and Conditions\nid: kbase-user\nversion: 1\nequivalentVersions: []\n---\nsome content', + markdown: 'some content', + title: 'KBase Terms and Conditions', + id: 'kbase-user', + version: '2', + equivalentVersions: [], + }, + 'test-policy': { + raw: '---\ntitle: Test Policy\nid: test-policy\nversion: 1\nequivalentVersions: []\n---\ntest content', + markdown: 'test content', + title: 'Test Policy', + id: 'test-policy', + version: '1', + equivalentVersions: [], + }, + }, +})); + +const renderWithProviders = ( + ui: React.ReactElement, + { store = createTestStore() } = {} +) => { + return render( + + + +
{ui}
+
+
+
+ ); +}; + +describe('EnforcePolicies', () => { + it('renders default message', () => { + renderWithProviders( + + ); + expect( + screen.getByText( + 'To continue to your account, you must agree to the following KBase use policies.' + ) + ).toBeInTheDocument(); + }); + + it('renders special v2 policy message', () => { + renderWithProviders( + + ); + expect( + screen.getByText( + "KBase's recent renewal (Oct '2024) has prompted an update and version 2 release to our Terms and Conditions. Please review and agree to these policies changes to continue using this free resource." + ) + ).toBeInTheDocument(); + }); + + it('disables accept button until all policies are accepted', async () => { + const mockAccept = jest.fn(); + renderWithProviders( + + ); + + const acceptButton = screen.getByRole('button', { + name: /agree and continue/i, + }); + expect(acceptButton).toBeDisabled(); + + const checkbox = screen.getByTestId('policy-checkbox'); + await userEvent.click(checkbox); + + expect(acceptButton).toBeEnabled(); + }); + + it('calls onAccept when accept button clicked', async () => { + const mockAccept = jest.fn(); + renderWithProviders( + + ); + + const checkbox = screen.getByTestId('policy-checkbox'); + await userEvent.click(checkbox); + + const acceptButton = screen.getByRole('button', { + name: /agree and continue/i, + }); + await userEvent.click(acceptButton); + + expect(mockAccept).toHaveBeenCalledWith(['kbase-user.2']); + }); + it('throws error when policy does not exist', () => { + expect(() => + renderWithProviders( + + ) + ).toThrow('Required policy "non-existent-policy" cannot be loaded'); + }); +}); diff --git a/src/features/login/EnforcePolicies.tsx b/src/features/login/EnforcePolicies.tsx index de196255..2c779b88 100644 --- a/src/features/login/EnforcePolicies.tsx +++ b/src/features/login/EnforcePolicies.tsx @@ -1,10 +1,22 @@ import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Alert, Button, Container, Paper } from '@mui/material'; +import { + Alert, + Button, + Container, + Paper, + Box, + Checkbox, + FormControl, + FormControlLabel, + Typography, +} from '@mui/material'; import { Stack } from '@mui/system'; import { useState } from 'react'; import classes from '../signup/SignUp.module.scss'; -import { kbasePolicies, PolicyViewer } from './Policies'; +import { kbasePolicies } from './Policies'; +import createDOMPurify from 'dompurify'; +import { marked } from 'marked'; export const EnforcePolicies = ({ policyIds, @@ -14,13 +26,17 @@ export const EnforcePolicies = ({ onAccept: (versionedPolicyIds: string[]) => void; }) => { // Get policy information - const targetPolicies = policyIds.map((id) => kbasePolicies[id]); + const targetPolicies = policyIds.map((id) => { + if (!kbasePolicies[id]) + throw new Error(`Required policy "${id}" cannot be loaded`); + return kbasePolicies[id]; + }); const [accepted, setAccepted] = useState<{ [k in typeof targetPolicies[number]['id']]?: boolean; }>({}); - const allAccepted = targetPolicies.every( - (policy) => accepted[policy.id] === true - ); + const allAccepted = targetPolicies.every((policy) => { + return accepted[policy.id] === true; + }); // Message to user, uses a special message when agreeing to kbase-user.2 let message = @@ -77,3 +93,47 @@ export const EnforcePolicies = ({ ); }; + +const purify = createDOMPurify(window); + +export const PolicyViewer = ({ + policyId, + setAccept, + accepted = false, +}: { + policyId: string; + setAccept: (accepted: boolean) => void; + accepted?: boolean; +}) => { + const policy = kbasePolicies[policyId]; + if (!policy) + throw new Error(`Required policy "${policyId}" cannot be loaded`); + return ( + + {policy.title} + +
+ +
+ + { + setAccept(e.currentTarget.checked); + }} + /> + } + label="I have read and agree to this policy" + /> + +
+ + ); +}; diff --git a/src/features/login/LogInContinue.test.tsx b/src/features/login/LogInContinue.test.tsx index e0a8b2a6..7a09ae38 100644 --- a/src/features/login/LogInContinue.test.tsx +++ b/src/features/login/LogInContinue.test.tsx @@ -245,4 +245,52 @@ describe('Login Continue', () => { expect(Login).toHaveBeenCalled(); }); }); + + it('handles new user signup flow', async () => { + // getLoginChoice - return create data instead of login data + fetchMock.mockResponseOnce( + JSON.stringify({ + login: [], + create: [ + { + id: 'newuserid', + provider: 'google', + username: 'newuser@google.com', + }, + ], + }) + ); + + const Signup = jest.fn(() => <>); + const store = createTestStore(); + render( + + + + + } /> + + + + + + ); + + await waitFor(() => { + // Check that login data was set in store + expect(store.getState().signup.loginData).toEqual({ + login: [], + create: [ + { + id: 'newuserid', + provider: 'google', + username: 'newuser@google.com', + }, + ], + }); + }); + await waitFor(() => { + expect(window.location.pathname === '/signup/2'); + }); + }); }); diff --git a/src/features/login/Policies.tsx b/src/features/login/Policies.tsx index 8d6bbeee..07196036 100644 --- a/src/features/login/Policies.tsx +++ b/src/features/login/Policies.tsx @@ -1,21 +1,8 @@ import policyStrings from 'kbase-policies'; import frontmatter from 'front-matter'; -import { - Box, - Checkbox, - FormControl, - FormControlLabel, - Paper, - Typography, -} from '@mui/material'; -import classes from './PolicyViewer.module.scss'; -import createDOMPurify from 'dompurify'; -import { marked } from 'marked'; export const ENFORCED_POLICIES = ['kbase-user']; -const purify = createDOMPurify(window); - interface PolicyMeta { title: string; id: string; @@ -46,44 +33,3 @@ export const kbasePolicies = policyStrings.reduce( } > ); - -export const PolicyViewer = ({ - policyId, - setAccept, - accepted = false, -}: { - policyId: string; - setAccept: (accepted: boolean) => void; - accepted?: boolean; -}) => { - const policy = kbasePolicies[policyId]; - if (!policy) - throw new Error(`Required policy "${policyId}" cannot be loaded`); - return ( - - {policy.title} - -
- -
- - { - setAccept(e.currentTarget.checked); - }} - /> - } - label="I have read and agree to this policy" - /> - -
- - ); -}; diff --git a/src/features/login/PolicyViewer.module.scss b/src/features/login/PolicyViewer.module.scss deleted file mode 100644 index c55bbe92..00000000 --- a/src/features/login/PolicyViewer.module.scss +++ /dev/null @@ -1,8 +0,0 @@ -.agreement-box { - border: 1px solid use-color("base"); - border-radius: 4px; - display: inline-block; - margin-top: 1rem; - padding: 1rem; - -} diff --git a/src/features/signup/AccountInformation.test.tsx b/src/features/signup/AccountInformation.test.tsx new file mode 100644 index 00000000..d491669b --- /dev/null +++ b/src/features/signup/AccountInformation.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { createTestStore } from '../../app/store'; +import { Provider } from 'react-redux'; +import { ThemeProvider } from '@mui/material'; +import { BrowserRouter, useNavigate } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { AccountInformation } from './AccountInformation'; +import { setLoginData } from './SignupSlice'; +import { theme } from '../../theme'; +import { act } from 'react-dom/test-utils'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); +jest.mock('../../common/api/authService', () => ({ + ...jest.requireActual('../../common/api/authService'), + loginUsernameSuggest: { + useQuery: jest.fn().mockReturnValue({ + currentData: { availablename: 'testuser' }, + isFetching: false, + }), + }, +})); + +const renderWithProviders = ( + ui: React.ReactElement, + { store = createTestStore() } = {} +) => { + return render( + + + +
{ui}
+
+
+
+ ); +}; + +describe('AccountInformation', () => { + const mockNavigate = jest.fn(); + + beforeEach(() => { + (useNavigate as jest.Mock).mockImplementation(() => mockNavigate); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('redirects to step 1 if no login data', () => { + const store = createTestStore(); + renderWithProviders(, { store }); + expect(mockNavigate).toHaveBeenCalledWith('/signup/1'); + }); + + test('displays login data from provider', () => { + const store = createTestStore(); + store.dispatch( + setLoginData({ + creationallowed: true, + expires: 0, + login: [], + provider: 'Google', + create: [ + { + provemail: 'test@test.com', + provfullname: 'Test User', + availablename: 'testuser', + id: '123', + provusername: 'testuser', + }, + ], + }) + ); + renderWithProviders(, { store }); + expect(screen.getAllByText(/Google/)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/test@test.com/)[0]).toBeInTheDocument(); + }); + + test('form submission with valid data', async () => { + const store = createTestStore(); + store.dispatch( + setLoginData({ + creationallowed: true, + expires: 0, + login: [], + provider: 'Google', + create: [ + { + provemail: 'test@test.com', + provfullname: 'Test User', + availablename: 'testuser', + id: '123', + provusername: 'testuser', + }, + ], + }) + ); + renderWithProviders(, { store }); + + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Full Name/i }), { + target: { value: 'Test User' }, + }); + }); + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Email/i }), { + target: { value: 'test@test.com' }, + }); + }); + await act(() => { + fireEvent.change( + screen.getByRole('textbox', { name: /KBase Username/i }), + { + target: { value: 'testuser' }, + } + ); + }); + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Organization/i }), { + target: { value: 'Test Org' }, + }); + }); + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Department/i }), { + target: { value: 'Test Dept' }, + }); + }); + + await act(() => { + fireEvent.submit(screen.getByTestId('accountinfoform')); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/signup/3'); + }); +}); diff --git a/src/features/signup/AccountInformation.tsx b/src/features/signup/AccountInformation.tsx index 28895be7..7e92c0e4 100644 --- a/src/features/signup/AccountInformation.tsx +++ b/src/features/signup/AccountInformation.tsx @@ -17,7 +17,7 @@ import { TextField, Typography, } from '@mui/material'; -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useState, Fragment } from 'react'; import { toast } from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '../../common/hooks'; @@ -158,7 +158,7 @@ export const AccountInformation: FC<{}> = () => { -
+ Create a new KBase Account @@ -254,7 +254,7 @@ export const AccountInformation: FC<{}> = () => { {ReferalSources.map((source) => { if (source.customText) { return ( - <> + = () => { }} /> - + ); } else { return ( { - Already have an account? Log in + Already have an account? Log in ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useParams: jest.fn(), +})); + +const mockNavigate = jest.fn(); +const mockScrollTo = jest.fn(); + +const renderWithProviders = ( + ui: React.ReactElement, + { store = createTestStore() } = {} +) => { + return render( + + + +
{ui}
+
+
+
+ ); +}; + +describe('SignUp', () => { + beforeEach(() => { + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + (useParams as jest.Mock).mockReturnValue({ step: '1' }); + Element.prototype.scrollTo = mockScrollTo; + }); + + it('renders signup steps', () => { + renderWithProviders(); + expect(screen.getByText('Sign up for KBase')).toBeInTheDocument(); + expect( + screen.getByText('Sign up with a supported provider') + ).toBeInTheDocument(); + expect(screen.getByText('Account information')).toBeInTheDocument(); + expect(screen.getByText('KBase use policies')).toBeInTheDocument(); + }); + + it('navigates between steps when clicking previous steps', async () => { + (useParams as jest.Mock).mockReturnValue({ step: '3' }); + renderWithProviders(); + + const step1 = screen.getByText('Sign up with a supported provider'); + await userEvent.click(step1); + expect(mockNavigate).toHaveBeenCalledWith('/signup/1'); + expect(mockScrollTo).toHaveBeenCalledWith(0, 0); + }); +}); + +describe('useDoSignup', () => { + const mockLoginCreateMutation = jest.fn(); + const mockSetUserProfileMutation = jest.fn(); + + beforeEach(() => { + jest.spyOn(loginCreate, 'useMutation').mockReturnValue([ + mockLoginCreateMutation, + { + isUninitialized: false, + isSuccess: true, + data: { token: { token: 'someToken' } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ]); + jest.spyOn(setUserProfile, 'useMutation').mockReturnValue([ + mockSetUserProfileMutation, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { isUninitialized: true } as any, + ]); + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + }); + + it('calls login create and set user profile mutations', async () => { + const mockStore = createTestStore({ + signup: { + loginData: { + create: [ + { + id: '123', + availablename: '', + provemail: '', + provfullname: '', + provusername: '', + }, + ], + creationallowed: true, + expires: 0, + login: [], + provider: 'Google', + }, + account: { + username: 'testuser', + display: 'Test User', + email: 'test@test.com', + policyids: [], + }, + profile: { + userdata: { + avatarOption: 'gravatar', + department: '', + gravatarDefault: 'identicon', + organization: '', + }, + surveydata: { + referralSources: { + question: '', + response: {}, + }, + }, + }, + }, + }); + + let doSignup: (policyIds: string[]) => void; + act(() => { + const TestComponent = () => { + [doSignup] = useDoSignup(); + return null; + }; + renderWithProviders(, { + store: mockStore, + }); + }); + + await act(async () => { + doSignup(['policy1']); + }); + + expect(mockLoginCreateMutation).toHaveBeenCalledWith({ + id: '123', + user: 'testuser', + display: 'Test User', + email: 'test@test.com', + policyids: ['policy1'], + linkall: false, + }); + + expect(mockSetUserProfileMutation).toHaveBeenCalledWith([ + { + profile: { + user: { + username: 'testuser', + realname: 'Test User', + }, + profile: { + metadata: expect.any(Object), + preferences: {}, + synced: { + gravatarHash: gravatarHash('test@test.com'), + }, + userdata: { + avatarOption: 'gravatar', + department: '', + gravatarDefault: 'identicon', + organization: '', + }, + surveydata: { + referralSources: { + question: '', + response: {}, + }, + }, + }, + }, + }, + 'someToken', + ]); + }); +}); diff --git a/src/features/signup/SignUp.tsx b/src/features/signup/SignUp.tsx index fe62cf90..1fdc9e2e 100644 --- a/src/features/signup/SignUp.tsx +++ b/src/features/signup/SignUp.tsx @@ -39,7 +39,7 @@ export const SignUp: FC = () => { }; useEffect(() => { - document.querySelector('main')?.scrollTo(0, 0); + document.querySelector('main')?.scrollTo?.(0, 0); }, [activeStep]); return ( @@ -148,6 +148,6 @@ export const useDoSignup = () => { return [doSignup, loading, complete, error] as const; }; -const gravatarHash = (email: string) => { +export const gravatarHash = (email: string) => { return md5.create().update(email.trim().toLowerCase()).hex(); }; diff --git a/src/features/signup/SignupPolicies.test.tsx b/src/features/signup/SignupPolicies.test.tsx new file mode 100644 index 00000000..c301e33d --- /dev/null +++ b/src/features/signup/SignupPolicies.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { configureStore } from '@reduxjs/toolkit'; +import { KBasePolicies } from './SignupPolicies'; +import { toast } from 'react-hot-toast'; +import * as SignUp from './SignUp'; + +// Mock dependencies +jest.mock('react-hot-toast'); +jest.mock('./AccountInformation', () => ({ + useCheckLoginDataOk: jest.fn(), +})); +jest.mock('./SignUp', () => ({ + useDoSignup: jest.fn(), +})); +jest.mock('../login/Policies', () => ({ + kbasePolicies: { + termsOfService: { + id: 'termsOfService', + version: '1', + title: 'Terms of Service', + }, + privacyPolicy: { + id: 'privacyPolicy', + version: '1', + title: 'Privacy Policy', + }, + }, + PolicyViewer: ({ + policyId, + accepted, + setAccept, + }: { + policyId: string; + accepted: boolean; + setAccept: (checked: boolean) => void; + }) => ( +
+ setAccept(e.target.checked)} + data-testid={`checkbox-${policyId}`} + /> +
+ ), +})); +const mockScrollTo = jest.fn(); +Element.prototype.scrollTo = mockScrollTo; + +// Create a mock store +const createMockStore = (initialState = {}) => { + return configureStore({ + reducer: { + signup: (state = { account: {} }, action) => state, + }, + preloadedState: { + signup: { + account: { + username: 'testuser', + email: 'test@test.com', + ...initialState, + }, + }, + }, + }); +}; + +describe('KBasePolicies', () => { + const mockNavigate = jest.fn(); + const mockDoSignup = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (SignUp.useDoSignup as jest.Mock).mockReturnValue([mockDoSignup, false]); + }); + + const renderComponent = (store = createMockStore()) => { + return render( + + + + + + ); + }; + + it('should render all policies', () => { + renderComponent(); + expect(screen.getByTestId('policy-termsOfService')).toBeInTheDocument(); + expect(screen.getByTestId('policy-privacyPolicy')).toBeInTheDocument(); + }); + + it('should not call doSignup when policies are not accepted', () => { + renderComponent(); + // Try to mock the button as enabled to ensure signup doesn't happen + const submitButton = screen.getByText('Create KBase account'); + Object.defineProperty(submitButton, 'disabled', { value: false }); + fireEvent.click(submitButton); + + // Verify doSignup was not called + expect(mockDoSignup).not.toHaveBeenCalled(); + }); + + it('should handle policy acceptance', () => { + renderComponent(); + const tosCheckbox = screen.getByTestId('checkbox-termsOfService'); + const privacyCheckbox = screen.getByTestId('checkbox-privacyPolicy'); + + // Initially, submit button should be disabled + const submitButton = screen.getByText('Create KBase account'); + expect(submitButton).toBeDisabled(); + + // Accept policies + fireEvent.click(tosCheckbox); + fireEvent.click(privacyCheckbox); + + // Submit button should be enabled + expect(submitButton).not.toBeDisabled(); + }); + + it('should call doSignup when all policies are accepted and form is submitted', () => { + renderComponent(); + + // Accept all policies + fireEvent.click(screen.getByTestId('checkbox-termsOfService')); + fireEvent.click(screen.getByTestId('checkbox-privacyPolicy')); + + // Submit form + fireEvent.click(screen.getByText('Create KBase account')); + + // Verify doSignup was called with correct parameters + expect(mockDoSignup).toHaveBeenCalledWith([ + 'termsOfService.1', + 'privacyPolicy.1', + ]); + }); + + it('should show warning toast if account information is missing', () => { + const store = createMockStore({ username: undefined }); + renderComponent(store); + expect(toast).toHaveBeenCalledWith( + 'You must fill out your account information to sign up!' + ); + }); + it('should navigate when cancel button is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('Cancel sign up')); + expect(mockNavigate).toHaveBeenCalledWith('/signup/1'); + }); + + it('should navigate when back button is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('Back to account information')); + expect(mockNavigate).toHaveBeenCalledWith('/signup/2'); + }); +}); diff --git a/src/features/signup/SignupPolicies.tsx b/src/features/signup/SignupPolicies.tsx index 0666bd0a..2d205725 100644 --- a/src/features/signup/SignupPolicies.tsx +++ b/src/features/signup/SignupPolicies.tsx @@ -6,7 +6,8 @@ import { toast } from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import { Loader } from '../../common/components'; import { useAppDispatch, useAppSelector } from '../../common/hooks'; -import { kbasePolicies, PolicyViewer } from '../login/Policies'; +import { PolicyViewer } from '../login/EnforcePolicies'; +import { kbasePolicies } from '../login/Policies'; import { useCheckLoginDataOk } from './AccountInformation'; import { useDoSignup } from './SignUp'; import classes from './SignUp.module.scss';