mirror of
https://github.com/supabase/supabase.git
synced 2026-06-28 19:39:19 -04:00
9660b0075c
## What kind of change does this PR introduce? Code cleanup. Follow-up to #45774. ## What is the current behavior? The organisation invite interstitial derives invite states, titles, and descriptions from nested conditional logic in the component. That makes the component harder to scan and pushes too much state coverage into render tests. ## What is the new behavior? See #45774 for screenshots of the general UI before-and-after (which this one builds upon). That PR also contains testing instructions. Extracts the invite status and content decisions into small pure helpers, then covers those helpers with focused unit tests. The component keeps the user-facing render and interaction coverage, including the invalid lookup regression where a 404 should render the invalid invite state instead of raw backend copy. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Improved organization invite flow with enhanced error state handling for expired, invalid, and wrong-account scenarios. * Better consistency in error messages and user guidance throughout the invite process. [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45813) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
250 lines
7.6 KiB
TypeScript
250 lines
7.6 KiB
TypeScript
import { screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
|
import { OrganizationInvite } from '@/components/interfaces/OrganizationInvite/OrganizationInvite'
|
|
import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query'
|
|
import type { ProfileContextType } from '@/lib/profile'
|
|
import { render } from '@/tests/helpers'
|
|
import type { ResponseError } from '@/types'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
isLoggedIn: vi.fn(),
|
|
useParams: vi.fn(),
|
|
useProfile: vi.fn(),
|
|
useProfileNameAndPicture: vi.fn(),
|
|
useIsFeatureEnabled: vi.fn(),
|
|
useInvitationQuery: vi.fn(),
|
|
useAcceptInvitationMutation: vi.fn(),
|
|
acceptInvitation: vi.fn(),
|
|
signOut: vi.fn(),
|
|
routerPush: vi.fn(),
|
|
routerReload: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('common', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as typeof import('common')
|
|
return {
|
|
...actual,
|
|
useIsLoggedIn: mocks.isLoggedIn,
|
|
useParams: mocks.useParams,
|
|
}
|
|
})
|
|
|
|
vi.mock('next/router', () => ({
|
|
useRouter: () => ({
|
|
isReady: true,
|
|
push: mocks.routerPush,
|
|
reload: mocks.routerReload,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/profile', () => ({
|
|
useProfile: mocks.useProfile,
|
|
useProfileNameAndPicture: mocks.useProfileNameAndPicture,
|
|
}))
|
|
|
|
vi.mock('@/lib/auth', () => ({
|
|
useSignOut: () => mocks.signOut,
|
|
}))
|
|
|
|
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
|
|
useIsFeatureEnabled: mocks.useIsFeatureEnabled,
|
|
}))
|
|
|
|
vi.mock('@/data/organization-members/organization-invitation-token-query', () => ({
|
|
useOrganizationInvitationTokenQuery: mocks.useInvitationQuery,
|
|
}))
|
|
|
|
vi.mock('@/data/organization-members/organization-invitation-accept-mutation', () => ({
|
|
useOrganizationAcceptInvitationMutation: mocks.useAcceptInvitationMutation,
|
|
}))
|
|
|
|
const READY_INVITE: OrganizationInviteByToken = {
|
|
authorized_user: true,
|
|
email_match: true,
|
|
expired_token: false,
|
|
invite_id: 42,
|
|
organization_name: 'Acme Corp',
|
|
sso_mismatch: false,
|
|
token_does_not_exist: false,
|
|
}
|
|
|
|
const PROFILE: ProfileContextType['profile'] = {
|
|
id: 1,
|
|
auth0_id: 'auth0|test',
|
|
gotrue_id: 'gotrue-test',
|
|
username: 'jane',
|
|
primary_email: 'jane@acmecorp.io',
|
|
first_name: null,
|
|
last_name: null,
|
|
mobile: null,
|
|
is_alpha_user: false,
|
|
is_sso_user: false,
|
|
disabled_features: [],
|
|
free_project_limit: null,
|
|
}
|
|
|
|
const responseError = (message: string, code = 500) => ({ message, code }) as ResponseError
|
|
|
|
function setSignedInDefaults() {
|
|
mocks.isLoggedIn.mockReturnValue(true)
|
|
mocks.useParams.mockReturnValue({ slug: 'acme-corp', token: 'invite-token' })
|
|
mocks.useProfile.mockReturnValue({
|
|
profile: PROFILE,
|
|
isLoading: false,
|
|
})
|
|
mocks.useProfileNameAndPicture.mockReturnValue({
|
|
username: 'jane',
|
|
primaryEmail: 'jane@acmecorp.io',
|
|
avatarUrl: undefined,
|
|
isLoading: false,
|
|
})
|
|
mocks.useIsFeatureEnabled.mockReturnValue(true)
|
|
mocks.useInvitationQuery.mockReturnValue({
|
|
data: READY_INVITE,
|
|
error: null,
|
|
isSuccess: true,
|
|
isError: false,
|
|
isPending: false,
|
|
})
|
|
mocks.useAcceptInvitationMutation.mockReturnValue({
|
|
mutate: mocks.acceptInvitation,
|
|
isPending: false,
|
|
})
|
|
}
|
|
|
|
describe('OrganizationInvite', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
setSignedInDefaults()
|
|
})
|
|
|
|
test('renders sign-in actions for signed-out users', () => {
|
|
mocks.isLoggedIn.mockReturnValue(false)
|
|
mocks.useProfile.mockReturnValue({ profile: null, isLoading: true })
|
|
mocks.useProfileNameAndPicture.mockReturnValue({
|
|
username: undefined,
|
|
primaryEmail: undefined,
|
|
avatarUrl: undefined,
|
|
isLoading: false,
|
|
})
|
|
|
|
render(<OrganizationInvite />)
|
|
|
|
expect(screen.getByText('View invitation')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText('Sign in or create an account to view this invitation')
|
|
).toBeInTheDocument()
|
|
expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute(
|
|
'href',
|
|
'/sign-in?returnTo=%2Fjoin%3Ftoken%3Dinvite-token%26slug%3Dacme-corp'
|
|
)
|
|
expect(screen.getByRole('link', { name: 'Create an account' })).toHaveAttribute(
|
|
'href',
|
|
'/sign-up?returnTo=%2Fjoin%3Ftoken%3Dinvite-token%26slug%3Dacme-corp'
|
|
)
|
|
})
|
|
|
|
test('renders a ready invite for the signed-in account', () => {
|
|
render(<OrganizationInvite />)
|
|
|
|
expect(screen.getByText('Join Acme Corp')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText('You have been invited to join this Supabase organization')
|
|
).toBeInTheDocument()
|
|
expect(screen.getByText('Signed in as')).toBeInTheDocument()
|
|
expect(screen.getByText('jane@acmecorp.io')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Accept invite' })).toBeInTheDocument()
|
|
expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/projects')
|
|
})
|
|
|
|
test('accepts an invite with the current slug and token', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
render(<OrganizationInvite />)
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Accept invite' }))
|
|
|
|
expect(mocks.acceptInvitation).toHaveBeenCalledWith({
|
|
slug: 'acme-corp',
|
|
token: 'invite-token',
|
|
})
|
|
})
|
|
|
|
test('renders a wrong-account warning and signs out', async () => {
|
|
const user = userEvent.setup()
|
|
mocks.useInvitationQuery.mockReturnValue({
|
|
data: { ...READY_INVITE, email_match: false },
|
|
error: null,
|
|
isSuccess: true,
|
|
isError: false,
|
|
isPending: false,
|
|
})
|
|
mocks.signOut.mockResolvedValue(undefined)
|
|
|
|
render(<OrganizationInvite />)
|
|
|
|
expect(screen.getByText('Wrong account')).toBeInTheDocument()
|
|
expect(screen.queryByText('Join Acme Corp')).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByText('You have been invited to join this Supabase organization')
|
|
).not.toBeInTheDocument()
|
|
expect(screen.getByText(/jane@acmecorp\.io/)).toBeInTheDocument()
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Sign out' }))
|
|
|
|
await waitFor(() => expect(mocks.signOut).toHaveBeenCalled())
|
|
expect(mocks.routerReload).toHaveBeenCalled()
|
|
})
|
|
|
|
test('renders no-longer-valid, invalid lookup, and generic error states', () => {
|
|
mocks.useInvitationQuery.mockReturnValueOnce({
|
|
data: undefined,
|
|
error: responseError('Failed to retrieve organization', 401),
|
|
isSuccess: false,
|
|
isError: true,
|
|
isPending: false,
|
|
})
|
|
|
|
const { rerender } = render(<OrganizationInvite />)
|
|
|
|
expect(screen.getByText('Invite no longer available')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText('This invite has already been accepted or declined.')
|
|
).toBeInTheDocument()
|
|
expect(screen.getByRole('link', { name: 'Back to dashboard' })).toHaveAttribute('href', '/')
|
|
|
|
mocks.useInvitationQuery.mockReturnValueOnce({
|
|
data: undefined,
|
|
error: responseError('Not Found', 404),
|
|
isSuccess: false,
|
|
isError: true,
|
|
isPending: false,
|
|
})
|
|
|
|
rerender(<OrganizationInvite />)
|
|
|
|
expect(screen.getByText('Invite invalid')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText(
|
|
'Open the full invite link again, or ask the organization owner for a new invite.'
|
|
)
|
|
).toBeInTheDocument()
|
|
expect(screen.queryByText('Not Found')).not.toBeInTheDocument()
|
|
|
|
mocks.useInvitationQuery.mockReturnValueOnce({
|
|
data: undefined,
|
|
error: responseError('Failed to retrieve token', 500),
|
|
isSuccess: false,
|
|
isError: true,
|
|
isPending: false,
|
|
})
|
|
|
|
rerender(<OrganizationInvite />)
|
|
|
|
expect(screen.getByText('Unable to load invitation')).toBeInTheDocument()
|
|
expect(screen.getByText('Failed to retrieve token')).toBeInTheDocument()
|
|
})
|
|
})
|