Files
jobtrackingapp/job-tracker-ui/src/verify-email-page.test.tsx
T

116 lines
5.9 KiB
TypeScript

import React from 'react';
import '@testing-library/jest-dom';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import VerifyEmailPage from './views/VerifyEmailPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api, getApiErrorMessage } from './api';
import { translations } from './i18n/translations';
const mockedApi = api as jest.Mocked<typeof api>;
// CRA's jest config sets resetMocks: true, which wipes the initial implementation given to
// jest.fn() in setupTests.ts before every test -- re-arm it here so error-derived text is testable.
const mockedGetApiErrorMessage = getApiErrorMessage as jest.Mock;
function renderVerifyEmailPage(search: string, emailChange = false) {
const path = emailChange ? '/confirm-email-change' : '/verify-email';
window.history.pushState({}, '', `${path}${search}`);
return render(
<MemoryRouter initialEntries={[`${path}${search}`]}>
<I18nProvider>
<VerifyEmailPage emailChange={emailChange} />
</I18nProvider>
</MemoryRouter>,
);
}
describe('VerifyEmailPage', () => {
beforeEach(() => {
localStorage.clear();
mockedApi.post.mockReset();
mockedGetApiErrorMessage.mockImplementation((e: any, fallback?: string) => {
const data = e?.response?.data;
return typeof data === 'string' && data.trim() ? data.trim() : fallback;
});
});
it('confirms the account and shows success when the link is valid', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
renderVerifyEmailPage('?userId=user-1&token=good-token');
expect(await screen.findByText('Your email has been verified. You can now sign in.')).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith('/auth/verify-email', { userId: 'user-1', token: 'good-token' });
});
it('shows an error when the link is invalid or expired', async () => {
mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: 'Invalid or expired link.' } });
renderVerifyEmailPage('?userId=user-1&token=bad-token');
expect(await screen.findByText('Invalid or expired link.')).toBeInTheDocument();
});
it('shows an error without calling the API when the link is missing userId/token', async () => {
renderVerifyEmailPage('');
expect(await screen.findByText('Missing user/token in link.')).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('confirms a pending email change and requires a fresh sign-in', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
renderVerifyEmailPage('?userId=user-1&email=new%40example.com&token=change-token', true);
expect(await screen.findByText('Your email has changed. Sign in again with the new address.')).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith('/auth/email-change/confirm', { userId: 'user-1', email: 'new@example.com', token: 'change-token' });
});
it.each([false, true])('changing UI language never resubmits a pending confirmation: emailChange=%s', async emailChange => {
let finish!: (value: any) => void;
mockedApi.post.mockReturnValue(new Promise(resolve => { finish = resolve; }));
renderVerifyEmailPage('?userId=user-1&email=new%40example.test&token=one-use', emailChange);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
const pendingCalls = mockedApi.post.mock.calls.length;
await act(async () => finish({ data: {} }));
expect(pendingCalls).toBe(1);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByRole('status')).toHaveTextContent(translations.nb[emailChange ? 'confirmEmailChangeSuccess' : 'verifyEmailSuccess']);
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'en' })));
expect(screen.getByRole('status')).toHaveTextContent(translations.en[emailChange ? 'confirmEmailChangeSuccess' : 'verifyEmailSuccess']);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('localizes missing-link errors without a confirmation request', async () => {
renderVerifyEmailPage('');
expect(await screen.findByText(translations.en.missingVerifyLinkInfo)).toBeInTheDocument();
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.missingVerifyLinkInfo);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('localizes a failed confirmation fallback without retrying the one-time command', async () => {
mockedApi.post.mockRejectedValue({ response: { status: 503, data: '' } });
renderVerifyEmailPage('?userId=user-1&token=one-use');
expect(await screen.findByText(translations.en.verifyEmailFailed)).toBeInTheDocument();
await act(async () => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.verifyEmailFailed);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('does not let an older confirmation replace a different flow missing-link error', async () => {
let finish!: (value: any) => void;
mockedApi.post.mockReturnValue(new Promise(resolve => { finish = resolve; }));
const view = renderVerifyEmailPage('?userId=user-1&token=one-use');
window.history.replaceState({}, '', '/confirm-email-change?userId=user-1&token=one-use');
view.rerender(<MemoryRouter><I18nProvider><VerifyEmailPage emailChange /></I18nProvider></MemoryRouter>);
expect(await screen.findByText(translations.en.missingEmailChangeLinkInfo)).toBeInTheDocument();
await act(async () => finish({ data: {} }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.en.missingEmailChangeLinkInfo);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
});