OrganizationInvite.test.tsx249 lines · main
1import { screen, waitFor } from '@testing-library/react'
2import userEvent from '@testing-library/user-event'
3import { beforeEach, describe, expect, test, vi } from 'vitest'
4
5import { OrganizationInvite } from '@/components/interfaces/OrganizationInvite/OrganizationInvite'
6import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query'
7import type { ProfileContextType } from '@/lib/profile'
8import { render } from '@/tests/helpers'
9import type { ResponseError } from '@/types'
10
11const mocks = vi.hoisted(() => ({
12 isLoggedIn: vi.fn(),
13 useParams: vi.fn(),
14 useProfile: vi.fn(),
15 useProfileNameAndPicture: vi.fn(),
16 useIsFeatureEnabled: vi.fn(),
17 useInvitationQuery: vi.fn(),
18 useAcceptInvitationMutation: vi.fn(),
19 acceptInvitation: vi.fn(),
20 signOut: vi.fn(),
21 routerPush: vi.fn(),
22 routerReload: vi.fn(),
23}))
24
25vi.mock('common', async (importOriginal) => {
26 const actual = (await importOriginal()) as typeof import('common')
27 return {
28 ...actual,
29 useIsLoggedIn: mocks.isLoggedIn,
30 useParams: mocks.useParams,
31 }
32})
33
34vi.mock('next/router', () => ({
35 useRouter: () => ({
36 isReady: true,
37 push: mocks.routerPush,
38 reload: mocks.routerReload,
39 }),
40}))
41
42vi.mock('@/lib/profile', () => ({
43 useProfile: mocks.useProfile,
44 useProfileNameAndPicture: mocks.useProfileNameAndPicture,
45}))
46
47vi.mock('@/lib/auth', () => ({
48 useSignOut: () => mocks.signOut,
49}))
50
51vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
52 useIsFeatureEnabled: mocks.useIsFeatureEnabled,
53}))
54
55vi.mock('@/data/organization-members/organization-invitation-token-query', () => ({
56 useOrganizationInvitationTokenQuery: mocks.useInvitationQuery,
57}))
58
59vi.mock('@/data/organization-members/organization-invitation-accept-mutation', () => ({
60 useOrganizationAcceptInvitationMutation: mocks.useAcceptInvitationMutation,
61}))
62
63const READY_INVITE: OrganizationInviteByToken = {
64 authorized_user: true,
65 email_match: true,
66 expired_token: false,
67 invite_id: 42,
68 organization_name: 'Acme Corp',
69 sso_mismatch: false,
70 token_does_not_exist: false,
71}
72
73const PROFILE: ProfileContextType['profile'] = {
74 id: 1,
75 auth0_id: 'auth0|test',
76 gotrue_id: 'gotrue-test',
77 username: 'jane',
78 primary_email: 'jane@acmecorp.io',
79 first_name: null,
80 last_name: null,
81 mobile: null,
82 is_alpha_user: false,
83 is_sso_user: false,
84 disabled_features: [],
85 free_project_limit: null,
86}
87
88const responseError = (message: string, code = 500) => ({ message, code }) as ResponseError
89
90function setSignedInDefaults() {
91 mocks.isLoggedIn.mockReturnValue(true)
92 mocks.useParams.mockReturnValue({ slug: 'acme-corp', token: 'invite-token' })
93 mocks.useProfile.mockReturnValue({
94 profile: PROFILE,
95 isLoading: false,
96 })
97 mocks.useProfileNameAndPicture.mockReturnValue({
98 username: 'jane',
99 primaryEmail: 'jane@acmecorp.io',
100 avatarUrl: undefined,
101 isLoading: false,
102 })
103 mocks.useIsFeatureEnabled.mockReturnValue(true)
104 mocks.useInvitationQuery.mockReturnValue({
105 data: READY_INVITE,
106 error: null,
107 isSuccess: true,
108 isError: false,
109 isPending: false,
110 })
111 mocks.useAcceptInvitationMutation.mockReturnValue({
112 mutate: mocks.acceptInvitation,
113 isPending: false,
114 })
115}
116
117describe('OrganizationInvite', () => {
118 beforeEach(() => {
119 vi.clearAllMocks()
120 setSignedInDefaults()
121 })
122
123 test('renders sign-in actions for signed-out users', () => {
124 mocks.isLoggedIn.mockReturnValue(false)
125 mocks.useProfile.mockReturnValue({ profile: null, isLoading: true })
126 mocks.useProfileNameAndPicture.mockReturnValue({
127 username: undefined,
128 primaryEmail: undefined,
129 avatarUrl: undefined,
130 isLoading: false,
131 })
132
133 render(<OrganizationInvite />)
134
135 expect(screen.getByText('View invitation')).toBeInTheDocument()
136 expect(
137 screen.getByText('Sign in or create an account to view this invitation')
138 ).toBeInTheDocument()
139 expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute(
140 'href',
141 '/sign-in?returnTo=%2Fjoin%3Ftoken%3Dinvite-token%26slug%3Dacme-corp'
142 )
143 expect(screen.getByRole('link', { name: 'Create an account' })).toHaveAttribute(
144 'href',
145 '/sign-up?returnTo=%2Fjoin%3Ftoken%3Dinvite-token%26slug%3Dacme-corp'
146 )
147 })
148
149 test('renders a ready invite for the signed-in account', () => {
150 render(<OrganizationInvite />)
151
152 expect(screen.getByText('Join Acme Corp')).toBeInTheDocument()
153 expect(
154 screen.getByText('You have been invited to join this Briven organization')
155 ).toBeInTheDocument()
156 expect(screen.getByText('Signed in as')).toBeInTheDocument()
157 expect(screen.getByText('jane@acmecorp.io')).toBeInTheDocument()
158 expect(screen.getByRole('button', { name: 'Accept invite' })).toBeInTheDocument()
159 expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/projects')
160 })
161
162 test('accepts an invite with the current slug and token', async () => {
163 const user = userEvent.setup()
164
165 render(<OrganizationInvite />)
166
167 await user.click(screen.getByRole('button', { name: 'Accept invite' }))
168
169 expect(mocks.acceptInvitation).toHaveBeenCalledWith({
170 slug: 'acme-corp',
171 token: 'invite-token',
172 })
173 })
174
175 test('renders a wrong-account warning and signs out', async () => {
176 const user = userEvent.setup()
177 mocks.useInvitationQuery.mockReturnValue({
178 data: { ...READY_INVITE, email_match: false },
179 error: null,
180 isSuccess: true,
181 isError: false,
182 isPending: false,
183 })
184 mocks.signOut.mockResolvedValue(undefined)
185
186 render(<OrganizationInvite />)
187
188 expect(screen.getByText('Wrong account')).toBeInTheDocument()
189 expect(screen.queryByText('Join Acme Corp')).not.toBeInTheDocument()
190 expect(
191 screen.queryByText('You have been invited to join this Briven organization')
192 ).not.toBeInTheDocument()
193 expect(screen.getByText(/jane@acmecorp\.io/)).toBeInTheDocument()
194
195 await user.click(screen.getByRole('button', { name: 'Sign out' }))
196
197 await waitFor(() => expect(mocks.signOut).toHaveBeenCalled())
198 expect(mocks.routerReload).toHaveBeenCalled()
199 })
200
201 test('renders no-longer-valid, invalid lookup, and generic error states', () => {
202 mocks.useInvitationQuery.mockReturnValueOnce({
203 data: undefined,
204 error: responseError('Failed to retrieve organization', 401),
205 isSuccess: false,
206 isError: true,
207 isPending: false,
208 })
209
210 const { rerender } = render(<OrganizationInvite />)
211
212 expect(screen.getByText('Invite no longer available')).toBeInTheDocument()
213 expect(
214 screen.getByText('This invite has already been accepted or declined.')
215 ).toBeInTheDocument()
216 expect(screen.getByRole('link', { name: 'Back to dashboard' })).toHaveAttribute('href', '/')
217
218 mocks.useInvitationQuery.mockReturnValueOnce({
219 data: undefined,
220 error: responseError('Not Found', 404),
221 isSuccess: false,
222 isError: true,
223 isPending: false,
224 })
225
226 rerender(<OrganizationInvite />)
227
228 expect(screen.getByText('Invite invalid')).toBeInTheDocument()
229 expect(
230 screen.getByText(
231 'Open the full invite link again, or ask the organization owner for a new invite.'
232 )
233 ).toBeInTheDocument()
234 expect(screen.queryByText('Not Found')).not.toBeInTheDocument()
235
236 mocks.useInvitationQuery.mockReturnValueOnce({
237 data: undefined,
238 error: responseError('Failed to retrieve token', 500),
239 isSuccess: false,
240 isError: true,
241 isPending: false,
242 })
243
244 rerender(<OrganizationInvite />)
245
246 expect(screen.getByText('Unable to load invitation')).toBeInTheDocument()
247 expect(screen.getByText('Failed to retrieve token')).toBeInTheDocument()
248 })
249})