diff --git a/.changeset/mosaic-switcher-mutation-tests.md b/.changeset/mosaic-switcher-mutation-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-switcher-mutation-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 8a2cc51c25c..278fd3d049e 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -193,6 +193,56 @@ describe('OrganizationSwitcher', () => { expect(within(trigger()).getByText('Other')).toBeInTheDocument(); }); + describe('landing after an action', () => { + it('lands on afterSelectPersonalUrl, filled from the account, after switching to the personal account', async () => { + const { navigate } = await renderSwitcher({ afterSelectPersonalUrl: '/users/:id' }, noOffers()); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Personal account' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/users/user_1')); + }); + + it('lands where an afterSelectOrganizationUrl function points for the selected organization', async () => { + const { navigate } = await renderSwitcher( + { afterSelectOrganizationUrl: organization => `/orgs/${organization.name}` }, + noOffers(), + ); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Other' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/orgs/Other')); + }); + + it('tells the organization profile where to land after leaving', async () => { + const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(openOrganizationProfile).toHaveBeenCalledWith( + expect.objectContaining({ afterLeaveOrganizationUrl: '/left' }), + ); + }); + + it('tells create-organization where to land and whether to skip inviting members', async () => { + const { clerk } = await renderSwitcher( + { afterCreateOrganizationUrl: '/created', skipInvitationScreen: true }, + noOffers(), + ); + const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {}); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Add organization' })); + + expect(openCreateOrganization).toHaveBeenCalledWith( + expect.objectContaining({ afterCreateOrganizationUrl: '/created', skipInvitationScreen: true }), + ); + }); + }); + describe('an accepted invitation', () => { const fullFirstPage = Array.from({ length: 10 }, (_, i) => fapiMembership(fapiOrganization({ id: `org_page_${i}`, name: `Page ${i}` })), diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 27b2f9dc87a..4ebb5a240dd 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -174,8 +174,8 @@ describe('UserButton', () => { expect(trigger()).toBeInTheDocument(); }); - it('renders nothing when nobody is signed in', async () => { - await renderUserButton({ mode }, signedIn({ client: fapiClient() })); + it('renders nothing, not even its fallback, when nobody is signed in', async () => { + await renderUserButton({ mode, fallback: Loading }, signedIn({ client: fapiClient() })); expect(host()).toBeEmptyDOMElement(); }); @@ -290,6 +290,19 @@ describe('UserButton', () => { expect(popup()).toBeInTheDocument(); }); + it('lands a switched account where the instance sends it', async () => { + const { navigate } = await renderUserButton( + {}, + signedIn({ environment: fapiEnvironment({ display_config: { after_switch_session_url: '/switched' } }) }), + ); + const user = await open(); + + const menu = await openAccounts(user); + await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith(expect.stringMatching(/\/switched$/))); + }); + it('signs out of the active account alone while another stays signed in', async () => { const { fapi, navigate } = await renderUserButton(); const user = await open(); @@ -399,6 +412,39 @@ describe('UserButton', () => { expect(popup()).toBeInTheDocument(); }); + it('reports a failed accept, and drops an invitation that is no longer pending', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { fapi } = await renderUserButton({}, noOffers({ invitations: [fapiInvitation('inv_1', gamma)] })); + const user = await openWithList(); + const accept = holdRequests('post', '/v1/me/organization_invitations/:id/accept'); + + await user.click(screen.getByRole('button', { name: 'Accept' })); + fapi.invitations = [fapiInvitation('inv_1', gamma, { status: 'revoked' })]; + accept.fail('organization_invitation_not_pending'); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull()); + expect(reading('Gamma')).toEqual([]); + expect(log).toHaveBeenCalledWith( + '[Clerk] User button action failed', + expect.objectContaining({ errors: [expect.objectContaining({ code: 'organization_invitation_not_pending' })] }), + ); + expect(popup()).toBeInTheDocument(); + }); + + it('shows a suggestion as requested when a failed join finds it already accepted', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const { fapi } = await renderUserButton({}, noOffers({ suggestions: [fapiSuggestion('sug_1', beta)] })); + const user = await openWithList(); + const join = holdRequests('post', '/v1/me/organization_suggestions/:id/accept'); + + await user.click(screen.getByRole('button', { name: 'Join' })); + fapi.suggestions = [fapiSuggestion('sug_1', beta, { status: 'accepted' })]; + join.fail('organization_suggestion_already_accepted'); + + await waitFor(() => expect(reading('Beta', 'Requested')).toEqual(['Beta', 'Requested'])); + expect(screen.queryByRole('button', { name: 'Join' })).toBeNull(); + }); + it('spins inside the join button while the suggestion is joined', async () => { await renderUserButton(); const user = await open(); @@ -577,6 +623,21 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); }); + it('offers no invite to a member who cannot manage memberships', async () => { + const plain = [fapiMembership(acme), fapiMembership(other)]; + await renderUserButton( + {}, + signedIn({ + client: fapiClient([fapiSession({ ...aliceSession, user: { ...alice, organization_memberships: plain } })]), + memberships: plain, + }), + ); + await open(); + + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull(); + }); + it('opens the CreateOrganization modal and closes', async () => { const { clerk, navigate } = await renderUserButton(); const openCreateOrganization = vi.spyOn(clerk, 'openCreateOrganization').mockImplementation(() => {}); @@ -813,6 +874,19 @@ describe('UserButton', () => { expect(await screen.findByRole('button', { name: 'Org 15' })).toBeInTheDocument(); }); + it('loads the next page of invitations when the end of the list scrolls into view', async () => { + const many = Array.from({ length: 15 }, (_, i) => + fapiInvitation(`inv_p${i + 1}`, fapiOrganization({ id: `org_i${i + 1}`, name: `Invited ${i + 1}` })), + ); + await renderUserButton({}, noOffers({ invitations: many })); + await open(); + + const last = await screen.findByText('Invited 10'); + last.scrollIntoView(); + + expect(await screen.findByText('Invited 15')).toBeInTheDocument(); + }); + it('lists the active organization where its page puts it, and lists it once', async () => { const many = Array.from({ length: 15 }, (_, i) => fapiOrganization({ id: `org_p${i + 1}`, name: `Org ${i + 1}` })); await renderUserButton({}, signedIn({ memberships: [...many.map(o => fapiMembership(o)), fapiMembership(acme)] })); @@ -1077,25 +1151,6 @@ describe('UserButton', () => { expect(reading('Gamma', 'Personal account')).toEqual(['Personal account', 'Gamma']); }); - it('drops revoked and expired invitations, which have nothing to accept', async () => { - await renderUserButton( - {}, - noOffers({ - invitations: [ - fapiInvitation('inv_1', gamma, { status: 'revoked' }), - fapiInvitation('inv_2', beta, { status: 'expired' }), - ], - }), - ); - await openWithList(); - - expect(reading('Gamma', 'Beta', 'Personal account', 'Acme', 'Other')).toEqual([ - 'Personal account', - 'Acme', - 'Other', - ]); - }); - it('reports an accepted suggestion instead of offering to join it again', async () => { await renderUserButton({}, noOffers({ suggestions: [fapiSuggestion('sug_1', beta, { status: 'accepted' })] })); await openWithList();