Files
TREK/client/src/components/Trips/TripMembersModal.test.tsx
T
Maurice 92181cdefb test(trips): the suites follow the settings dialog's areas and the journal link
The dialog's nav buttons are named by their area alone (aria-label), so a hint line naming invites no longer matches the invite button. The sharing cases open the Sharing area first, the wiring and More sheet tests drive the settings button and row, the hook test reads the toasts as keys (renderHook loads no locale), and the lazy list and store registry know the journal panel and store.
2026-10-18 20:49:38 +02:00

1185 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-061
import type { Mock } from 'vitest';
import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import TripMembersModal from './TripMembersModal';
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
tripId: 1,
tripTitle: 'Test Trip',
};
const ownerUser = buildUser({ id: 1, username: 'owner' });
const memberUser = buildUser({ id: 2, username: 'alice' });
type AddToast = NonNullable<typeof window.__addToast>;
let addToast: Mock<AddToast>;
interface MemberRow {
id: number;
username: string;
avatar_url?: string | null;
is_guest?: boolean;
}
/** Replaces the roster handler; `owner` defaults to the seeded owner user. */
function mockRoster(members: MemberRow[], owner: MemberRow = { id: ownerUser.id, username: ownerUser.username, avatar_url: null }): void {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({ owner, members, current_user_id: ownerUser.id })
),
);
}
/** Owner-only permissions so the share/invite column renders for the seeded owner. */
function asShareOwner(): void {
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })));
}
/** A browser on https, where the async clipboard API is the path taken. */
function mockClipboard(): Mock<(text: string) => Promise<void>> {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true });
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true });
return writeText;
}
/**
* A `location` whose `reload` is a spy.
*
* jsdom's own `reload` cannot be redefined, so the whole object has to be
* replaced, and `{ ...window.location }` is the trap: its fields are getters on
* the prototype, so the copy has no `href` for axios to resolve a relative URL
* against and the request never reaches MSW. The fields are therefore read out
* by name.
*/
/**
* A `location` whose `reload` is a spy, for the two flows that reload the app.
*
* jsdom's own `reload` cannot be redefined, so the whole object is replaced, and
* the replacement is a flat copy without the prototype's getters. That is enough
* for a test that only asserts the reload, but it leaves a request with nothing
* to resolve a relative URL against, so a test that asserts the call itself must
* not use this. The original is put back after every test: left in place, it
* broke every later case in the file.
*/
let realLocation: PropertyDescriptor | undefined;
function stubReload(): Mock<() => void> {
const reload = vi.fn();
realLocation ??= Object.getOwnPropertyDescriptor(window, 'location');
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true, configurable: true });
return reload;
}
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
members: [],
current_user_id: ownerUser.id,
})
),
http.get('/api/trips/1/share-link', () =>
HttpResponse.json({ token: null })
),
http.get('/api/auth/users', () =>
HttpResponse.json({ users: [memberUser] })
),
);
seedStore(useAuthStore, { user: ownerUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Test Trip' }) });
addToast = vi.fn<AddToast>(() => 0);
window.__addToast = addToast;
});
afterEach(() => {
delete window.__addToast;
vi.useRealTimers();
vi.restoreAllMocks();
if (realLocation) {
Object.defineProperty(window, 'location', realLocation);
realLocation = undefined;
}
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
});
describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-001: renders without crashing', () => {
render(<TripMembersModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
render(<TripMembersModal {...defaultProps} />);
// trip.settings.title = "Trip settings"
expect(screen.getByText('Trip settings')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText('owner')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-004: shows Owner label', async () => {
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText('Owner')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-005: shows Access section heading', async () => {
render(<TripMembersModal {...defaultProps} />);
// Text is "Access (1 person)" so use regex
expect(await screen.findByText(/Access/i)).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-006: shows member when members are loaded', async () => {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
current_user_id: ownerUser.id,
})
)
);
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText('alice')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-007: shows Invite User section', async () => {
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText('Invite User')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-008: shows Invite button', async () => {
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByRole('button', { name: /Invite/i })).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-009: Cancel/close button is present', () => {
render(<TripMembersModal {...defaultProps} />);
// Modal has a close button (×)
const closeBtn = screen.queryByRole('button', { name: /close/i }) || document.querySelector('[aria-label="close"], button[title="Close"]');
// The modal renders at minimum a close button or can be closed by clicking overlay
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-010: shows member count of 1 with owner', async () => {
render(<TripMembersModal {...defaultProps} />);
// 1 person (just owner)
expect(await screen.findByText(/1 person/i)).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-011: members count increases when member is added', async () => {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
current_user_id: ownerUser.id,
})
)
);
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText(/2 people/i)).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-012: shows "you" label next to current user', async () => {
render(<TripMembersModal {...defaultProps} />);
// A badge beside the name on the desktop; the phone keeps "(you)".
expect(await screen.findByText('you')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-013: shows remove access button for members (not owner)', async () => {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
current_user_id: ownerUser.id,
})
)
);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
// Remove access button shown for members
expect(screen.getByRole('button', { name: 'Remove access' })).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-014: remove member calls DELETE API', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
current_user_id: ownerUser.id,
})
),
http.delete('/api/trips/1/members/:userId', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
await user.click(screen.getByRole('button', { name: 'Remove access' }));
// The question comes first, in the app's own dialog.
expect(screen.getByText('Remove access for this user?')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(deleteCalled).toBe(true));
});
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
render(<TripMembersModal {...defaultProps} isOpen={true} />);
expect(screen.getByText('Trip settings')).toBeInTheDocument();
});
// ── Share Link Section (016-021) ───────────────────────────────────────────
it('FE-COMP-MEMBERS-016: share link section not rendered for non-owner', async () => {
const nonOwner = buildUser({ id: 99, username: 'stranger' });
seedStore(useAuthStore, { user: nonOwner, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
render(<TripMembersModal {...defaultProps} />);
// Wait for members list to load so the component is fully rendered
await screen.findByText(/Access/i);
expect(screen.queryByText('Public Link')).not.toBeInTheDocument();
});
it('FE-COMP-MEMBERS-017: share link section visible for owner', async () => {
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText('Public Link')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-018: create share link shows URL after clicking create', async () => {
const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
// GET returns null token initially; POST returns a new token
server.use(
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })),
http.post('/api/trips/1/share-link', () =>
HttpResponse.json({
token: 'abc123',
share_map: true,
share_bookings: true,
share_packing: false,
share_budget: false,
share_collab: false,
})
),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
const createBtn = await screen.findByText('Create link');
await user.click(createBtn);
await waitFor(() => {
const input = screen.getByDisplayValue(/\/shared\/abc123/);
expect(input).toBeInTheDocument();
});
// Each toggle says whether it is on, not only by its colour (#2604).
expect(screen.getByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => {
const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
const writeText = mockClipboard();
server.use(
http.get('/api/trips/1/share-link', () =>
HttpResponse.json({
token: 'tok99',
share_map: true,
share_bookings: true,
share_packing: false,
share_budget: false,
share_collab: false,
})
),
);
render(<TripMembersModal {...defaultProps} />);
const copyBtn = await screen.findByText('Copy');
await user.click(copyBtn);
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('tok99'));
await screen.findByText('Copied');
});
it('FE-COMP-MEMBERS-020: delete share link removes URL and shows create button', async () => {
const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
let deleteHandlerCalled = false;
server.use(
http.get('/api/trips/1/share-link', () =>
HttpResponse.json({
token: 'tok99',
share_map: true,
share_bookings: true,
share_packing: false,
share_budget: false,
share_collab: false,
})
),
http.delete('/api/trips/1/share-link', () => {
deleteHandlerCalled = true;
return HttpResponse.json({ success: true });
}),
);
render(<TripMembersModal {...defaultProps} />);
const deleteBtn = await screen.findByText('Delete link');
await user.click(deleteBtn);
expect(deleteHandlerCalled).toBe(true);
await screen.findByText('Create link');
});
it('FE-COMP-MEMBERS-021: clicking permission toggle calls POST with updated perms', async () => {
const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
let postedPerms: Record<string, unknown> | null = null;
server.use(
http.get('/api/trips/1/share-link', () =>
HttpResponse.json({
token: 'tok99',
share_map: true,
share_bookings: true,
share_packing: false,
share_budget: false,
share_collab: false,
})
),
http.post('/api/trips/1/share-link', async ({ request }) => {
postedPerms = await request.json() as Record<string, unknown>;
return HttpResponse.json({ token: 'tok99', ...postedPerms });
}),
);
render(<TripMembersModal {...defaultProps} />);
// Wait for the share section to load
await screen.findByText('Public Link');
// Click the "Packing" permission pill to toggle it on
const packingBtn = await screen.findByText('Packing');
await user.click(packingBtn);
await waitFor(() => {
expect(postedPerms).not.toBeNull();
expect(postedPerms).toMatchObject({ share_packing: true });
});
});
// ── Member management (022-025) ────────────────────────────────────────────
it('FE-COMP-MEMBERS-022: adding a member via select + invite calls POST', async () => {
const user = userEvent.setup();
let postBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/members', async ({ request }) => {
postBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
render(<TripMembersModal {...defaultProps} />);
// Wait for Invite section to load
await screen.findByText('Invite User');
// Open the CustomSelect by clicking its trigger button (shows placeholder)
const selectTrigger = screen.getByText('Select user…');
await user.click(selectTrigger);
// alice option appears in the portal dropdown
const aliceOption = await screen.findByRole('button', { name: 'alice' });
await user.click(aliceOption);
// Click the member "Invite" button (exact — the Share area also has a
// "Create invite link" button that a loose /Invite/i would match too).
const inviteBtn = screen.getByRole('button', { name: 'Invite' });
await user.click(inviteBtn);
await waitFor(() => {
expect(postBody).not.toBeNull();
});
});
it('FE-COMP-MEMBERS-023: invite button is disabled when no user is selected', async () => {
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Invite User');
const inviteBtn = screen.getByRole('button', { name: /Invite/i });
expect(inviteBtn).toBeDisabled();
});
it('FE-COMP-MEMBERS-024: leave trip calls DELETE for current user', async () => {
const user = userEvent.setup();
// No reload stub here on purpose: this case asserts the DELETE, and a stubbed
// location is exactly what stops the request from reaching it. jsdom's own
// reload is a no-op that only logs.
seedStore(useAuthStore, { user: memberUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
let deleteCalledForUserId: string | null = null;
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
current_user_id: memberUser.id,
})
),
http.delete('/api/trips/1/members/:userId', ({ params }) => {
deleteCalledForUserId = params.userId as string;
return HttpResponse.json({ success: true });
}),
);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
await user.click(screen.getByRole('button', { name: 'Leave trip' }));
expect(screen.getByText('Leave trip? You will lose access.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => {
expect(deleteCalledForUserId).toBe(String(memberUser.id));
});
});
it('FE-COMP-MEMBERS-025: "all have access" message shown when all users are members', async () => {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
current_user_id: ownerUser.id,
})
),
http.get('/api/auth/users', () =>
HttpResponse.json({ users: [memberUser] })
),
);
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText('All users already have access.')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-026: owner sees the guests section and can add a guest (#1362)', async () => {
let createdName: string | null = null;
server.use(
http.post('/api/trips/1/guests', async ({ request }) => {
createdName = ((await request.json()) as { name: string }).name;
return HttpResponse.json({ member: { id: 99, username: createdName, is_guest: true } });
}),
);
render(<TripMembersModal {...defaultProps} />);
// The guests section + add affordance is shown to the owner.
await screen.findByText('Guests');
const input = screen.getByPlaceholderText('Guest name');
await userEvent.type(input, 'Grandpa');
await userEvent.click(screen.getByRole('button', { name: /Add guest/i }));
await waitFor(() => expect(createdName).toBe('Grandpa'));
});
it('FE-COMP-MEMBERS-027: a guest member is shown in the guests section with a Guest badge, not the members list (#1362)', async () => {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null, is_guest: false },
members: [
{ id: 2, username: 'alice', avatar_url: null, is_guest: false },
{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true },
],
current_user_id: ownerUser.id,
})
),
);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Grandma');
// The guest carries a "Guest" badge.
expect(screen.getAllByText('Guest').length).toBeGreaterThan(0);
// The head band counts the owner and the real member (2), not the guest.
expect(screen.getByText('2 people')).toBeInTheDocument();
});
// ── Avatars and load failures (028-031) ───────────────────────────────────
it('FE-COMP-MEMBERS-028: a member with an avatar renders the image instead of the initial', async () => {
mockRoster([{ id: 2, username: 'alice', avatar_url: '/uploads/avatars/alice.png' }]);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).toBeInTheDocument();
// The owner has no avatar and still gets a letter tile.
expect(screen.getByText('O')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-029: a nameless member falls back to a question mark', async () => {
mockRoster([], { id: ownerUser.id, username: '', avatar_url: null });
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText('?')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-030: a failing roster load shows an error toast and an empty list', async () => {
server.use(http.get('/api/trips/1/members', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />);
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to load members', 'error', undefined));
expect(screen.getByText('0 people')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-031: a failing share-link load still offers link creation', async () => {
asShareOwner();
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Create link');
expect(screen.getByText('Public Link')).toBeInTheDocument();
});
// ── Share link errors and defaults (032-036) ──────────────────────────────
it('FE-COMP-MEMBERS-032: a failing share-link creation is reported', async () => {
const user = userEvent.setup();
asShareOwner();
server.use(http.post('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByText('Create link'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined));
expect(screen.getByText('Create link')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-033: missing permission flags fall back to map+bookings, and Map is not toggleable', async () => {
const user = userEvent.setup();
asShareOwner();
let postedPerms: Record<string, unknown> | null = null;
server.use(
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })),
http.post('/api/trips/1/share-link', async ({ request }) => {
postedPerms = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ token: 'tok77', ...postedPerms });
}),
);
render(<TripMembersModal {...defaultProps} />);
// The always-on Map & Plan pill ignores clicks.
await user.click(await screen.findByText('Map & Plan'));
expect(postedPerms).toBeNull();
await user.click(screen.getByText('Costs'));
await waitFor(() => expect(postedPerms).toMatchObject({
share_map: true, share_bookings: true, share_packing: false, share_budget: true, share_collab: false,
}));
});
it('FE-COMP-MEMBERS-033b: the two narrowing options post with the link (#1712)', async () => {
const user = userEvent.setup();
asShareOwner();
let postedPerms: Record<string, unknown> | null = null;
server.use(
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77', share_hide_images: true })),
http.post('/api/trips/1/share-link', async ({ request }) => {
postedPerms = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ token: 'tok77' });
}),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByText('Travel & stays only'));
await waitFor(() => expect(postedPerms).toMatchObject({ share_travel_only: true, share_hide_images: true }));
});
it('FE-COMP-MEMBERS-034: a failing permission update is reported', async () => {
const user = userEvent.setup();
asShareOwner();
server.use(
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })),
http.post('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByText('Packing'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined));
});
it('FE-COMP-MEMBERS-035: a failing share-link deletion keeps the link', async () => {
const user = userEvent.setup();
asShareOwner();
server.use(
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })),
http.delete('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByText('Delete link'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByDisplayValue(/\/shared\/tok77/)).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-036: the copied badge resets two seconds after the last copy', async () => {
asShareOwner();
const writeText = mockClipboard();
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })));
const view = render(<TripMembersModal {...defaultProps} />);
const copyBtn = (await screen.findByText('Copy')).closest('button')!;
vi.useFakeTimers();
fireEvent.click(copyBtn);
// The copy is awaited now, so the badge lands a microtask after the click.
await act(async () => {});
expect(screen.getByText('Copied')).toBeInTheDocument();
// A second copy replaces the pending reset rather than stacking timers.
fireEvent.click(screen.getByText('Copied').closest('button')!);
await act(async () => {});
act(() => { vi.advanceTimersByTime(2000); });
expect(screen.getByText('Copy')).toBeInTheDocument();
expect(writeText).toHaveBeenCalledTimes(2);
view.unmount();
});
// ── Trip invite link (037-039) ────────────────────────────────────────────
it('FE-COMP-MEMBERS-037: an invite link can be created, copied, regenerated and disabled', async () => {
const user = userEvent.setup();
asShareOwner();
const writeText = mockClipboard();
const tokens = ['inv1', 'inv2'];
server.use(
http.post('/api/trips/1/invite-link', async () => {
await delay(20);
return HttpResponse.json({ token: tokens.shift() });
}),
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
);
const view = render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
fireEvent.click(createBtn);
expect(createBtn).toBeDisabled();
await screen.findByDisplayValue(/\/join\/inv1$/);
await user.click(screen.getByText('Copy').closest('button')!);
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/join/inv1'));
await screen.findByText('Copied');
// Copying again swaps the pending reset instead of queueing a second one.
await user.click(screen.getByText('Copied').closest('button')!);
const regenerate = screen.getByRole('button', { name: /Regenerate/i });
fireEvent.click(regenerate);
expect(regenerate).toBeDisabled();
expect(screen.getByRole('button', { name: /Disable/i })).toBeDisabled();
await screen.findByDisplayValue(/\/join\/inv2$/);
await user.click(screen.getByRole('button', { name: /Disable/i }));
await screen.findByRole('button', { name: /Create invite link/i });
view.unmount();
});
it('FE-COMP-MEMBERS-038: a failing invite-link creation is reported', async () => {
const user = userEvent.setup();
asShareOwner();
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined));
expect(screen.getByRole('button', { name: /Create invite link/i })).toBeEnabled();
});
it('FE-COMP-MEMBERS-039: a failing invite-link removal keeps the link', async () => {
const user = userEvent.setup();
asShareOwner();
server.use(
http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv9' })),
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
await user.click(await screen.findByRole('button', { name: /Disable/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByDisplayValue(/\/join\/inv9$/)).toBeInTheDocument();
});
// ── Member management errors and ownership transfer (040-045) ─────────────
it('FE-COMP-MEMBERS-040: a failing invite surfaces the server error', async () => {
const user = userEvent.setup();
server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ error: 'User is already a member' }, { status: 409 })));
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Invite User');
await user.click(screen.getByText('Select user…'));
await user.click(await screen.findByRole('button', { name: 'alice' }));
await user.click(screen.getByRole('button', { name: 'Invite' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('User is already a member', 'error', undefined));
});
it('FE-COMP-MEMBERS-041: a successful invite reloads the roster and notifies the planner', async () => {
const user = userEvent.setup();
const onMembersChanged = vi.fn();
server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ success: true })));
render(<TripMembersModal {...defaultProps} onMembersChanged={onMembersChanged} />);
await screen.findByText('Invite User');
await user.click(screen.getByText('Select user…'));
await user.click(await screen.findByRole('button', { name: 'alice' }));
await user.click(screen.getByRole('button', { name: 'Invite' }));
await waitFor(() => expect(onMembersChanged).toHaveBeenCalled());
expect(addToast).toHaveBeenCalledWith('alice added', 'success', undefined);
});
// #2478: the pick showed nowhere, only the Invite button knew about it.
it('FE-COMP-MEMBERS-057: the picked user shows in the field before the invite is sent', async () => {
const user = userEvent.setup();
server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ success: true })));
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Invite User');
const trigger = screen.getByText('Select user…').closest('button') as HTMLButtonElement;
await user.click(trigger);
await user.click(await screen.findByRole('button', { name: 'alice' }));
expect(trigger).toHaveTextContent('alice');
expect(screen.queryByText('Select user…')).toBeNull();
expect(screen.getByRole('button', { name: 'Invite' })).toBeEnabled();
// Once sent, the field is free for the next pick again.
await user.click(screen.getByRole('button', { name: 'Invite' }));
await waitFor(() => expect(trigger).toHaveTextContent('Select user…'));
});
it('FE-COMP-MEMBERS-042: transferring ownership reloads the app', async () => {
const onClose = vi.fn();
const reload = stubReload();
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let transferBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/transfer', async ({ request }) => {
transferBody = (await request.json()) as Record<string, unknown>;
await delay(20);
return HttpResponse.json({ success: true });
}),
);
render(<TripMembersModal {...defaultProps} onClose={onClose} />);
const crown = await screen.findByRole('button', { name: 'Make owner' });
fireEvent.click(crown);
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
expect(crown).toBeDisabled();
await waitFor(() => expect(reload).toHaveBeenCalled());
expect(transferBody).toMatchObject({ newOwnerId: memberUser.id });
expect(onClose).toHaveBeenCalled();
});
it('FE-COMP-MEMBERS-043: declining the transfer confirmation does nothing', async () => {
const user = userEvent.setup();
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let transferCalled = false;
server.use(http.post('/api/trips/1/transfer', () => { transferCalled = true; return HttpResponse.json({}); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
expect(screen.getByText('Transfer ownership to alice? You will become a regular member.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByText('Transfer ownership to alice? You will become a regular member.')).not.toBeInTheDocument();
expect(transferCalled).toBe(false);
});
it('FE-COMP-MEMBERS-044: a failing transfer re-enables the button', async () => {
const user = userEvent.setup();
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
server.use(http.post('/api/trips/1/transfer', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined));
expect(screen.getByRole('button', { name: 'Make owner' })).toBeEnabled();
});
it('FE-COMP-MEMBERS-045: member removal is cancellable and reports failures', async () => {
const user = userEvent.setup();
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let deletes = 0;
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({}, { status: 500 }); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(deletes).toBe(0);
await user.click(screen.getByRole('button', { name: 'Remove access' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined));
expect(screen.getByText('alice')).toBeInTheDocument();
});
// ── Guests (046-051) ──────────────────────────────────────────────────────
const guestRow = { id: 3, username: 'Grandma', avatar_url: null, is_guest: true };
it('FE-COMP-MEMBERS-046: pressing Enter adds a guest, an empty name is ignored', async () => {
const user = userEvent.setup();
const onMembersChanged = vi.fn();
let createdName: string | null = null;
server.use(
http.post('/api/trips/1/guests', async ({ request }) => {
createdName = ((await request.json()) as { name: string }).name;
return HttpResponse.json({ member: { id: 9, username: createdName, is_guest: true } });
}),
);
render(<TripMembersModal {...defaultProps} onMembersChanged={onMembersChanged} />);
const input = await screen.findByPlaceholderText('Guest name');
fireEvent.keyDown(input, { key: 'Enter' });
expect(createdName).toBeNull();
await user.type(input, 'Grandpa');
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(createdName).toBe('Grandpa'));
expect(addToast).toHaveBeenCalledWith('Guest added', 'success', undefined);
await waitFor(() => expect(onMembersChanged).toHaveBeenCalled());
});
it('FE-COMP-MEMBERS-047: a failing guest creation is reported', async () => {
const user = userEvent.setup();
server.use(http.post('/api/trips/1/guests', () => HttpResponse.json({ error: 'Guest limit reached' }, { status: 400 })));
render(<TripMembersModal {...defaultProps} />);
await user.type(await screen.findByPlaceholderText('Guest name'), 'Grandpa');
await user.click(screen.getByRole('button', { name: /Add guest/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Guest limit reached', 'error', undefined));
});
it('FE-COMP-MEMBERS-048: renaming a guest saves on Enter', async () => {
const user = userEvent.setup();
mockRoster([guestRow]);
let renamed: { id: string; name: string } | null = null;
server.use(
http.put('/api/trips/1/guests/:userId', async ({ params, request }) => {
renamed = { id: params.userId as string, name: ((await request.json()) as { name: string }).name };
return HttpResponse.json({ success: true });
}),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: 'Granny' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(renamed).toEqual({ id: '3', name: 'Granny' }));
await waitFor(() => expect(screen.queryByDisplayValue('Granny')).not.toBeInTheDocument());
});
it('FE-COMP-MEMBERS-048b: Enter followed by a blur renames only once', async () => {
const user = userEvent.setup();
mockRoster([guestRow]);
let renames = 0;
server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: 'Granny' } });
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.blur(input);
await waitFor(() => expect(screen.queryByDisplayValue('Granny')).not.toBeInTheDocument());
expect(renames).toBe(1);
});
it('FE-COMP-MEMBERS-049: Escape cancels a rename and a blank name saves nothing', async () => {
const user = userEvent.setup();
mockRoster([guestRow]);
let renames = 0;
server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Rename' }));
fireEvent.keyDown(screen.getByDisplayValue('Grandma'), { key: 'Escape' });
expect(screen.queryByDisplayValue('Grandma')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: ' ' } });
fireEvent.blur(input);
await waitFor(() => expect(screen.queryByDisplayValue(' ')).not.toBeInTheDocument());
expect(renames).toBe(0);
});
it('FE-COMP-MEMBERS-050: a failing rename is reported', async () => {
const user = userEvent.setup();
mockRoster([guestRow]);
server.use(http.put('/api/trips/1/guests/:userId', () => HttpResponse.json({ error: 'Name taken' }, { status: 409 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Rename' }));
const input = screen.getByDisplayValue('Grandma');
fireEvent.change(input, { target: { value: 'Granny' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined));
});
it('FE-COMP-MEMBERS-051: removing a guest is confirmed, refreshes costs and notifies', async () => {
const user = userEvent.setup();
const onMembersChanged = vi.fn();
mockRoster([guestRow]);
let deletedId: string | null = null;
server.use(
http.delete('/api/trips/1/guests/:userId', async ({ params }) => {
deletedId = params.userId as string;
await delay(20);
return HttpResponse.json({ success: true });
}),
);
render(<TripMembersModal {...defaultProps} onMembersChanged={onMembersChanged} />);
const trash = await screen.findByRole('button', { name: 'Remove access' });
await user.click(trash);
expect(screen.getByText('Remove this guest? Their assignments are removed and their share of each expense goes to the others in its split.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(deletedId).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Remove access' }));
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Remove access' })).toBeDisabled());
await waitFor(() => expect(deletedId).toBe('3'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Guest removed', 'success', undefined));
expect(onMembersChanged).toHaveBeenCalled();
});
it('FE-COMP-MEMBERS-052: a failing guest removal is reported', async () => {
const user = userEvent.setup();
mockRoster([guestRow]);
server.use(http.delete('/api/trips/1/guests/:userId', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
await user.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined));
expect(screen.getByText('Grandma')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-061: a guest who paid for something is kept, with the reason (#2604)', async () => {
const user = userEvent.setup();
mockRoster([guestRow]);
server.use(http.delete('/api/trips/1/guests/:userId', () => HttpResponse.json({ error: 'refused' }, { status: 409 })));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
await user.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith(
'This guest paid for an expense or is part of a recorded payment. Change the payer or delete the payment first.',
'error',
undefined,
));
expect(screen.getByText('Grandma')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-053: without member_manage a plain member sees no invite or guest controls', async () => {
const stranger = buildUser({ id: 99, username: 'stranger' });
seedStore(useAuthStore, { user: stranger, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
seedStore(usePermissionsStore, { permissions: { member_manage: 'trip_owner', share_manage: 'trip_owner' } });
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('alice');
expect(screen.queryByText('Invite User')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('Guest name')).not.toBeInTheDocument();
expect(screen.queryByText('Guests')).not.toBeInTheDocument();
expect(screen.queryByText('Public Link')).not.toBeInTheDocument();
// Nothing to remove either — a plain member can only leave themselves.
expect(screen.queryByRole('button', { name: 'Remove access' })).not.toBeInTheDocument();
});
// A self-hosted install served over plain HTTP has no navigator.clipboard, and
// the unguarded call threw before the button ever showed feedback.
it('FE-COMP-MEMBERS-055: the share link copies through execCommand without a clipboard API', async () => {
const user = userEvent.setup();
asShareOwner();
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true });
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
const execCommand = vi.fn(() => true);
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true });
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok55' })));
render(<TripMembersModal {...defaultProps} />);
await user.click((await screen.findByText('Copy')).closest('button')!);
expect(execCommand).toHaveBeenCalledWith('copy');
await screen.findByText('Copied');
expect(document.querySelector('textarea')).toBeNull();
});
it('FE-COMP-MEMBERS-056: the invite link copies through execCommand without a clipboard API', async () => {
const user = userEvent.setup();
asShareOwner();
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true });
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
const execCommand = vi.fn(() => true);
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true });
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv56' })));
render(<TripMembersModal {...defaultProps} />);
await user.click((await screen.findByText('Copy')).closest('button')!);
expect(execCommand).toHaveBeenCalledWith('copy');
await screen.findByText('Copied');
expect(document.querySelector('textarea')).toBeNull();
});
it('FE-COMP-MEMBERS-054: the invite-link copied badge resets after two seconds', async () => {
asShareOwner();
const writeText = mockClipboard();
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv9' })));
const view = render(<TripMembersModal {...defaultProps} />);
const copyBtn = (await screen.findByText('Copy')).closest('button')!;
vi.useFakeTimers();
fireEvent.click(copyBtn);
await act(async () => {});
expect(screen.getByText('Copied')).toBeInTheDocument();
act(() => { vi.advanceTimersByTime(2000); });
expect(screen.getByText('Copy')).toBeInTheDocument();
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/join/inv9'));
view.unmount();
});
});
describe('TripMembersModal on a phone', () => {
let width: number;
beforeEach(() => {
width = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true });
});
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
});
it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => {
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({
token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false,
})));
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true');
});
it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => {
render(<TripMembersModal {...defaultProps} />);
expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument();
expect(screen.getByText(/Access \(1/)).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
let deletes = 0;
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); }));
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Remove access'));
expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?');
expect(deletes).toBe(0);
confirmSpy.mockReturnValue(true);
await user.click(screen.getByTitle('Remove access'));
await waitFor(() => expect(deletes).toBe(1));
});
it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]);
let renamed: string | null = null;
let deleted = false;
server.use(
http.put('/api/trips/1/guests/:userId', async ({ request }) => {
renamed = ((await request.json()) as { name: string }).name;
return HttpResponse.json({ success: true });
}),
http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByTitle('Rename'));
const input = screen.getByDisplayValue('Grandma');
fireEvent.keyDown(input, { key: 'Escape' });
await user.click(screen.getByTitle('Rename'));
fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } });
fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' });
await waitFor(() => expect(renamed).toBe('Granny'));
await user.click(screen.getByTitle('Remove access'));
await waitFor(() => expect(deleted).toBe(true));
});
});