fix(web): SpaceInviteCard navigates to space on already-member instead of showing error

This commit is contained in:
Jannis Braun
2026-04-29 22:41:49 +02:00
parent bc66ddc633
commit d8b08d8990
2 changed files with 33 additions and 2 deletions
@@ -3,11 +3,12 @@ import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { SpaceInviteCard } from './SpaceInviteCard'; import { SpaceInviteCard } from './SpaceInviteCard';
const { mockJoinByCode, mockGetApiForOrigin } = vi.hoisted(() => ({ const { mockJoinByCode, mockGetApiForOrigin, mockNavigate } = vi.hoisted(() => ({
mockJoinByCode: vi.fn(), mockJoinByCode: vi.fn(),
mockGetApiForOrigin: vi.fn(() => ({ mockGetApiForOrigin: vi.fn(() => ({
spaces: { invitePreview: vi.fn() }, spaces: { invitePreview: vi.fn() },
})), })),
mockNavigate: vi.fn(),
})); }));
vi.mock('../../stores/spaceStore', () => ({ vi.mock('../../stores/spaceStore', () => ({
useSpaceStore: (selector: any) => selector({ joinByCode: mockJoinByCode }), useSpaceStore: (selector: any) => selector({ joinByCode: mockJoinByCode }),
@@ -16,6 +17,10 @@ vi.mock('../../stores/spaceStore', () => ({
vi.mock('../../api/client', () => ({ vi.mock('../../api/client', () => ({
createApiClient: vi.fn(), createApiClient: vi.fn(),
})); }));
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<typeof import('react-router-dom')>('react-router-dom')),
useNavigate: () => mockNavigate,
}));
const basePayload = { const basePayload = {
event: 'space_invite' as const, event: 'space_invite' as const,
@@ -36,6 +41,7 @@ describe('SpaceInviteCard', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockJoinByCode.mockReset(); mockJoinByCode.mockReset();
mockNavigate.mockReset();
mockJoinByCode.mockResolvedValue({ id: 'S1', name: 'Aether' }); mockJoinByCode.mockResolvedValue({ id: 'S1', name: 'Aether' });
}); });
@@ -91,4 +97,21 @@ describe('SpaceInviteCard', () => {
expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', ''); expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', '');
expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', undefined); expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', undefined);
}); });
it('navigates to space when join returns "already a member" (no error shown)', async () => {
const userEvent = (await import('@testing-library/user-event')).default;
const user = userEvent.setup();
mockJoinByCode.mockRejectedValueOnce(new Error('You are already a member of this space'));
mockGetApiForOrigin.mockReturnValue({
spaces: { invitePreview: vi.fn().mockResolvedValue({ ...basePayload.snapshot, spaceId: 'S1' }) },
});
render(<MemoryRouter><SpaceInviteCard payload={basePayload} senderName="Alice" /></MemoryRouter>);
const btn = await screen.findByRole('button', { name: /^join$/i });
await user.click(btn);
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/spaces/S1'));
expect(screen.queryByText(/already a member of this space/i)).not.toBeInTheDocument();
});
}); });
@@ -56,7 +56,15 @@ export function SpaceInviteCard({ payload, senderName }: Props) {
const space = await joinByCode(payload.inviteCode, payload.spaceInstanceOrigin || undefined); const space = await joinByCode(payload.inviteCode, payload.spaceInstanceOrigin || undefined);
navigate(`/spaces/${space.id}`); navigate(`/spaces/${space.id}`);
} catch (err) { } catch (err) {
setJoinError((err as Error)?.message ?? 'Failed to join'); const msg = (err as Error)?.message ?? '';
if (msg.toLowerCase().includes('already a member')) {
// Already a member is a successful state — just navigate to the space.
// Look up the space in the store by id; if not found (rare race), stay
// silent rather than block the user with a noisy error.
navigate(`/spaces/${payload.spaceId}`);
return;
}
setJoinError(msg || 'Failed to join');
setJoining(false); setJoining(false);
} }
}; };