From f1ea2b5c020edecabc9d6e178bebc1d51ca1628e Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 13 Mar 2022 09:14:41 +0200 Subject: [PATCH] chore(account): write tests for CreateAccessToken [EE-2561] (#6578) --- app/__mocks__/i18next.test.ts | 39 ++++++++++++++ app/__mocks__/i18next.ts | 36 +++++++++++++ app/__mocks__/react-i18next.tsx | 16 ++++++ app/portainer/components/ReactExample.tsx | 4 +- .../CreateAccessToken.test.tsx | 54 +++++++++++++++++++ .../CreateAccessToken/CreateAccessToken.tsx | 34 ++++-------- app/setup-tests/i18n.ts | 21 ++++++++ tsconfig.json | 1 + 8 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 app/__mocks__/i18next.test.ts create mode 100644 app/__mocks__/i18next.ts create mode 100644 app/__mocks__/react-i18next.tsx create mode 100644 app/portainer/views/account/CreateAccessToken/CreateAccessToken.test.tsx create mode 100644 app/setup-tests/i18n.ts diff --git a/app/__mocks__/i18next.test.ts b/app/__mocks__/i18next.test.ts new file mode 100644 index 000000000..e6cc9707f --- /dev/null +++ b/app/__mocks__/i18next.test.ts @@ -0,0 +1,39 @@ +import * as i18nextMocks from './i18next'; + +describe('mockT', () => { + it('should return correctly with no arguments', async () => { + const testText = `The company's new IT initiative, code named Phoenix Project, is critical to the + future of Parts Unlimited, but the project is massively over budget and very late. The CEO wants + Bill to report directly to him and fix the mess in ninety days or else Bill's entire department + will be outsourced.`; + + const translatedText = i18nextMocks.mockT(testText); + + expect(translatedText).toBe(testText); + }); + + test.each` + testText | args | expectedText + ${'{{fileName}} is invalid.'} | ${{ fileName: 'example_5.csv' }} | ${'example_5.csv is invalid.'} + ${'{{fileName}} {is}.'} | ${{ fileName: ' ' }} | ${' {is}.'} + ${'{{number}} of {{total}}'} | ${{ number: 0, total: 999 }} | ${'0 of 999'} + ${'There was an error:\n{{error}}'} | ${{ error: 'Failed' }} | ${'There was an error:\nFailed'} + ${'Click:{{li}}{{li2}}{{li_3}}'} | ${{ li: '', li2: 'https://', li_3: '!@#$%' }} | ${'Click:https://!@#$%'} + ${'{{happy}}😏y✔{{sad}}{{laugh}}'} | ${{ happy: '😃', sad: '😢', laugh: '🤣' }} | ${'😃😏y✔😢🤣'} + `( + 'should return correctly while handling arguments in different scenarios', + ({ testText, args, expectedText }) => { + const translatedText = i18nextMocks.mockT(testText, args); + + expect(translatedText).toBe(expectedText); + } + ); +}); + +describe('language', () => { + it('should return language', async () => { + const { language } = i18nextMocks.default; + + expect(language).toBe('en'); + }); +}); diff --git a/app/__mocks__/i18next.ts b/app/__mocks__/i18next.ts new file mode 100644 index 000000000..105ebf9e8 --- /dev/null +++ b/app/__mocks__/i18next.ts @@ -0,0 +1,36 @@ +function replaceBetween( + startIndex: number, + endIndex: number, + original: string, + insertion: string +) { + const result = + original.substring(0, startIndex) + + insertion + + original.substring(endIndex); + return result; +} + +export function mockT(i18nKey: string, args?: Record) { + let key = i18nKey; + + while (key.includes('{{') && args) { + const startIndex = key.indexOf('{{'); + const endIndex = key.indexOf('}}'); + + const currentArg = key.substring(startIndex + 2, endIndex); + const value = args[currentArg]; + + key = replaceBetween(startIndex, endIndex + 2, key, value); + } + + return key; +} + +const i18next: Record = jest.createMockFromModule('i18next'); +i18next.t = mockT; +i18next.language = 'en'; +i18next.changeLanguage = () => new Promise(() => {}); +i18next.use = () => i18next; + +export default i18next; diff --git a/app/__mocks__/react-i18next.tsx b/app/__mocks__/react-i18next.tsx new file mode 100644 index 000000000..f2aa454d3 --- /dev/null +++ b/app/__mocks__/react-i18next.tsx @@ -0,0 +1,16 @@ +import { PropsWithChildren } from 'react'; + +import { mockT } from './i18next'; + +export function useTranslation() { + return { + t: mockT, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + }; +} + +export function Trans({ children }: PropsWithChildren) { + return <>{children}; +} diff --git a/app/portainer/components/ReactExample.tsx b/app/portainer/components/ReactExample.tsx index af27d2dd9..63a7573f2 100644 --- a/app/portainer/components/ReactExample.tsx +++ b/app/portainer/components/ReactExample.tsx @@ -30,12 +30,12 @@ export function ReactExample({ text }: ReactExampleProps) {
{text}
- {t('reactExample.registries.useSref', 'Registries useSref')} + {t('Registries useSref')}
- + Registries Link diff --git a/app/portainer/views/account/CreateAccessToken/CreateAccessToken.test.tsx b/app/portainer/views/account/CreateAccessToken/CreateAccessToken.test.tsx new file mode 100644 index 000000000..01fd7b6e2 --- /dev/null +++ b/app/portainer/views/account/CreateAccessToken/CreateAccessToken.test.tsx @@ -0,0 +1,54 @@ +import userEvent from '@testing-library/user-event'; + +import { render } from '@/react-tools/test-utils'; + +import { CreateAccessToken } from './CreateAccessToken'; + +test('the button is disabled when description is missing and enabled when description is filled', async () => { + const queries = renderComponent(); + + const button = queries.getByRole('button', { name: 'Add access token' }); + + expect(button).toBeDisabled(); + + const descriptionField = queries.getByLabelText('Description'); + + userEvent.type(descriptionField, 'description'); + + expect(button).toBeEnabled(); + + userEvent.clear(descriptionField); + + expect(button).toBeDisabled(); +}); + +test('once the button is clicked, the access token is generated and displayed', async () => { + const token = 'a very long access token that should be displayed'; + const onSubmit = jest.fn(() => Promise.resolve({ rawAPIKey: token })); + + const queries = renderComponent(onSubmit); + + const descriptionField = queries.getByLabelText('Description'); + + userEvent.type(descriptionField, 'description'); + + const button = queries.getByRole('button', { name: 'Add access token' }); + + userEvent.click(button); + + expect(onSubmit).toHaveBeenCalledWith('description'); + expect(onSubmit).toHaveBeenCalledTimes(1); + + await expect(queries.findByText('New access token')).resolves.toBeVisible(); + expect(queries.getByText(token)).toHaveTextContent(token); +}); + +function renderComponent(onSubmit = jest.fn()) { + const queries = render( + + ); + + expect(queries.getByLabelText('Description')).toBeVisible(); + + return queries; +} diff --git a/app/portainer/views/account/CreateAccessToken/CreateAccessToken.tsx b/app/portainer/views/account/CreateAccessToken/CreateAccessToken.tsx index e1cf37f7e..406d50a01 100644 --- a/app/portainer/views/account/CreateAccessToken/CreateAccessToken.tsx +++ b/app/portainer/views/account/CreateAccessToken/CreateAccessToken.tsx @@ -18,9 +18,6 @@ interface AccessTokenResponse { } export interface Props { - // userId for whom the access token is generated for - userId: number; - // onSubmit dispatches a successful matomo analytics event onSubmit: (description: string) => Promise; @@ -32,7 +29,8 @@ export function CreateAccessToken({ onSubmit, onError, }: PropsWithChildren) { - const { t } = useTranslation(); + const translationNS = 'account.accessTokens.create'; + const { t } = useTranslation(translationNS); const router = useRouter(); const [description, setDescription] = useState(''); @@ -42,12 +40,7 @@ export function CreateAccessToken({ useEffect(() => { if (description.length === 0) { - setErrorText( - t( - 'users.access-tokens.create.form.description-field.error.required', - 'this field is required' - ) - ); + setErrorText(t('this field is required')); } else setErrorText(''); }, [description, t]); @@ -73,10 +66,7 @@ export function CreateAccessToken({
generateAccessToken()} className={styles.addButton} > - {t('users.access-tokens.create.add-button', 'Add access token')} + {t('Add access token')}
{accessToken && ( <> - - New access token - + New access token - + Please copy the new access token. You won't be able to view the token again. {accessToken} - - Copy access token - + Copy access token
)} diff --git a/app/setup-tests/i18n.ts b/app/setup-tests/i18n.ts new file mode 100644 index 000000000..66eab9e4c --- /dev/null +++ b/app/setup-tests/i18n.ts @@ -0,0 +1,21 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import translation from '../../translations/en/translation.json'; + +i18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + + // have a common namespace used around the full app + ns: ['translationsNS'], + defaultNS: 'translationsNS', + + interpolation: { + escapeValue: false, + }, + + resources: { en: { translationsNS: translation } }, +}); + +export default i18n; diff --git a/tsconfig.json b/tsconfig.json index d3d1ad0eb..339d07d91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ "strictNullChecks": true, "noUnusedLocals": true, "removeComments": true, + "resolveJsonModule": true, // "sourceMap": true, "lib": ["dom", "dom.iterable", "esnext"], "paths": {