-
Notifications
You must be signed in to change notification settings - Fork 4
fix(auth): report a failed sign-up in the form, not a fading toast #1613
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
2f5ba35
fix(auth): tell a signing-up user their email is already registered
dawsontoth 4bc5551
test(auth): give each SignUp test its own QueryClient
dawsontoth 7c69b47
docs(auth): correct the 409 rationale on isEmailAlreadyRegisteredError
dawsontoth 35bef39
fix(auth): report a failed sign-up in the form, not a fading toast
dawsontoth 75e46d1
fix(auth): keep the whole sentence in the inline sign-up error
dawsontoth File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
| import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
| import { fireEvent, render, screen, waitFor } from '@testing-library/react'; | ||
| import { AxiosError } from 'axios'; | ||
| import { PropsWithChildren } from 'react'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| const { post } = vi.hoisted(() => ({ post: vi.fn() })); | ||
| vi.mock('@/config/apiClient', () => ({ apiClient: { post } })); | ||
|
|
||
| const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() })); | ||
| vi.mock('@tanstack/react-router', () => ({ | ||
| useNavigate: () => navigate, | ||
| useSearch: () => ({}), | ||
| Link: ({ children, ...rest }: PropsWithChildren<{ className?: string }>) => <a {...rest}>{children}</a>, | ||
| })); | ||
|
|
||
| vi.mock('sonner', () => ({ | ||
| toast: { error: vi.fn(), dismiss: vi.fn() }, | ||
| })); | ||
|
|
||
| vi.mock('@/integrations/reo/reo', () => ({ reoClient: { identify: vi.fn() } })); | ||
|
|
||
| import { toast } from 'sonner'; | ||
| // The app's own mutation-error routing, not a copy of it: whether the form's failure also | ||
| // reaches the global toast is part of what's under test, so restating that rule here would let | ||
| // these tests pass even if `skipGlobalErrorToast` stopped being honored. | ||
| import { mutationErrorHandler } from '@/react-query/queryClient'; | ||
| import { SignUp } from './SignUp'; | ||
|
|
||
| function axiosError(status: number, data?: unknown): AxiosError { | ||
| return { isAxiosError: true, response: { status, data } } as AxiosError; | ||
| } | ||
|
|
||
| // Fresh per test, so nothing leaks between them. | ||
| let queryClient: QueryClient; | ||
|
|
||
| function renderSignUp() { | ||
| return render( | ||
| <QueryClientProvider client={queryClient}> | ||
| <SignUp /> | ||
| </QueryClientProvider>, | ||
| ); | ||
| } | ||
|
|
||
| function fillValidForm() { | ||
| fireEvent.change(screen.getByLabelText('First Name'), { target: { value: 'Ada' } }); | ||
| fireEvent.change(screen.getByLabelText('Last Name'), { target: { value: 'Lovelace' } }); | ||
| fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'taken@example.com' } }); | ||
| fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct horse battery' } }); | ||
| fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'correct horse battery' } }); | ||
| // `termsCheckbox` is rendered twice (above the OAuth buttons and inside the form); either | ||
| // one drives the same field. | ||
| fireEvent.click(screen.getAllByRole('checkbox')[0]); | ||
| } | ||
|
|
||
| function submit() { | ||
| fireEvent.click(screen.getByRole('button', { name: 'Sign Up For Free' })); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| queryClient = new QueryClient({ | ||
| mutationCache: new MutationCache({ onError: mutationErrorHandler }), | ||
| defaultOptions: { mutations: { retry: false } }, | ||
| }); | ||
| }); | ||
|
|
||
| afterEach(() => vi.clearAllMocks()); | ||
|
|
||
| describe('SignUp', () => { | ||
| it("reports the server's reason in the form rather than a toast", async () => { | ||
| post.mockRejectedValue(axiosError(500, { code: 'InternalError', title: 'Signup is unavailable' })); | ||
|
|
||
| renderSignUp(); | ||
| fillValidForm(); | ||
| submit(); | ||
|
|
||
| await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Signup is unavailable')); | ||
| expect(toast.error).not.toHaveBeenCalled(); | ||
| expect(navigate).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| // Whatever central-manager rejects with has to reach the user — the form maps no status | ||
| // codes of its own, so this must hold for a shape it has never seen. | ||
| it.each([ | ||
| [400, { error: 'Password is too short' }, 'Password is too short'], | ||
| [409, 'User already exists', 'User already exists'], | ||
| // A legacy "Code: sentence" body: the toast splits the first clause into its heading, and | ||
| // the inline line has no heading — it must still read as a whole sentence. | ||
| [409, 'Conflict: user already exists', 'Conflict: user already exists'], | ||
| [503, undefined, 'We had some trouble!'], | ||
| ])('surfaces a %i rejection inline', async (status, data, expected) => { | ||
| post.mockRejectedValue(axiosError(status, data)); | ||
|
|
||
| renderSignUp(); | ||
| fillValidForm(); | ||
| submit(); | ||
|
|
||
| await waitFor(() => expect(screen.getByRole('alert').textContent).toContain(expected)); | ||
| }); | ||
|
|
||
| it('clears the previous failure when the form is resubmitted', async () => { | ||
| post.mockRejectedValueOnce(axiosError(503)); | ||
|
|
||
| renderSignUp(); | ||
| fillValidForm(); | ||
| submit(); | ||
| await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy()); | ||
|
|
||
| post.mockResolvedValueOnce({ data: { id: 'usr-1', email: 'taken@example.com' } }); | ||
| submit(); | ||
|
|
||
| await waitFor(() => expect(navigate).toHaveBeenCalled()); | ||
| expect(screen.queryByRole('alert')).toBeNull(); | ||
| }); | ||
|
|
||
| it('disables the submit button while the sign-up request is in flight', async () => { | ||
| let settle: (() => void) | undefined; | ||
| post.mockReturnValue( | ||
| new Promise((_resolve, reject) => { | ||
| settle = () => reject(axiosError(503)); | ||
| }), | ||
| ); | ||
|
|
||
| renderSignUp(); | ||
| fillValidForm(); | ||
| submit(); | ||
|
|
||
| const button = screen.getByRole('button', { name: 'Sign Up For Free' }); | ||
| await waitFor(() => expect(button.hasAttribute('disabled')).toBe(true)); | ||
|
|
||
| settle!(); | ||
| await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false)); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.