Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-switcher-mutation-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -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}` })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: <output>Loading</output> }, signedIn({ client: fapiClient() }));

expect(host()).toBeEmptyDOMElement();
});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(() => {});
Expand Down Expand Up @@ -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)] }));
Expand Down Expand Up @@ -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();
Expand Down
Loading